impl accout management and refactor

This commit is contained in:
Sebastian 2023-02-08 17:41:19 -03:00
parent 9b0d887a1b
commit a8c5a9696c
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
34 changed files with 3534 additions and 1246 deletions

View File

@ -25,7 +25,7 @@
"preact": "10.11.3",
"preact-router": "3.2.1",
"qrcode-generator": "^1.4.4",
"swr": "1.3.0"
"swr": "2.0.3"
},
"eslintConfig": {
"plugins": [
@ -66,4 +66,4 @@
"pogen": {
"domain": "bank"
}
}
}

View File

@ -0,0 +1,69 @@
/*
This file is part of GNU Taler
(C) 2022 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 { HttpError, utils } from "@gnu-taler/web-util/lib/index.browser";
import { Loading } from "../Loading.js";
// import { compose, StateViewMap } from "../../utils/index.js";
// import { wxApi } from "../../wxApi.js";
import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
import { useComponentState } from "./state.js";
import { LoadingUriView, ReadyView } from "./views.js";
export interface Props {
account: string;
}
export type State = State.Loading | State.LoadingUriError | State.Ready;
export namespace State {
export interface Loading {
status: "loading";
error: undefined;
}
export interface LoadingUriError {
status: "loading-error";
error: HttpError<SandboxBackend.SandboxError>;
}
export interface BaseInfo {
error: undefined;
}
export interface Ready extends BaseInfo {
status: "ready";
error: undefined;
cashouts: SandboxBackend.Circuit.CashoutStatusResponse[];
}
}
export interface Transaction {
negative: boolean;
counterpart: string;
when: AbsoluteTime;
amount: AmountJson | undefined;
subject: string;
}
const viewMapping: utils.StateViewMap<State> = {
loading: Loading,
"loading-error": LoadingUriView,
ready: ReadyView,
};
export const Cashouts = utils.compose(
(p: Props) => useComponentState(p),
viewMapping,
);

View File

@ -0,0 +1,44 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
import { useCashouts } from "../../hooks/circuit.js";
import { Props, State, Transaction } from "./index.js";
export function useComponentState({
account,
}: Props): State {
const result = useCashouts()
if (result.loading) {
return {
status: "loading",
error: undefined
}
}
if (!result.ok) {
return {
status: "loading-error",
error: result
}
}
return {
status: "ready",
error: undefined,
cashout: result.data,
};
}

View File

@ -0,0 +1,45 @@
/*
This file is part of GNU Taler
(C) 2022 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/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { tests } from "@gnu-taler/web-util/lib/index.browser";
import { ReadyView } from "./views.js";
export default {
title: "transaction list",
};
export const Ready = tests.createExample(ReadyView, {
transactions: [
{
amount: {
currency: "USD",
fraction: 0,
value: 1,
},
counterpart: "ASD",
negative: false,
subject: "Some",
when: {
t_ms: new Date().getTime(),
},
},
],
});

View File

@ -0,0 +1,179 @@
/*
This file is part of GNU Taler
(C) 2022 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/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { tests } from "@gnu-taler/web-util/lib/index.browser";
import { SwrMockEnvironment } from "@gnu-taler/web-util/lib/tests/swr";
import { expect } from "chai";
import { TRANSACTION_API_EXAMPLE } from "../../endpoints.js";
import { Props } from "./index.js";
import { useComponentState } from "./state.js";
describe("Transaction states", () => {
it("should query backend and render transactions", async () => {
const env = new SwrMockEnvironment();
const props: Props = {
account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
response: {
transactions: [
{
creditorIban: "DE159593",
creditorBic: "SANDBOXX",
creditorName: "exchange company",
debtorIban: "DE118695",
debtorBic: "SANDBOXX",
debtorName: "Name unknown",
amount: "1",
currency: "KUDOS",
subject:
"Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410",
date: "2022-12-12Z",
uid: "8PPFR9EM",
direction: "DBIT",
pmtInfId: null,
msgId: null,
},
{
creditorIban: "DE159593",
creditorBic: "SANDBOXX",
creditorName: "exchange company",
debtorIban: "DE118695",
debtorBic: "SANDBOXX",
debtorName: "Name unknown",
amount: "5.00",
currency: "KUDOS",
subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0",
date: "2022-12-07Z",
uid: "7FZJC3RJ",
direction: "DBIT",
pmtInfId: null,
msgId: null,
},
{
creditorIban: "DE118695",
creditorBic: "SANDBOXX",
creditorName: "Name unknown",
debtorIban: "DE579516",
debtorBic: "SANDBOXX",
debtorName: "The Bank",
amount: "100",
currency: "KUDOS",
subject: "Sign-up bonus",
date: "2022-12-07Z",
uid: "I31A06J8",
direction: "CRDT",
pmtInfId: null,
msgId: null,
},
],
},
});
const hookBehavior = await tests.hookBehaveLikeThis(
useComponentState,
props,
[
({ status, error }) => {
expect(status).equals("loading");
expect(error).undefined;
},
({ status, error }) => {
expect(status).equals("ready");
expect(error).undefined;
},
],
env.buildTestingContext(),
);
expect(hookBehavior).deep.eq({ result: "ok" });
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
});
it("should show error message on not found", async () => {
const env = new SwrMockEnvironment();
const props: Props = {
account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
const hookBehavior = await tests.hookBehaveLikeThis(
useComponentState,
props,
[
({ status, error }) => {
expect(status).equals("loading");
expect(error).undefined;
},
({ status, error }) => {
expect(status).equals("loading-error");
expect(error).deep.eq({
hasError: true,
operational: false,
message: "Transactions page 0 was not found.",
});
},
],
env.buildTestingContext(),
);
expect(hookBehavior).deep.eq({ result: "ok" });
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
});
it("should show error message on server error", async () => {
const env = new SwrMockEnvironment(false);
const props: Props = {
account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});
const hookBehavior = await tests.hookBehaveLikeThis(
useComponentState,
props,
[
({ status, error }) => {
expect(status).equals("loading");
expect(error).undefined;
},
({ status, error }) => {
expect(status).equals("loading-error");
expect(error).deep.equal({
hasError: true,
operational: false,
message: "Transaction page 0 could not be retrieved.",
});
},
],
env.buildTestingContext(),
);
expect(hookBehavior).deep.eq({ result: "ok" });
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
});
});

View File

@ -0,0 +1,66 @@
/*
This file is part of GNU Taler
(C) 2022 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 { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { State } from "./index.js";
import { format } from "date-fns";
import { Amounts } from "@gnu-taler/taler-util";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
return (
<div>
<i18n.Translate>Could not load</i18n.Translate>
</div>
);
}
export function ReadyView({ cashouts }: State.Ready): VNode {
const { i18n } = useTranslationContext();
return (
<div class="results">
<table class="pure-table pure-table-striped">
<thead>
<tr>
<th>{i18n.str`Created`}</th>
<th>{i18n.str`Confirmed`}</th>
<th>{i18n.str`Counterpart`}</th>
<th>{i18n.str`Subject`}</th>
</tr>
</thead>
<tbody>
{cashouts.map((item, idx) => {
return (
<tr key={idx}>
<td>{format(item.creation_time, "dd/MM/yyyy HH:mm:ss")}</td>
<td>
{item.confirmation_time
? format(item.confirmation_time, "dd/MM/yyyy HH:mm:ss")
: "-"}
</td>
<td>{Amounts.stringifyValue(item.amount_credit)}</td>
<td>{item.counterpart}</td>
<td>{item.subject}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -17,5 +17,27 @@
import { h, VNode } from "preact";
export function Loading(): VNode {
return <div>loading...</div>;
return (
<div
class="columns is-centered is-vcentered"
style={{
height: "calc(100% - 3rem)",
position: "absolute",
width: "100%",
}}
>
<Spinner />
</div>
);
}
export function Spinner(): VNode {
return (
<div class="lds-ring">
<div />
<div />
<div />
<div />
</div>
);
}

View File

@ -14,18 +14,16 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { HttpError, utils } from "@gnu-taler/web-util/lib/index.browser";
import { Loading } from "../Loading.js";
import { HookError, utils } from "@gnu-taler/web-util/lib/index.browser";
// import { compose, StateViewMap } from "../../utils/index.js";
// import { wxApi } from "../../wxApi.js";
import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
import { useComponentState } from "./state.js";
import { LoadingUriView, ReadyView } from "./views.js";
import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
export interface Props {
pageNumber: number;
accountLabel: string;
balanceValue?: string;
account: string;
}
export type State = State.Loading | State.LoadingUriError | State.Ready;
@ -38,7 +36,7 @@ export namespace State {
export interface LoadingUriError {
status: "loading-error";
error: HookError;
error: HttpError<SandboxBackend.SandboxError>;
}
export interface BaseInfo {

View File

@ -15,66 +15,65 @@
*/
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
import { parse } from "date-fns";
import { useEffect } from "preact/hooks";
import useSWR from "swr";
import { Props, State } from "./index.js";
import { useTransactions } from "../../hooks/access.js";
import { Props, State, Transaction } from "./index.js";
export function useComponentState({
accountLabel,
pageNumber,
balanceValue,
account,
}: Props): State {
const { data, error, mutate } = useSWR(
`access-api/accounts/${accountLabel}/transactions?page=${pageNumber}`,
);
useEffect(() => {
if (balanceValue) {
mutate();
}
}, [balanceValue ?? ""]);
if (error) {
switch (error.status) {
case 404:
return {
status: "loading-error",
error: {
hasError: true,
operational: false,
message: `Transactions page ${pageNumber} was not found.`,
},
};
case 401:
return {
status: "loading-error",
error: {
hasError: true,
operational: false,
message: "Wrong credentials given.",
},
};
default:
return {
status: "loading-error",
error: {
hasError: true,
operational: false,
message: `Transaction page ${pageNumber} could not be retrieved.`,
} as any,
};
}
}
if (!data) {
const result = useTransactions(account)
if (result.loading) {
return {
status: "loading",
error: undefined,
};
error: undefined
}
}
if (!result.ok) {
return {
status: "loading-error",
error: result
}
}
// if (error) {
// switch (error.status) {
// case 404:
// return {
// status: "loading-error",
// error: {
// hasError: true,
// operational: false,
// message: `Transactions page ${pageNumber} was not found.`,
// },
// };
// case 401:
// return {
// status: "loading-error",
// error: {
// hasError: true,
// operational: false,
// message: "Wrong credentials given.",
// },
// };
// default:
// return {
// status: "loading-error",
// error: {
// hasError: true,
// operational: false,
// message: `Transaction page ${pageNumber} could not be retrieved.`,
// } as any,
// };
// }
// }
const transactions = data.transactions.map((item: unknown) => {
// if (!data) {
// return {
// status: "loading",
// error: undefined,
// };
// }
const transactions = result.data.transactions.map((item: unknown) => {
if (
!item ||
typeof item !== "object" ||
@ -120,7 +119,7 @@ export function useComponentState({
amount,
subject,
};
});
}).filter((x): x is Transaction => x !== undefined);
return {
status: "ready",

View File

@ -31,8 +31,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment();
const props: Props = {
accountLabel: "myAccount",
pageNumber: 0,
account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
@ -116,8 +115,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment();
const props: Props = {
accountLabel: "myAccount",
pageNumber: 0,
account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
@ -150,8 +148,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment(false);
const props: Props = {
accountLabel: "myAccount",
pageNumber: 0,
account: "myAccount",
};
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});

View File

@ -24,6 +24,9 @@ import { PageStateProvider } from "../context/pageState.js";
import { Routing } from "../pages/Routing.js";
import { strings } from "../i18n/strings.js";
import { TranslationProvider } from "@gnu-taler/web-util/lib/index.browser";
import { SWRConfig } from "swr";
const WITH_LOCAL_STORAGE_CACHE = false;
/**
* FIXME:
@ -47,7 +50,15 @@ const App: FunctionalComponent = () => {
<TranslationProvider source={strings}>
<PageStateProvider>
<BackendStateProvider>
<Routing />
<SWRConfig
value={{
provider: WITH_LOCAL_STORAGE_CACHE
? localStorageProvider
: undefined,
}}
>
<Routing />
</SWRConfig>
</BackendStateProvider>
</PageStateProvider>
</TranslationProvider>
@ -58,4 +69,14 @@ const App: FunctionalComponent = () => {
return globalLogLevel;
};
function localStorageProvider(): Map<unknown, unknown> {
const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
window.addEventListener("beforeunload", () => {
const appCache = JSON.stringify(Array.from(map.entries()));
localStorage.setItem("app-cache", appCache);
});
return map;
}
export default App;

View File

@ -31,10 +31,10 @@ export type Type = BackendStateHandler;
const initial: Type = {
state: defaultState,
clear() {
logOut() {
null;
},
save(info) {
logIn(info) {
null;
},
};

View File

@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { TranslatedString } from "@gnu-taler/taler-util";
import { useNotNullLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
import { ComponentChildren, createContext, h, VNode } from "preact";
import { StateUpdater, useContext } from "preact/hooks";
@ -29,7 +30,6 @@ export type Type = {
};
const initial: Type = {
pageState: {
isRawPayto: false,
withdrawalInProgress: false,
},
pageStateSetter: () => {
@ -58,7 +58,6 @@ export const PageStateProvider = ({
*/
function usePageState(
state: PageStateType = {
isRawPayto: false,
withdrawalInProgress: false,
},
): [PageStateType, StateUpdater<PageStateType>] {
@ -92,24 +91,24 @@ function usePageState(
return [retObj, removeLatestInfo];
}
export type ErrorMessage = {
description?: string;
title: TranslatedString;
debug?: string;
}
/**
* Track page state.
*/
export interface PageStateType {
isRawPayto: boolean;
withdrawalInProgress: boolean;
error?: {
description?: string;
title: string;
debug?: string;
};
error?: ErrorMessage;
info?: TranslatedString;
info?: string;
withdrawalInProgress: boolean;
talerWithdrawUri?: string;
/**
* Not strictly a presentational value, could
* be moved in a future "withdrawal state" object.
*/
withdrawalId?: string;
timestamp?: number;
}

View File

@ -30,10 +30,6 @@ declare module "*.png" {
const content: any;
export default content;
}
declare module "jed" {
const x: any;
export = x;
}
/**********************************************
* Type definitions for states and API calls. *
@ -73,3 +69,361 @@ interface WireTransferRequestType {
subject?: string;
amount?: string;
}
type HashCode = string;
type EddsaPublicKey = string;
type EddsaSignature = string;
type WireTransferIdentifierRawP = string;
type RelativeTime = Duration;
type ImageDataUrl = string;
interface WithId {
id: string;
}
interface Timestamp {
// Milliseconds since epoch, or the special
// value "forever" to represent an event that will
// never happen.
t_s: number | "never";
}
interface Duration {
d_us: number | "forever";
}
interface WithId {
id: string;
}
type Amount = string;
type UUID = string;
type Integer = number;
namespace SandboxBackend {
export interface Config {
// Name of this API, always "circuit".
name: string;
// API version in the form $n:$n:$n
version: string;
// Contains ratios and fees related to buying
// and selling the circuit currency.
ratios_and_fees: RatiosAndFees;
}
interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat.
buy_at_ratio: number;
// Exchange rate to sell the circuit currency for fiat.
sell_at_ratio: number;
// Fee to subtract after applying the buy ratio.
buy_in_fee: number;
// Fee to subtract after applying the sell ratio.
sell_out_fee: number;
}
export interface SandboxError {
error: SandboxErrorDetail;
}
interface SandboxErrorDetail {
// String enum classifying the error.
type: ErrorType;
// Human-readable error description.
description: string;
}
enum ErrorType {
/**
* This error can be related to a business operation,
* a non-existent object requested by the client, or
* even when the bank itself fails.
*/
SandboxError = "sandbox-error",
/**
* It is the error type thrown by helper functions
* from the Util library. Those are used by both
* Sandbox and Nexus, therefore the actual meaning
* must be carried by the error 'message' field.
*/
UtilError = "util-error"
}
namespace Access {
interface PublicAccountsResponse {
publicAccounts: PublicAccount[]
}
interface PublicAccount {
iban: string;
balance: string;
// The account name _and_ the username of the
// Sandbox customer that owns such a bank account.
accountLabel: string;
}
interface BankAccountBalanceResponse {
// Available balance on the account.
balance: {
amount: Amount;
credit_debit_indicator: "credit" | "debit";
};
// payto://-URI of the account. (New)
paytoUri: string;
}
interface BankAccountCreateWithdrawalRequest {
// Amount to withdraw.
amount: Amount;
}
interface BankAccountCreateWithdrawalResponse {
// ID of the withdrawal, can be used to view/modify the withdrawal operation.
withdrawal_id: string;
// URI that can be passed to the wallet to initiate the withdrawal.
taler_withdraw_uri: string;
}
interface BankAccountGetWithdrawalResponse {
// Amount that will be withdrawn with this withdrawal operation.
amount: Amount;
// Was the withdrawal aborted?
aborted: boolean;
// Has the withdrawal been confirmed by the bank?
// The wire transfer for a withdrawal is only executed once
// both confirmation_done is true and selection_done is true.
confirmation_done: boolean;
// Did the wallet select reserve details?
selection_done: boolean;
// Reserve public key selected by the exchange,
// only non-null if selection_done is true.
selected_reserve_pub: string | null;
// Exchange account selected by the wallet, or by the bank
// (with the default exchange) in case the wallet did not provide one
// through the Integration API.
selected_exchange_account: string | null;
}
interface BankAccountTransactionsResponse {
transactions: BankAccountTransactionInfo[];
}
interface BankAccountTransactionInfo {
creditorIban: string;
creditorBic: string; // Optional
creditorName: string;
debtorIban: string;
debtorBic: string;
debtorName: string;
amount: number;
currency: string;
subject: string;
// Transaction unique ID. Matches
// $transaction_id from the URI.
uid: string;
direction: "DBIT" | "CRDT";
date: string; // milliseconds since the Unix epoch
}
interface CreateBankAccountTransactionCreate {
// Address in the Payto format of the wire transfer receiver.
// It needs at least the 'message' query string parameter.
paytoUri: string;
// Transaction amount (in the $currency:x.y format), optional.
// However, when not given, its value must occupy the 'amount'
// query string parameter of the 'payto' field. In case it
// is given in both places, the paytoUri's takes the precedence.
amount?: string;
}
interface BankRegistrationRequest {
username: string;
password: string;
}
}
namespace Circuit {
interface CircuitAccountRequest {
// Username
username: string;
// Password.
password: string;
// Addresses where to send the TAN. If
// this field is missing, then the cashout
// won't succeed.
contact_data: CircuitContactData;
// Legal subject owning the account.
name: string;
// 'payto' address pointing the bank account
// where to send payments, in case the user
// wants to convert the local currency back
// to fiat.
cashout_address: string;
// IBAN of this bank account, which is therefore
// internal to the circuit. Randomly generated,
// when it is not given.
internal_iban?: string;
}
interface CircuitContactData {
// E-Mail address
email?: string;
// Phone number.
phone?: string;
}
interface CircuitAccountReconfiguration {
// Addresses where to send the TAN.
contact_data: CircuitContactData;
// 'payto' address pointing the bank account
// where to send payments, in case the user
// wants to convert the local currency back
// to fiat.
cashout_address: string;
}
interface AccountPasswordChange {
// New password.
new_password: string;
}
interface CircuitAccounts {
customers: CircuitAccountMinimalData[];
}
interface CircuitAccountMinimalData {
// Username
username: string;
// Legal subject owning the account.
name: string;
}
interface CircuitAccountData {
// Username
username: string;
// IBAN hosted at Libeufin Sandbox
iban: string;
contact_data: CircuitContactData;
// Legal subject owning the account.
name: string;
// 'payto' address pointing the bank account
// 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
// as the incoming wire transfer subject in
// the user's external bank account.
subject?: string;
// That is the plain amount that the user specified
// to cashout. Its $currency is the circuit currency.
amount_debit: Amount;
// That is the amount that will effectively be
// transferred by the bank to the user's bank
// account, that is external to the circuit.
// It is expressed in the fiat currency and
// is calculated after the cashout fee and the
// exchange rate. See the /cashout-rates call.
amount_credit: Amount;
// Which channel the TAN should be sent to. If
// this field is missing, it defaults to SMS.
// The default choice prefers to change the communication
// channel respect to the one used to issue this request.
tan_channel?: TanChannel;
}
interface CashoutPending {
// UUID identifying the operation being created
// and now waiting for the TAN confirmation.
uuid: string;
}
interface CashoutConfirm {
// the TAN that confirms $cashoutId.
tan: string;
}
interface Config {
// Name of this API, always "circuit".
name: string;
// API version in the form $n:$n:$n
version: string;
// Contains ratios and fees related to buying
// and selling the circuit currency.
ratios_and_fees: RatiosAndFees;
}
interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat.
buy_at_ratio: float;
// Exchange rate to sell the circuit currency for fiat.
sell_at_ratio: float;
// Fee to subtract after applying the buy ratio.
buy_in_fee: float;
// Fee to subtract after applying the sell ratio.
sell_out_fee: float;
}
interface Cashouts {
// Every string represents a cash-out operation UUID.
cashouts: string[];
}
interface CashoutStatusResponse {
status: CashoutStatus;
// Amount debited to the circuit bank account.
amount_debit: Amount;
// Amount credited to the external bank account.
amount_credit: Amount;
// Transaction subject.
subject: string;
// Circuit bank account that created the cash-out.
account: string;
// Time when the cash-out was created.
creation_time: number; // milliseconds since the Unix epoch
// Time when the cash-out was confirmed via its TAN.
// 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",
}
}
}

View File

@ -0,0 +1,330 @@
/*
This file is part of GNU Taler
(C) 2022 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 useSWR from "swr";
import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import { useEffect, useState } from "preact/hooks";
import {
HttpError,
HttpResponse,
HttpResponseOk,
HttpResponsePaginated,
} from "@gnu-taler/web-util/lib/index.browser";
import { useAuthenticatedBackend, useMatchMutate, usePublicBackend } from "./backend.js";
import { useBackendContext } from "../context/backend.js";
export function useAccessAPI(): AccessAPI {
const mutateAll = useMatchMutate();
const { request } = useAuthenticatedBackend();
const { state } = useBackendContext()
if (state.status === "loggedOut") {
throw Error("access-api can't be used when the user is not logged In")
}
const account = state.username
const createWithdrawal = async (
data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest,
): Promise<HttpResponseOk<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>> => {
const res = await request<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>(`access-api/accounts/${account}/withdrawals`, {
method: "POST",
data,
contentType: "json"
});
return res;
};
const abortWithdrawal = async (
id: string,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/accounts/${account}/withdrawals/${id}`, {
method: "POST",
contentType: "json"
});
await mutateAll(/.*accounts\/.*\/withdrawals\/.*/);
return res;
};
const confirmWithdrawal = async (
id: string,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/accounts/${account}/withdrawals/${id}`, {
method: "POST",
contentType: "json"
});
await mutateAll(/.*accounts\/.*\/withdrawals\/.*/);
return res;
};
const createTransaction = async (
data: SandboxBackend.Access.CreateBankAccountTransactionCreate
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/accounts/${account}/transactions`, {
method: "POST",
data,
contentType: "json"
});
await mutateAll(/.*accounts\/.*\/transactions.*/);
return res;
};
const deleteAccount = async (
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`access-api/accounts/${account}`, {
method: "DELETE",
contentType: "json"
});
await mutateAll(/.*accounts\/.*/);
return res;
};
return { abortWithdrawal, confirmWithdrawal, createWithdrawal, createTransaction, deleteAccount };
}
export function useTestingAPI(): TestingAPI {
const mutateAll = useMatchMutate();
const { request: noAuthRequest } = usePublicBackend();
const register = async (
data: SandboxBackend.Access.BankRegistrationRequest
): Promise<HttpResponseOk<void>> => {
const res = await noAuthRequest<void>(`access-api/testing/register`, {
method: "POST",
data,
contentType: "json"
});
await mutateAll(/.*accounts\/.*/);
return res;
};
return { register };
}
export interface TestingAPI {
register: (
data: SandboxBackend.Access.BankRegistrationRequest
) => Promise<HttpResponseOk<void>>;
}
export interface AccessAPI {
createWithdrawal: (
data: SandboxBackend.Access.BankAccountCreateWithdrawalRequest,
) => Promise<HttpResponseOk<SandboxBackend.Access.BankAccountCreateWithdrawalResponse>>;
abortWithdrawal: (
wid: string,
) => Promise<HttpResponseOk<void>>;
confirmWithdrawal: (
wid: string
) => Promise<HttpResponseOk<void>>;
createTransaction: (
data: SandboxBackend.Access.CreateBankAccountTransactionCreate
) => Promise<HttpResponseOk<void>>;
deleteAccount: () => Promise<HttpResponseOk<void>>;
}
export interface InstanceTemplateFilter {
//FIXME: add filter to the template list
position?: string;
}
export function useAccountDetails(account: string): HttpResponse<SandboxBackend.Access.BankAccountBalanceResponse, SandboxBackend.SandboxError> {
const { fetcher } = useAuthenticatedBackend();
const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountBalanceResponse>,
HttpError<SandboxBackend.SandboxError>
>([`access-api/accounts/${account}`], 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;
return { loading: true };
}
// FIXME: should poll
export function useWithdrawalDetails(account: string, wid: string): HttpResponse<SandboxBackend.Access.BankAccountGetWithdrawalResponse, SandboxBackend.SandboxError> {
const { fetcher } = useAuthenticatedBackend();
const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountGetWithdrawalResponse>,
HttpError<SandboxBackend.SandboxError>
>([`access-api/accounts/${account}/withdrawals/${wid}`], fetcher, {
refreshInterval: 1000,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
errorRetryCount: 0,
errorRetryInterval: 1,
shouldRetryOnError: false,
keepPreviousData: true,
});
// if (isValidating) return { loading: true, data: data?.data };
if (data) return data;
if (error) return error;
return { loading: true };
}
export function useTransactionDetails(account: string, tid: string): HttpResponse<SandboxBackend.Access.BankAccountTransactionInfo, SandboxBackend.SandboxError> {
const { fetcher } = useAuthenticatedBackend();
const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountTransactionInfo>,
HttpError<SandboxBackend.SandboxError>
>([`access-api/accounts/${account}/transactions/${tid}`], fetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
errorRetryCount: 0,
errorRetryInterval: 1,
shouldRetryOnError: false,
keepPreviousData: true,
});
// if (isValidating) return { loading: true, data: data?.data };
if (data) return data;
if (error) return error;
return { loading: true };
}
interface PaginationFilter {
page: number,
}
export function usePublicAccounts(
args?: PaginationFilter,
): HttpResponsePaginated<SandboxBackend.Access.PublicAccountsResponse, SandboxBackend.SandboxError> {
const { paginatedFetcher } = usePublicBackend();
const [page, setPage] = useState(1);
const {
data: afterData,
error: afterError,
isValidating: loadingAfter,
} = useSWR<
HttpResponseOk<SandboxBackend.Access.PublicAccountsResponse>,
HttpError<SandboxBackend.SandboxError>
>([`public-accounts`, args?.page, PAGE_SIZE], paginatedFetcher);
const [lastAfter, setLastAfter] = useState<
HttpResponse<SandboxBackend.Access.PublicAccountsResponse, SandboxBackend.SandboxError>
>({ loading: true });
useEffect(() => {
if (afterData) setLastAfter(afterData);
}, [afterData]);
if (afterError) return afterError;
// if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd =
afterData && afterData.data.publicAccounts.length < PAGE_SIZE;
const isReachingStart = false;
const pagination = {
isReachingEnd,
isReachingStart,
loadMore: () => {
if (!afterData || isReachingEnd) return;
if (afterData.data.publicAccounts.length < MAX_RESULT_SIZE) {
setPage(page + 1);
}
},
loadMorePrev: () => {
null
},
};
const publicAccounts = !afterData ? [] : (afterData || lastAfter).data.publicAccounts;
if (loadingAfter)
return { loading: true, data: { publicAccounts } };
if (afterData) {
return { ok: true, data: { publicAccounts }, ...pagination };
}
return { loading: true };
}
/**
* FIXME: mutate result when balance change (transaction )
* @param account
* @param args
* @returns
*/
export function useTransactions(
account: string,
args?: PaginationFilter,
): HttpResponsePaginated<SandboxBackend.Access.BankAccountTransactionsResponse, SandboxBackend.SandboxError> {
const { paginatedFetcher } = useAuthenticatedBackend();
const [page, setPage] = useState(1);
const {
data: afterData,
error: afterError,
isValidating: loadingAfter,
} = useSWR<
HttpResponseOk<SandboxBackend.Access.BankAccountTransactionsResponse>,
HttpError<SandboxBackend.SandboxError>
>([`access-api/accounts/${account}/transactions`, args?.page, PAGE_SIZE], paginatedFetcher);
const [lastAfter, setLastAfter] = useState<
HttpResponse<SandboxBackend.Access.BankAccountTransactionsResponse, SandboxBackend.SandboxError>
>({ loading: true });
useEffect(() => {
if (afterData) setLastAfter(afterData);
}, [afterData]);
if (afterError) return afterError;
// if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd =
afterData && afterData.data.transactions.length < PAGE_SIZE;
const isReachingStart = false;
const pagination = {
isReachingEnd,
isReachingStart,
loadMore: () => {
if (!afterData || isReachingEnd) return;
if (afterData.data.transactions.length < MAX_RESULT_SIZE) {
setPage(page + 1);
}
},
loadMorePrev: () => {
null
},
};
const transactions = !afterData ? [] : (afterData || lastAfter).data.transactions;
if (loadingAfter)
return { loading: true, data: { transactions } };
if (afterData) {
return { ok: true, data: { transactions }, ...pagination };
}
return { loading: true };
}

View File

@ -62,7 +62,6 @@ export function useAsync<T>(
};
function cancel() {
// cancelPendingRequest()
setLoading(false);
setSlow(false);
}

View File

@ -14,7 +14,17 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
import {
HttpResponse,
HttpResponseOk,
RequestOptions,
} from "@gnu-taler/web-util/lib/index.browser";
import { useApiContext } from "@gnu-taler/web-util/lib/index.browser";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useSWRConfig } from "swr";
import { useBackendContext } from "../context/backend.js";
/**
* Has the information to reach and
@ -22,25 +32,38 @@ import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
*/
export type BackendState = LoggedIn | LoggedOut;
export interface BackendInfo {
url: string;
export interface BackendCredentials {
username: string;
password: string;
}
interface LoggedIn extends BackendInfo {
interface LoggedIn extends BackendCredentials {
url: string;
status: "loggedIn";
isUserAdministrator: boolean;
}
interface LoggedOut {
url: string;
status: "loggedOut";
}
export const defaultState: BackendState = { status: "loggedOut" };
const maybeRootPath = "https://bank.demo.taler.net/demobanks/default/";
export function getInitialBackendBaseURL(): string {
const overrideUrl = localStorage.getItem("bank-base-url");
return canonicalizeBaseUrl(overrideUrl ? overrideUrl : maybeRootPath);
}
export const defaultState: BackendState = {
status: "loggedOut",
url: getInitialBackendBaseURL()
};
export interface BackendStateHandler {
state: BackendState;
clear(): void;
save(info: BackendInfo): void;
logOut(): void;
logIn(info: BackendCredentials): void;
}
/**
* Return getters and setters for
@ -52,7 +75,7 @@ export function useBackendState(): BackendStateHandler {
"backend-state",
JSON.stringify(defaultState),
);
// const parsed = value !== undefined ? JSON.parse(value) : value;
let parsed;
try {
parsed = JSON.parse(value!);
@ -63,12 +86,162 @@ export function useBackendState(): BackendStateHandler {
return {
state,
clear() {
update(JSON.stringify(defaultState));
logOut() {
update(JSON.stringify({ ...defaultState, url: state.url }));
},
save(info) {
const nextState: BackendState = { status: "loggedIn", ...info };
logIn(info) {
//admin is defined by the username
const nextState: BackendState = { status: "loggedIn", url: state.url, ...info, isUserAdministrator: info.username === "admin" };
update(JSON.stringify(nextState));
},
};
}
interface useBackendType {
request: <T>(
path: string,
options?: RequestOptions,
) => Promise<HttpResponseOk<T>>;
fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
multiFetcher: <T>(endpoint: string[]) => Promise<HttpResponseOk<T>[]>;
paginatedFetcher: <T>(args: [string, number, number]) => Promise<HttpResponseOk<T>>;
sandboxAccountsFetcher: <T>(args: [string, number, number, string]) => Promise<HttpResponseOk<T>>;
}
export function usePublicBackend(): useBackendType {
const { state } = useBackendContext();
const { request: requestHandler } = useApiContext();
const baseUrl = state.url
const request = useCallback(
function requestImpl<T>(
path: string,
options: RequestOptions = {},
): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, path, options);
},
[baseUrl],
);
const fetcher = useCallback(
function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint);
},
[baseUrl],
);
const paginatedFetcher = useCallback(
function fetcherImpl<T>([endpoint, page, size]: [string, number, number]): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { params: { page: page || 1, size } });
},
[baseUrl],
);
const multiFetcher = useCallback(
function multiFetcherImpl<T>(
endpoints: string[],
): Promise<HttpResponseOk<T>[]> {
return Promise.all(
endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint)),
);
},
[baseUrl],
);
const sandboxAccountsFetcher = useCallback(
function fetcherImpl<T>([endpoint, page, size, account]: [string, number, number, string]): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { params: { page: page || 1, size } });
},
[baseUrl],
);
return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher };
}
export function useAuthenticatedBackend(): useBackendType {
const { state } = useBackendContext();
const { request: requestHandler } = useApiContext();
const creds = state.status === "loggedIn" ? state : undefined
const baseUrl = state.url
const request = useCallback(
function requestImpl<T>(
path: string,
options: RequestOptions = {},
): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, path, { basicAuth: creds, ...options });
},
[baseUrl, creds],
);
const fetcher = useCallback(
function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds });
},
[baseUrl, creds],
);
const paginatedFetcher = useCallback(
function fetcherImpl<T>([endpoint, page = 0, size]: [string, number, number]): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds, params: { page, size } });
},
[baseUrl, creds],
);
const multiFetcher = useCallback(
function multiFetcherImpl<T>(
endpoints: string[],
): Promise<HttpResponseOk<T>[]> {
return Promise.all(
endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint, { basicAuth: creds })),
);
},
[baseUrl, creds],
);
const sandboxAccountsFetcher = useCallback(
function fetcherImpl<T>([endpoint, page, size, account]: [string, number, number, string]): Promise<HttpResponseOk<T>> {
return requestHandler<T>(baseUrl, endpoint, { basicAuth: creds, params: { page: page || 1, size } });
},
[baseUrl],
);
return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher };
}
export function useBackendConfig(): HttpResponse<SandboxBackend.Config, SandboxBackend.SandboxError> {
const { request } = usePublicBackend();
type Type = SandboxBackend.Config;
const [result, setResult] = useState<HttpResponse<Type, SandboxBackend.SandboxError>>({ loading: true });
useEffect(() => {
request<Type>(`/config`)
.then((data) => setResult(data))
.catch((error) => setResult(error));
}, [request]);
return result;
}
export function useMatchMutate(): (
re: RegExp,
value?: unknown,
) => Promise<any> {
const { cache, mutate } = useSWRConfig();
if (!(cache instanceof Map)) {
throw new Error(
"matchMutate requires the cache provider to be a Map instance",
);
}
return function matchRegexMutate(re: RegExp, value?: unknown) {
const allKeys = Array.from(cache.keys());
const keys = allKeys.filter((key) => re.test(key));
const mutations = keys.map((key) => {
mutate(key, value, true);
});
return Promise.all(mutations);
};
}

View File

@ -0,0 +1,317 @@
/*
This file is part of GNU Taler
(C) 2022 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 {
HttpError,
HttpResponse,
HttpResponseOk,
HttpResponsePaginated,
RequestError
} from "@gnu-taler/web-util/lib/index.browser";
import { useEffect, useMemo, useState } from "preact/hooks";
import useSWR from "swr";
import { useBackendContext } from "../context/backend.js";
import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import { useAuthenticatedBackend } from "./backend.js";
export function useAdminAccountAPI(): AdminAccountAPI {
const { request } = useAuthenticatedBackend();
const { state } = useBackendContext()
if (state.status === "loggedOut") {
throw Error("access-api can't be used when the user is not logged In")
}
const createAccount = async (
data: SandboxBackend.Circuit.CircuitAccountRequest,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`circuit-api/accounts`, {
method: "POST",
data,
contentType: "json"
});
return res;
};
const updateAccount = async (
account: string,
data: SandboxBackend.Circuit.CircuitAccountReconfiguration,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`circuit-api/accounts/${account}`, {
method: "PATCH",
data,
contentType: "json"
});
return res;
};
const deleteAccount = async (
account: string,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`circuit-api/accounts/${account}`, {
method: "DELETE",
contentType: "json"
});
return res;
};
const changePassword = async (
account: string,
data: SandboxBackend.Circuit.AccountPasswordChange,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`circuit-api/accounts/${account}/auth`, {
method: "PATCH",
data,
contentType: "json"
});
return res;
};
return { createAccount, deleteAccount, updateAccount, changePassword };
}
export function useCircuitAccountAPI(): CircuitAccountAPI {
const { request } = useAuthenticatedBackend();
const { state } = useBackendContext()
if (state.status === "loggedOut") {
throw Error("access-api can't be used when the user is not logged In")
}
const account = state.username;
const updateAccount = async (
data: SandboxBackend.Circuit.CircuitAccountReconfiguration,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`circuit-api/accounts/${account}`, {
method: "PATCH",
data,
contentType: "json"
});
return res;
};
const changePassword = async (
data: SandboxBackend.Circuit.AccountPasswordChange,
): Promise<HttpResponseOk<void>> => {
const res = await request<void>(`circuit-api/accounts/${account}/auth`, {
method: "PATCH",
data,
contentType: "json"
});
return res;
};
return { updateAccount, changePassword };
}
export interface AdminAccountAPI {
createAccount: (
data: SandboxBackend.Circuit.CircuitAccountRequest,
) => Promise<HttpResponseOk<void>>;
deleteAccount: (account: string) => Promise<HttpResponseOk<void>>;
updateAccount: (
account: string,
data: SandboxBackend.Circuit.CircuitAccountReconfiguration
) => Promise<HttpResponseOk<void>>;
changePassword: (
account: string,
data: SandboxBackend.Circuit.AccountPasswordChange
) => Promise<HttpResponseOk<void>>;
}
export interface CircuitAccountAPI {
updateAccount: (
data: SandboxBackend.Circuit.CircuitAccountReconfiguration
) => Promise<HttpResponseOk<void>>;
changePassword: (
data: SandboxBackend.Circuit.AccountPasswordChange
) => Promise<HttpResponseOk<void>>;
}
export interface InstanceTemplateFilter {
//FIXME: add filter to the template list
position?: string;
}
export function useMyAccountDetails(): HttpResponse<SandboxBackend.Circuit.CircuitAccountData, SandboxBackend.SandboxError> {
const { fetcher } = useAuthenticatedBackend();
const { state } = useBackendContext()
if (state.status === "loggedOut") {
throw Error("can't access my-account-details when logged out")
}
const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData>,
HttpError<SandboxBackend.SandboxError>
>([`accounts/${state.username}`], 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;
return { loading: true };
}
export function useAccountDetails(account: string): HttpResponse<SandboxBackend.Circuit.CircuitAccountData, SandboxBackend.SandboxError> {
const { fetcher } = useAuthenticatedBackend();
const { data, error } = useSWR<
HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData>,
RequestError<SandboxBackend.SandboxError>
>([`circuit-api/accounts/${account}`], fetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
errorRetryCount: 0,
errorRetryInterval: 1,
shouldRetryOnError: false,
keepPreviousData: true,
});
// if (isValidating) return { loading: true, data: data?.data };
if (data) return data;
if (error) return error.info;
return { loading: true };
}
interface PaginationFilter {
account?: string,
page?: number,
}
export function useAccounts(
args?: PaginationFilter,
): HttpResponsePaginated<SandboxBackend.Circuit.CircuitAccounts, SandboxBackend.SandboxError> {
const { sandboxAccountsFetcher } = useAuthenticatedBackend();
const [page, setPage] = useState(0);
const {
data: afterData,
error: afterError,
// isValidating: loadingAfter,
} = useSWR<
HttpResponseOk<SandboxBackend.Circuit.CircuitAccounts>,
RequestError<SandboxBackend.SandboxError>
>([`circuit-api/accounts`, args?.page, PAGE_SIZE, args?.account], sandboxAccountsFetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
errorRetryCount: 0,
errorRetryInterval: 1,
shouldRetryOnError: false,
keepPreviousData: true,
});
// const [lastAfter, setLastAfter] = useState<
// HttpResponse<SandboxBackend.Circuit.CircuitAccounts, SandboxBackend.SandboxError>
// >({ loading: true });
// useEffect(() => {
// if (afterData) setLastAfter(afterData);
// }, [afterData]);
// if the query returns less that we ask, then we have reach the end or beginning
const isReachingEnd =
afterData && afterData.data?.customers?.length < PAGE_SIZE;
const isReachingStart = false;
const pagination = {
isReachingEnd,
isReachingStart,
loadMore: () => {
if (!afterData || isReachingEnd) return;
if (afterData.data?.customers?.length < MAX_RESULT_SIZE) {
setPage(page + 1);
}
},
loadMorePrev: () => {
null
},
};
const result = useMemo(() => {
const customers = !afterData ? [] : (afterData)?.data?.customers ?? [];
return { ok: true as const, data: { customers }, ...pagination }
}, [afterData?.data])
if (afterError) return afterError.info;
if (afterData) {
return result
}
// if (loadingAfter)
// return { loading: true, data: { customers } };
// if (afterData) {
// return { ok: true, data: { customers }, ...pagination };
// }
return { loading: true };
}
export function useCashouts(): HttpResponse<
(SandboxBackend.Circuit.CashoutStatusResponse & WithId)[],
SandboxBackend.SandboxError
> {
const { fetcher, multiFetcher } = useAuthenticatedBackend();
const { data: list, error: listError } = useSWR<
HttpResponseOk<SandboxBackend.Circuit.Cashouts>,
RequestError<SandboxBackend.SandboxError>
>([`circuit-api/cashouts`], fetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
});
const paths = (list?.data.cashouts || []).map(
(cashoutId) => `circuit-api/cashouts/${cashoutId}`,
);
const { data: cashouts, error: productError } = useSWR<
HttpResponseOk<SandboxBackend.Circuit.CashoutStatusResponse>[],
RequestError<SandboxBackend.SandboxError>
>([paths], multiFetcher, {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
});
if (listError) return listError.info;
if (productError) return productError.info;
if (cashouts) {
const dataWithId = cashouts.map((d) => {
//take the id from the queried url
return {
...d.data,
id: d.info?.url.replace(/.*\/cashouts\//, "") || "",
};
});
return { ok: true, data: dataWithId };
}
return { loading: true };
}

View File

@ -14,206 +14,52 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Amounts, HttpStatusCode, Logger } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { ComponentChildren, Fragment, h, VNode } from "preact";
import { useEffect } from "preact/hooks";
import useSWR, { SWRConfig, useSWRConfig } from "swr";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { BackendInfo } from "../hooks/backend.js";
import { bankUiSettings } from "../settings.js";
import { getIbanFromPayto, prepareHeaders } from "../utils.js";
import { BankFrame } from "./BankFrame.js";
import { LoginForm } from "./LoginForm.js";
import { PaymentOptions } from "./PaymentOptions.js";
import { Amounts, parsePaytoUri } from "@gnu-taler/taler-util";
import {
HttpResponsePaginated,
useTranslationContext,
} 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 { Transactions } from "../components/Transactions/index.js";
import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
import { useAccountDetails } from "../hooks/access.js";
import { PaymentOptions } from "./PaymentOptions.js";
export function AccountPage(): VNode {
const backend = useBackendContext();
interface Props {
account: string;
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
}
/**
* Query account information and show QR code if there is pending withdrawal
*/
export function AccountPage({ account, onLoadNotOk }: Props): VNode {
const result = useAccountDetails(account);
const { i18n } = useTranslationContext();
if (backend.state.status === "loggedOut") {
if (!result.ok) {
return onLoadNotOk(result);
}
const { data } = result;
const balance = Amounts.parse(data.balance.amount);
const errorParsingBalance = !balance;
const payto = parsePaytoUri(data.paytoUri);
if (!payto || !payto.isKnown || payto.targetType !== "iban") {
return (
<BankFrame>
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
<LoginForm />
</BankFrame>
<div>Payto from server is not valid &quot;{data.paytoUri}&quot;</div>
);
}
const accountNumber = payto.iban;
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
return (
<SWRWithCredentials info={backend.state}>
<Account accountLabel={backend.state.username} />
</SWRWithCredentials>
);
}
/**
* Factor out login credentials.
*/
function SWRWithCredentials({
children,
info,
}: {
children: ComponentChildren;
info: BackendInfo;
}): VNode {
const { username, password, url: backendUrl } = info;
const headers = prepareHeaders(username, password);
return (
<SWRConfig
value={{
fetcher: (url: string) => {
return fetch(new URL(url, backendUrl).href, { headers }).then((r) => {
if (!r.ok) throw { status: r.status, json: r.json() };
return r.json();
});
},
}}
>
{children as any}
</SWRConfig>
);
}
const logger = new Logger("AccountPage");
/**
* Show only the account's balance. NOTE: the backend state
* is mostly needed to provide the user's credentials to POST
* to the bank.
*/
function Account({ accountLabel }: { accountLabel: string }): VNode {
const { cache } = useSWRConfig();
// Getting the bank account balance:
const endpoint = `access-api/accounts/${accountLabel}`;
const { data, error, mutate } = useSWR(endpoint, {
// refreshInterval: 0,
// revalidateIfStale: false,
// revalidateOnMount: false,
// revalidateOnFocus: false,
// revalidateOnReconnect: false,
});
const backend = useBackendContext();
const { pageState, pageStateSetter: setPageState } = usePageContext();
const { withdrawalId, talerWithdrawUri, timestamp } = pageState;
const { i18n } = useTranslationContext();
useEffect(() => {
mutate();
}, [timestamp]);
/**
* This part shows a list of transactions: with 5 elements by
* default and offers a "load more" button.
*/
// const [txPageNumber, setTxPageNumber] = useTransactionPageNumber();
// const txsPages = [];
// for (let i = 0; i <= txPageNumber; i++) {
// txsPages.push(<Transactions accountLabel={accountLabel} pageNumber={i} />);
// }
if (typeof error !== "undefined") {
logger.error("account error", error, endpoint);
/**
* FIXME: to minimize the code, try only one invocation
* of pageStateSetter, after having decided the error
* message in the case-branch.
*/
switch (error.status) {
case 404: {
backend.clear();
setPageState((prevState: PageStateType) => ({
...prevState,
error: {
title: i18n.str`Username or account label '${accountLabel}' not found. Won't login.`,
},
}));
/**
* 404 should never stick to the cache, because they
* taint successful future registrations. How? After
* registering, the user gets navigated to this page,
* therefore a previous 404 on this SWR key (the requested
* resource) would still appear as valid and cause this
* page not to be shown! A typical case is an attempted
* login of a unregistered user X, and then a registration
* attempt of the same user X: in this case, the failed
* login would cache a 404 error to X's profile, resulting
* in the legitimate request after the registration to still
* be flagged as 404. Clearing the cache should prevent
* this. */
(cache as any).clear();
return <p>Profile not found...</p>;
}
case HttpStatusCode.Unauthorized:
case HttpStatusCode.Forbidden: {
backend.clear();
setPageState((prevState: PageStateType) => ({
...prevState,
error: {
title: i18n.str`Wrong credentials given.`,
},
}));
return <p>Wrong credentials...</p>;
}
default: {
backend.clear();
setPageState((prevState: PageStateType) => ({
...prevState,
error: {
title: i18n.str`Account information could not be retrieved.`,
debug: JSON.stringify(error),
},
}));
return <p>Unknown problem...</p>;
}
}
}
const balance = !data ? undefined : Amounts.parse(data.balance.amount);
const errorParsingBalance = data && !balance;
const accountNumber = !data ? undefined : getIbanFromPayto(data.paytoUri);
const balanceIsDebit = data && data.balance.credit_debit_indicator == "debit";
/**
* This block shows the withdrawal QR code.
*
* A withdrawal operation replaces everything in the page and
* (ToDo:) starts polling the backend until either the wallet
* selected a exchange and reserve public key, or a error / abort
* happened.
*
* After reaching one of the above states, the user should be
* brought to this ("Account") page where they get informed about
* the outcome.
*/
if (talerWithdrawUri && withdrawalId) {
logger.trace("Bank created a new Taler withdrawal");
return (
<BankFrame>
<WithdrawalQRCode
withdrawalId={withdrawalId}
talerWithdrawUri={talerWithdrawUri}
/>
</BankFrame>
);
}
const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance);
return (
<BankFrame>
<Fragment>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>
Welcome,
{accountNumber
? `${accountLabel} (${accountNumber})`
: accountLabel}
!
{accountNumber ? `${account} (${accountNumber})` : account}!
</i18n.Translate>
</h1>
</div>
@ -239,7 +85,10 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {
) : (
<div class="large-amount amount">
{balanceIsDebit ? <b>-</b> : null}
<span class="value">{`${balanceValue}`}</span>&nbsp;
<span class="value">{`${Amounts.stringifyValue(
balance,
)}`}</span>
&nbsp;
<span class="currency">{`${balance.currency}`}</span>
</div>
)}
@ -248,34 +97,56 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {
<section id="payments">
<div class="payments">
<h2>{i18n.str`Payments`}</h2>
<PaymentOptions currency={balance?.currency} />
<PaymentOptions currency={balance.currency} />
</div>
</section>
</Fragment>
)}
<section id="main">
<article>
<h2>{i18n.str`Latest transactions:`}</h2>
<Transactions
balanceValue={balanceValue}
pageNumber={0}
accountLabel={accountLabel}
/>
</article>
<section style={{ marginTop: "2em" }}>
<Moves account={account} />
</section>
</BankFrame>
</Fragment>
);
}
// function useTransactionPageNumber(): [number, StateUpdater<number>] {
// const ret = useNotNullLocalStorage("transaction-page", "0");
// const retObj = JSON.parse(ret[0]);
// const retSetter: StateUpdater<number> = function (val) {
// const newVal =
// val instanceof Function
// ? JSON.stringify(val(retObj))
// : JSON.stringify(val);
// ret[1](newVal);
// };
// return [retObj, retSetter];
// }
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" && (
<div class="active">
<h3>{i18n.str`Latest transactions`}</h3>
<Transactions account={account} />
</div>
)}
{tab === "cashouts" && (
<div class="active">
<h3>{i18n.str`Latest cashouts`}</h3>
<Cashouts account={account} />
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,707 @@
/*
This file is part of GNU Taler
(C) 2022 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 { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util";
import {
HttpResponsePaginated,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { ErrorMessage, usePageContext } from "../context/pageState.js";
import {
useAccountDetails,
useAccounts,
useAdminAccountAPI,
} from "../hooks/circuit.js";
import {
PartialButDefined,
undefinedIfEmpty,
WithIntermediate,
} from "../utils.js";
import { ErrorBanner } from "./BankFrame.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const charset =
"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const upperIdx = charset.indexOf("A");
function randomPassword(): string {
const random = Array.from({ length: 16 }).map(() => {
return charset.charCodeAt(Math.random() * charset.length);
});
// first char can't be upper
const charIdx = charset.indexOf(String.fromCharCode(random[0]));
random[0] =
charIdx > upperIdx ? charset.charCodeAt(charIdx - upperIdx) : random[0];
return String.fromCharCode(...random);
}
interface Props {
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
}
/**
* Query account information and show QR code if there is pending withdrawal
*/
export function AdminPage({ onLoadNotOk }: Props): VNode {
const [account, setAccount] = useState<string | undefined>();
const [showDetails, setShowDetails] = useState<string | undefined>();
const [updatePassword, setUpdatePassword] = useState<string | undefined>();
const [createAccount, setCreateAccount] = useState(false);
const { pageStateSetter } = usePageContext();
function showInfoMessage(info: TranslatedString): void {
pageStateSetter((prev) => ({
...prev,
info,
}));
}
const result = useAccounts({ account });
const { i18n } = useTranslationContext();
if (result.loading) return <div />;
if (!result.ok) {
return onLoadNotOk(result);
}
const { customers } = result.data;
if (showDetails) {
return (
<ShowAccountDetails
account={showDetails}
onLoadNotOk={onLoadNotOk}
onUpdateSuccess={() => {
showInfoMessage(i18n.str`Account updated`);
setShowDetails(undefined);
}}
onClear={() => {
setShowDetails(undefined);
}}
/>
);
}
if (updatePassword) {
return (
<UpdateAccountPassword
account={updatePassword}
onLoadNotOk={onLoadNotOk}
onUpdateSuccess={() => {
showInfoMessage(i18n.str`Password changed`);
setUpdatePassword(undefined);
}}
onClear={() => {
setUpdatePassword(undefined);
}}
/>
);
}
if (createAccount) {
return (
<CreateNewAccount
onClose={() => setCreateAccount(false)}
onCreateSuccess={(password) => {
showInfoMessage(
i18n.str`Account created with password "${password}"`,
);
setCreateAccount(false);
}}
/>
);
}
return (
<Fragment>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Admin panel</i18n.Translate>
</h1>
</div>
<p>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div></div>
<div>
<input
class="pure-button pure-button-primary content"
type="submit"
value={i18n.str`Create account`}
onClick={async (e) => {
e.preventDefault();
setCreateAccount(true);
}}
/>
</div>
</div>
</p>
<section id="main">
<article>
<h2>{i18n.str`Accounts:`}</h2>
<div class="results">
<table class="pure-table pure-table-striped">
<thead>
<tr>
<th>{i18n.str`Username`}</th>
<th>{i18n.str`Name`}</th>
<th></th>
</tr>
</thead>
<tbody>
{customers.map((item, idx) => {
return (
<tr key={idx}>
<td>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setShowDetails(item.username);
}}
>
{item.username}
</a>
</td>
<td>{item.name}</td>
<td>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setUpdatePassword(item.username);
}}
>
change password
</a>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</article>
</section>
</Fragment>
);
}
const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
function initializeFromTemplate(
account: SandboxBackend.Circuit.CircuitAccountData | undefined,
): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> {
const emptyAccount = {
cashout_address: undefined,
iban: undefined,
name: undefined,
username: undefined,
contact_data: undefined,
};
const emptyContact = {
email: undefined,
phone: undefined,
};
const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> =
structuredClone(account) ?? emptyAccount;
if (typeof initial.contact_data === "undefined") {
initial.contact_data = emptyContact;
}
initial.contact_data.email;
return initial as any;
}
function UpdateAccountPassword({
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 { changePassword } = useAdminAccountAPI();
const [password, setPassword] = useState<string | undefined>();
const [repeat, setRepeat] = useState<string | undefined>();
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 errors = undefinedIfEmpty({
password: !password ? i18n.str`required` : undefined,
repeat: !repeat
? i18n.str`required`
: password !== repeat
? i18n.str`password doesn't match`
: undefined,
});
return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Admin panel</i18n.Translate>
</h1>
</div>
{error && (
<ErrorBanner error={error} onClear={() => saveError(undefined)} />
)}
<form class="pure-form">
<fieldset>
<label for="username">{i18n.str`Username`}</label>
<input name="username" type="text" readOnly value={account} />
</fieldset>
<fieldset>
<label>{i18n.str`Password`}</label>
<input
type="password"
value={password ?? ""}
onChange={(e) => {
setPassword(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.password}
isDirty={password !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`Repeast password`}</label>
<input
type="password"
value={repeat ?? ""}
onChange={(e) => {
setRepeat(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.repeat}
isDirty={repeat !== undefined}
/>
</fieldset>
</form>
<p>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div>
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
onClear();
}}
/>
</div>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={!!errors}
type="submit"
value={i18n.str`Confirm`}
onClick={async (e) => {
e.preventDefault();
if (!!errors || !password) return;
try {
const r = await changePassword(account, {
new_password: password,
});
onUpdateSuccess();
} catch (error) {
handleError(error, saveError, i18n);
}
}}
/>
</div>
</div>
</p>
</div>
);
}
function CreateNewAccount({
onClose,
onCreateSuccess,
}: {
onClose: () => void;
onCreateSuccess: (password: string) => void;
}): VNode {
const { i18n } = useTranslationContext();
const { createAccount } = useAdminAccountAPI();
const [submitAccount, setSubmitAccount] = useState<
SandboxBackend.Circuit.CircuitAccountData | undefined
>();
const [error, saveError] = useState<ErrorMessage | undefined>();
return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Admin panel</i18n.Translate>
</h1>
</div>
{error && (
<ErrorBanner error={error} onClear={() => saveError(undefined)} />
)}
<AccountForm
template={undefined}
purpose="create"
onChange={(a) => setSubmitAccount(a)}
/>
<p>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div>
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
onClose();
}}
/>
</div>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={!submitAccount}
type="submit"
value={i18n.str`Confirm`}
onClick={async (e) => {
e.preventDefault();
if (!submitAccount) return;
try {
const account: SandboxBackend.Circuit.CircuitAccountRequest =
{
cashout_address: submitAccount.cashout_address,
contact_data: submitAccount.contact_data,
internal_iban: submitAccount.iban,
name: submitAccount.name,
username: submitAccount.username,
password: randomPassword(),
};
await createAccount(account);
onCreateSuccess(account.password);
} catch (error) {
handleError(error, saveError, i18n);
}
}}
/>
</div>
</div>
</p>
</div>
);
}
function ShowAccountDetails({
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 { updateAccount } = useAdminAccountAPI();
const [update, setUpdate] = useState(false);
const [submitAccount, setSubmitAccount] = useState<
SandboxBackend.Circuit.CircuitAccountData | undefined
>();
const [error, saveError] = useState<ErrorMessage | undefined>();
if (result.clientError) {
if (result.isNotfound) return <div>account not found</div>;
}
if (!result.ok) {
return onLoadNotOk(result);
}
return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Admin panel</i18n.Translate>
</h1>
</div>
{error && (
<ErrorBanner error={error} onClear={() => saveError(undefined)} />
)}
<AccountForm
template={result.data}
purpose={update ? "update" : "show"}
onChange={(a) => setSubmitAccount(a)}
/>
<p>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div>
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
onClear();
}}
/>
</div>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={update && !submitAccount}
type="submit"
value={update ? i18n.str`Confirm` : i18n.str`Update`}
onClick={async (e) => {
e.preventDefault();
if (!update) {
setUpdate(true);
} else {
if (!submitAccount) return;
try {
await updateAccount(account, {
cashout_address: submitAccount.cashout_address,
contact_data: submitAccount.contact_data,
});
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
* Purpose indicate if all field al read only (show), part of them (update)
* or none (create)
* @param param0
* @returns
*/
function AccountForm({
template,
purpose,
onChange,
}: {
template: SandboxBackend.Circuit.CircuitAccountData | undefined;
onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void;
purpose: "create" | "update" | "show";
}): VNode {
const initial = initializeFromTemplate(template);
const [form, setForm] = useState(initial);
const [errors, setErrors] = useState<typeof initial | undefined>(undefined);
const { i18n } = useTranslationContext();
function updateForm(newForm: typeof initial): void {
const parsed = !newForm.cashout_address
? undefined
: parsePaytoUri(newForm.cashout_address);
const validationResult = undefinedIfEmpty<typeof initial>({
cashout_address: !newForm.cashout_address
? i18n.str`required`
: !parsed
? i18n.str`does not follow the pattern`
: !parsed.isKnown || parsed.targetType !== "iban"
? i18n.str`only "IBAN" target are supported`
: !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined,
contact_data: {
email: !newForm.contact_data.email
? undefined
: !EMAIL_REGEX.test(newForm.contact_data.email)
? i18n.str`it should be an email`
: undefined,
phone: !newForm.contact_data.phone
? undefined
: !newForm.contact_data.phone.startsWith("+")
? i18n.str`should start with +`
: !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone)
? i18n.str`phone number can't have other than numbers`
: undefined,
},
iban: !newForm.iban
? i18n.str`required`
: !IBAN_REGEX.test(newForm.iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined,
name: !newForm.name ? i18n.str`required` : undefined,
username: !newForm.username ? i18n.str`required` : undefined,
});
setErrors(validationResult);
setForm(newForm);
onChange(validationResult === undefined ? undefined : (newForm as any));
}
return (
<form class="pure-form">
<fieldset>
<label for="username">{i18n.str`Username`}</label>
<input
name="username"
type="text"
disabled={purpose !== "create"}
value={form.username}
onChange={(e) => {
form.username = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.username}
isDirty={form.username !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`Name`}</label>
<input
disabled={purpose !== "create"}
value={form.name ?? ""}
onChange={(e) => {
form.name = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.name}
isDirty={form.name !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`IBAN`}</label>
<input
disabled={purpose !== "create"}
value={form.iban ?? ""}
onChange={(e) => {
form.iban = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.iban}
isDirty={form.iban !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`Email`}</label>
<input
disabled={purpose === "show"}
value={form.contact_data.email ?? ""}
onChange={(e) => {
form.contact_data.email = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.contact_data.email}
isDirty={form.contact_data.email !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`Phone`}</label>
<input
disabled={purpose === "show"}
value={form.contact_data.phone ?? ""}
onChange={(e) => {
form.contact_data.phone = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.contact_data.phone}
isDirty={form.contact_data?.phone !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`Cashout address`}</label>
<input
disabled={purpose === "show"}
value={form.cashout_address ?? ""}
onChange={(e) => {
form.cashout_address = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.cashout_address}
isDirty={form.cashout_address !== undefined}
/>
</fieldset>
</form>
);
}
function handleError(
error: unknown,
saveError: (e: ErrorMessage) => void,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
): void {
if (error instanceof RequestError) {
const payload = error.info.error as SandboxBackend.SandboxError;
saveError({
title: error.info.serverError
? i18n.str`Server had an error`
: i18n.str`Server didn't accept the request`,
description: payload.error.description,
});
} else if (error instanceof Error) {
saveError({
title: i18n.str`Could not update account`,
description: error.message,
});
} else {
saveError({
title: i18n.str`Error, please report`,
debug: JSON.stringify(error),
});
}
}

View File

@ -19,7 +19,11 @@ import { ComponentChildren, Fragment, h, VNode } from "preact";
import talerLogo from "../assets/logo-white.svg";
import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import {
ErrorMessage,
PageStateType,
usePageContext,
} from "../context/pageState.js";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { bankUiSettings } from "../settings.js";
@ -42,7 +46,7 @@ export function BankFrame({
onClick={() => {
pageStateSetter((prevState: PageStateType) => {
const { talerWithdrawUri, withdrawalId, ...rest } = prevState;
backend.clear();
backend.logOut();
return {
...rest,
withdrawalInProgress: false,
@ -107,7 +111,14 @@ export function BankFrame({
</nav>
</div>
<section id="main" class="content">
<ErrorBanner />
{pageState.error && (
<ErrorBanner
error={pageState.error}
onClear={() => {
pageStateSetter((prev) => ({ ...prev, error: undefined }));
}}
/>
)}
<StatusBanner />
{backend.state.status === "loggedIn" ? logOut : null}
{children}
@ -136,33 +147,34 @@ function maybeDemoContent(content: VNode): VNode {
return <Fragment />;
}
function ErrorBanner(): VNode | null {
const { pageState, pageStateSetter } = usePageContext();
if (!pageState.error) return null;
const rval = (
export function ErrorBanner({
error,
onClear,
}: {
error: ErrorMessage;
onClear: () => void;
}): VNode | null {
return (
<div class="informational informational-fail" style={{ marginTop: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p>
<b>{pageState.error.title}</b>
<b>{error.title}</b>
</p>
<div>
<input
type="button"
class="pure-button"
value="Clear"
onClick={async () => {
pageStateSetter((prev) => ({ ...prev, error: undefined }));
onClick={(e) => {
e.preventDefault();
onClear();
}}
/>
</div>
</div>
<p>{pageState.error.description}</p>
<p>{error.description}</p>
</div>
);
delete pageState.error;
return rval;
}
function StatusBanner(): VNode | null {

View File

@ -0,0 +1,149 @@
/*
This file is part of GNU Taler
(C) 2022 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 { Logger } from "@gnu-taler/taler-util";
import {
HttpResponsePaginated,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { Loading } from "../components/Loading.js";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { AccountPage } from "./AccountPage.js";
import { AdminPage } from "./AdminPage.js";
import { LoginForm } from "./LoginForm.js";
import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
const logger = new Logger("AccountPage");
/**
* show content based on state:
* - LoginForm if the user is not logged in
* - qr code if withdrawal in progress
* - else account information
* Use the handler to catch error cases
*
* @param param0
* @returns
*/
export function HomePage({ onRegister }: { onRegister: () => void }): VNode {
const backend = useBackendContext();
const { pageState, pageStateSetter } = usePageContext();
const { i18n } = useTranslationContext();
function saveError(error: PageStateType["error"]): void {
pageStateSetter((prev) => ({ ...prev, error }));
}
function saveErrorAndLogout(error: PageStateType["error"]): void {
saveError(error);
backend.logOut();
}
function clearCurrentWithdrawal(): void {
pageStateSetter((prevState: PageStateType) => {
return {
...prevState,
withdrawalId: undefined,
talerWithdrawUri: undefined,
withdrawalInProgress: false,
};
});
}
if (backend.state.status === "loggedOut") {
return <LoginForm onRegister={onRegister} />;
}
const { withdrawalId, talerWithdrawUri } = pageState;
if (talerWithdrawUri && withdrawalId) {
return (
<WithdrawalQRCode
account={backend.state.username}
withdrawalId={withdrawalId}
talerWithdrawUri={talerWithdrawUri}
onAbort={clearCurrentWithdrawal}
onLoadNotOk={handleNotOkResult(
backend.state.username,
saveError,
i18n,
onRegister,
)}
/>
);
}
if (backend.state.isUserAdministrator) {
return (
<AdminPage
onLoadNotOk={handleNotOkResult(
backend.state.username,
saveErrorAndLogout,
i18n,
onRegister,
)}
/>
);
}
return (
<AccountPage
account={backend.state.username}
onLoadNotOk={handleNotOkResult(
backend.state.username,
saveErrorAndLogout,
i18n,
onRegister,
)}
/>
);
}
function handleNotOkResult(
account: string,
onErrorHandler: (state: PageStateType["error"]) => void,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
onRegister: () => void,
): <T, E>(result: HttpResponsePaginated<T, E>) => VNode {
return function handleNotOkResult2<T, E>(
result: HttpResponsePaginated<T, E>,
): VNode {
if (result.clientError && result.isUnauthorized) {
onErrorHandler({
title: i18n.str`Wrong credentials for "${account}"`,
});
return <LoginForm onRegister={onRegister} />;
}
if (result.clientError && result.isNotfound) {
onErrorHandler({
title: i18n.str`Username or account label "${account}" not found`,
});
return <LoginForm onRegister={onRegister} />;
}
if (result.loading) return <Loading />;
if (!result.ok) {
onErrorHandler({
title: i18n.str`The backend reported a problem: HTTP status #${result.status}`,
description: `Diagnostic from ${result.info?.url.href} is "${result.message}"`,
debug: JSON.stringify(result.error),
});
return <LoginForm onRegister={onRegister} />;
}
return <div />;
};
}

View File

@ -14,21 +14,19 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { h, VNode } from "preact";
import { route } from "preact-router";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { BackendStateHandler } from "../hooks/backend.js";
import { bankUiSettings } from "../settings.js";
import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js";
import { undefinedIfEmpty } from "../utils.js";
import { PASSWORD_REGEX, USERNAME_REGEX } from "./RegistrationPage.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
import { USERNAME_REGEX, PASSWORD_REGEX } from "./RegistrationPage.js";
/**
* Collect and submit login data.
*/
export function LoginForm(): VNode {
export function LoginForm({ onRegister }: { onRegister: () => void }): VNode {
const backend = useBackendContext();
const [username, setUsername] = useState<string | undefined>();
const [password, setPassword] = useState<string | undefined>();
@ -52,107 +50,93 @@ export function LoginForm(): VNode {
});
return (
<div class="login-div">
<form
class="login-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<div class="pure-form">
<h2>{i18n.str`Please login!`}</h2>
<p class="unameFieldLabel loginFieldLabel formFieldLabel">
<label for="username">{i18n.str`Username:`}</label>
</p>
<input
ref={ref}
autoFocus
type="text"
name="username"
id="username"
value={username ?? ""}
placeholder="Username"
required
onInput={(e): void => {
setUsername(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.username}
isDirty={username !== undefined}
/>
<p class="passFieldLabel loginFieldLabel formFieldLabel">
<label for="password">{i18n.str`Password:`}</label>
</p>
<input
type="password"
name="password"
id="password"
value={password ?? ""}
placeholder="Password"
required
onInput={(e): void => {
setPassword(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.password}
isDirty={password !== undefined}
/>
<br />
<button
type="submit"
class="pure-button pure-button-primary"
disabled={!!errors}
onClick={(e) => {
e.preventDefault();
if (!username || !password) return;
loginCall({ username, password }, backend);
setUsername(undefined);
setPassword(undefined);
}}
>
{i18n.str`Login`}
</button>
<Fragment>
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
{bankUiSettings.allowRegistrations ? (
<div class="login-div">
<form
class="login-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
<div class="pure-form">
<h2>{i18n.str`Please login!`}</h2>
<p class="unameFieldLabel loginFieldLabel formFieldLabel">
<label for="username">{i18n.str`Username:`}</label>
</p>
<input
ref={ref}
autoFocus
type="text"
name="username"
id="username"
value={username ?? ""}
placeholder="Username"
autocomplete="username"
required
onInput={(e): void => {
setUsername(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.username}
isDirty={username !== undefined}
/>
<p class="passFieldLabel loginFieldLabel formFieldLabel">
<label for="password">{i18n.str`Password:`}</label>
</p>
<input
type="password"
name="password"
id="password"
autocomplete="current-password"
value={password ?? ""}
placeholder="Password"
required
onInput={(e): void => {
setPassword(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.password}
isDirty={password !== undefined}
/>
<br />
<button
class="pure-button pure-button-secondary btn-cancel"
type="submit"
class="pure-button pure-button-primary"
disabled={!!errors}
onClick={(e) => {
e.preventDefault();
route("/register");
if (!username || !password) return;
backend.logIn({ username, password });
setUsername(undefined);
setPassword(undefined);
}}
>
{i18n.str`Register`}
{i18n.str`Login`}
</button>
) : (
<div />
)}
</div>
</form>
</div>
{bankUiSettings.allowRegistrations ? (
<button
class="pure-button pure-button-secondary btn-cancel"
onClick={(e) => {
e.preventDefault();
onRegister();
}}
>
{i18n.str`Register`}
</button>
) : (
<div />
)}
</div>
</form>
</div>
</Fragment>
);
}
async function loginCall(
req: { username: string; password: string },
/**
* FIXME: figure out if the two following
* functions can be retrieved from the state.
*/
backend: BackendStateHandler,
): Promise<void> {
/**
* Optimistically setting the state as 'logged in', and
* let the Account component request the balance to check
* whether the credentials are valid. */
backend.save({
url: getBankBackendBaseUrl(),
username: req.username,
password: req.password,
});
}

View File

@ -19,17 +19,22 @@ import { useState } from "preact/hooks";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
/**
* Let the user choose a payment option,
* then specify the details trigger the action.
*/
export function PaymentOptions({ currency }: { currency?: string }): VNode {
export function PaymentOptions({ currency }: { currency: string }): VNode {
const { i18n } = useTranslationContext();
const { pageStateSetter } = usePageContext();
const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">(
"charge-wallet",
);
function saveError(error: PageStateType["error"]): void {
pageStateSetter((prev) => ({ ...prev, error }));
}
return (
<article>
@ -55,13 +60,35 @@ export function PaymentOptions({ currency }: { currency?: string }): VNode {
{tab === "charge-wallet" && (
<div id="charge-wallet" class="tabcontent active">
<h3>{i18n.str`Obtain digital cash`}</h3>
<WalletWithdrawForm focus currency={currency} />
<WalletWithdrawForm
focus
currency={currency}
onSuccess={(data) => {
pageStateSetter((prevState: PageStateType) => ({
...prevState,
withdrawalInProgress: true,
talerWithdrawUri: data.taler_withdraw_uri,
withdrawalId: data.withdrawal_id,
}));
}}
onError={saveError}
/>
</div>
)}
{tab === "wire-transfer" && (
<div id="wire-transfer" class="tabcontent active">
<h3>{i18n.str`Transfer to bank account`}</h3>
<PaytoWireTransferForm focus currency={currency} />
<PaytoWireTransferForm
focus
currency={currency}
onSuccess={() => {
pageStateSetter((prevState: PageStateType) => ({
...prevState,
info: i18n.str`Wire transfer created!`,
}));
}}
onError={saveError}
/>
</div>
)}
</div>

View File

@ -14,64 +14,81 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Amounts, Logger, parsePaytoUri } from "@gnu-taler/taler-util";
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
import {
Amounts,
buildPayto,
Logger,
parsePaytoUri,
stringifyPaytoUri,
} from "@gnu-taler/taler-util";
import {
InternationalizationAPI,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { h, VNode } from "preact";
import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import {
InternationalizationAPI,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { useAccessAPI } from "../hooks/access.js";
import { BackendState } from "../hooks/backend.js";
import { prepareHeaders, undefinedIfEmpty } from "../utils.js";
import { undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("PaytoWireTransferForm");
export function PaytoWireTransferForm({
focus,
onError,
onSuccess,
currency,
}: {
focus?: boolean;
currency?: string;
onError: (e: PageStateType["error"]) => void;
onSuccess: () => void;
currency: string;
}): VNode {
const backend = useBackendContext();
const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button?
// const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button?
const [submitData, submitDataSetter] = useWireTransferRequestType();
const [isRawPayto, setIsRawPayto] = useState(false);
// const [submitData, submitDataSetter] = useWireTransferRequestType();
const [iban, setIban] = useState<string | undefined>(undefined);
const [subject, setSubject] = useState<string | undefined>(undefined);
const [amount, setAmount] = useState<string | undefined>(undefined);
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
undefined,
);
const { i18n } = useTranslationContext();
const ibanRegex = "^[A-Z][A-Z][0-9]+$";
let transactionData: TransactionRequestType;
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (focus) ref.current?.focus();
}, [focus, pageState.isRawPayto]);
}, [focus, isRawPayto]);
let parsedAmount = undefined;
const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
const errorsWire = undefinedIfEmpty({
iban: !submitData?.iban
iban: !iban
? i18n.str`Missing IBAN`
: !/^[A-Z0-9]*$/.test(submitData.iban)
: !IBAN_REGEX.test(iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined,
subject: !submitData?.subject ? i18n.str`Missing subject` : undefined,
amount: !submitData?.amount
subject: !subject ? i18n.str`Missing subject` : undefined,
amount: !amount
? i18n.str`Missing amount`
: !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`))
: !(parsedAmount = Amounts.parse(`${currency}:${amount}`))
? i18n.str`Amount is not valid`
: Amounts.isZero(parsedAmount)
? i18n.str`Should be greater than 0`
: undefined,
});
if (!pageState.isRawPayto)
const { createTransaction } = useAccessAPI();
if (!isRawPayto)
return (
<div>
<form
@ -90,21 +107,18 @@ export function PaytoWireTransferForm({
type="text"
id="iban"
name="iban"
value={submitData?.iban ?? ""}
value={iban ?? ""}
placeholder="CC0123456789"
required
pattern={ibanRegex}
onInput={(e): void => {
submitDataSetter((submitData) => ({
...submitData,
iban: e.currentTarget.value,
}));
setIban(e.currentTarget.value);
}}
/>
<br />
<ShowInputErrorLabel
message={errorsWire?.iban}
isDirty={submitData?.iban !== undefined}
isDirty={iban !== undefined}
/>
<br />
<label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp;
@ -113,19 +127,16 @@ export function PaytoWireTransferForm({
name="subject"
id="subject"
placeholder="subject"
value={submitData?.subject ?? ""}
value={subject ?? ""}
required
onInput={(e): void => {
submitDataSetter((submitData) => ({
...submitData,
subject: e.currentTarget.value,
}));
setSubject(e.currentTarget.value);
}}
/>
<br />
<ShowInputErrorLabel
message={errorsWire?.subject}
isDirty={submitData?.subject !== undefined}
isDirty={subject !== undefined}
/>
<br />
<label for="amount">{i18n.str`Amount:`}</label>&nbsp;
@ -146,18 +157,15 @@ export function PaytoWireTransferForm({
id="amount"
placeholder="amount"
required
value={submitData?.amount ?? ""}
value={amount ?? ""}
onInput={(e): void => {
submitDataSetter((submitData) => ({
...submitData,
amount: e.currentTarget.value,
}));
setAmount(e.currentTarget.value);
}}
/>
</div>
<ShowInputErrorLabel
message={errorsWire?.amount}
isDirty={submitData?.amount !== undefined}
isDirty={amount !== undefined}
/>
</p>
@ -169,43 +177,28 @@ export function PaytoWireTransferForm({
value="Send"
onClick={async (e) => {
e.preventDefault();
if (
typeof submitData === "undefined" ||
typeof submitData.iban === "undefined" ||
submitData.iban === "" ||
typeof submitData.subject === "undefined" ||
submitData.subject === "" ||
typeof submitData.amount === "undefined" ||
submitData.amount === ""
) {
logger.error("Not all the fields were given.");
pageStateSetter((prevState: PageStateType) => ({
...prevState,
error: {
title: i18n.str`Field(s) missing.`,
},
}));
if (!(iban && subject && amount)) {
return;
}
transactionData = {
paytoUri: `payto://iban/${
submitData.iban
}?message=${encodeURIComponent(submitData.subject)}`,
amount: `${currency}:${submitData.amount}`,
};
return await createTransactionCall(
transactionData,
backend.state,
pageStateSetter,
() =>
submitDataSetter((p) => ({
amount: undefined,
iban: undefined,
subject: undefined,
})),
i18n,
);
const ibanPayto = buildPayto("iban", iban, undefined);
ibanPayto.params.message = encodeURIComponent(subject);
const paytoUri = stringifyPaytoUri(ibanPayto);
await createTransaction({
paytoUri,
amount: `${currency}:${amount}`,
});
// return await createTransactionCall(
// transactionData,
// backend.state,
// pageStateSetter,
// () => {
// setAmount(undefined);
// setIban(undefined);
// setSubject(undefined);
// },
// i18n,
// );
}}
/>
<input
@ -214,11 +207,9 @@ export function PaytoWireTransferForm({
value="Clear"
onClick={async (e) => {
e.preventDefault();
submitDataSetter((p) => ({
amount: undefined,
iban: undefined,
subject: undefined,
}));
setAmount(undefined);
setIban(undefined);
setSubject(undefined);
}}
/>
</p>
@ -227,11 +218,7 @@ export function PaytoWireTransferForm({
<a
href="/account"
onClick={() => {
logger.trace("switch to raw payto form");
pageStateSetter((prevState) => ({
...prevState,
isRawPayto: true,
}));
setIsRawPayto(true);
}}
>
{i18n.str`Want to try the raw payto://-format?`}
@ -240,11 +227,23 @@ export function PaytoWireTransferForm({
</div>
);
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
const errorsPayto = undefinedIfEmpty({
rawPaytoInput: !rawPaytoInput
? i18n.str`Missing payto address`
: !parsePaytoUri(rawPaytoInput)
? i18n.str`Payto does not follow the pattern`
? i18n.str`required`
: !parsed
? i18n.str`does not follow the pattern`
: !parsed.params.amount
? i18n.str`use the "amount" parameter to specify the amount to be transferred`
: Amounts.parse(parsed.params.amount) === undefined
? i18n.str`the amount is not valid`
: !parsed.params.message
? i18n.str`use the "message" parameter to specify a reference text for the transfer`
: !parsed.isKnown || parsed.targetType !== "iban"
? i18n.str`only "IBAN" target are supported`
: !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined,
});
@ -296,25 +295,29 @@ export function PaytoWireTransferForm({
disabled={!!errorsPayto}
value={i18n.str`Send`}
onClick={async () => {
// empty string evaluates to false.
if (!rawPaytoInput) {
logger.error("Didn't get any raw Payto string!");
return;
}
transactionData = { paytoUri: rawPaytoInput };
if (
typeof transactionData.paytoUri === "undefined" ||
transactionData.paytoUri.length === 0
)
return;
return await createTransactionCall(
transactionData,
backend.state,
pageStateSetter,
() => rawPaytoInputSetter(undefined),
i18n,
);
try {
await createTransaction({
paytoUri: rawPaytoInput,
});
onSuccess();
rawPaytoInputSetter(undefined);
} catch (error) {
if (error instanceof RequestError) {
const errorData: SandboxBackend.SandboxError =
error.info.error;
onError({
title: i18n.str`Transfer creation gave response error`,
description: errorData.error.description,
debug: JSON.stringify(errorData),
});
}
}
}}
/>
</p>
@ -322,11 +325,7 @@ export function PaytoWireTransferForm({
<a
href="/account"
onClick={() => {
logger.trace("switch to wire-transfer-form");
pageStateSetter((prevState) => ({
...prevState,
isRawPayto: false,
}));
setIsRawPayto(false);
}}
>
{i18n.str`Use wire-transfer form?`}
@ -336,115 +335,3 @@ export function PaytoWireTransferForm({
</div>
);
}
/**
* Stores in the state a object representing a wire transfer,
* in order to avoid losing the handle of the data entered by
* the user in <input> fields. FIXME: name not matching the
* purpose, as this is not a HTTP request body but rather the
* state of the <input>-elements.
*/
type WireTransferRequestTypeOpt = WireTransferRequestType | undefined;
function useWireTransferRequestType(
state?: WireTransferRequestType,
): [WireTransferRequestTypeOpt, StateUpdater<WireTransferRequestTypeOpt>] {
const ret = useLocalStorage(
"wire-transfer-request-state",
JSON.stringify(state),
);
const retObj: WireTransferRequestTypeOpt = ret[0]
? JSON.parse(ret[0])
: ret[0];
const retSetter: StateUpdater<WireTransferRequestTypeOpt> = function (val) {
const newVal =
val instanceof Function
? JSON.stringify(val(retObj))
: JSON.stringify(val);
ret[1](newVal);
};
return [retObj, retSetter];
}
/**
* This function creates a new transaction. It reads a Payto
* address entered by the user and POSTs it to the bank. No
* sanity-check of the input happens before the POST as this is
* already conducted by the backend.
*/
async function createTransactionCall(
req: TransactionRequestType,
backendState: BackendState,
pageStateSetter: StateUpdater<PageStateType>,
/**
* Optional since the raw payto form doesn't have
* a stateful management of the input data yet.
*/
cleanUpForm: () => void,
i18n: InternationalizationAPI,
): Promise<void> {
if (backendState.status === "loggedOut") {
logger.error("No credentials found.");
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`No credentials found.`,
},
}));
return;
}
let res: Response;
try {
const { username, password } = backendState;
const headers = prepareHeaders(username, password);
const url = new URL(
`access-api/accounts/${backendState.username}/transactions`,
backendState.url,
);
res = await fetch(url.href, {
method: "POST",
headers,
body: JSON.stringify(req),
});
} catch (error) {
logger.error("Could not POST transaction request to the bank", error);
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`Could not create the wire transfer`,
description: (error as any).error.description,
debug: JSON.stringify(error),
},
}));
return;
}
// POST happened, status not sure yet.
if (!res.ok) {
const response = await res.json();
logger.error(
`Transfer creation gave response error: ${response} (${res.status})`,
);
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`Transfer creation gave response error`,
description: response.error.description,
debug: JSON.stringify(response),
},
}));
return;
}
// status is 200 OK here, tell the user.
logger.trace("Wire transfer created!");
pageStateSetter((prevState) => ({
...prevState,
info: i18n.str`Wire transfer created!`,
}));
// Only at this point the input data can
// be discarded.
cleanUpForm();
}

View File

@ -15,91 +15,42 @@
*/
import { Logger } from "@gnu-taler/taler-util";
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
import { ComponentChildren, Fragment, h, VNode } from "preact";
import { route } from "preact-router";
import {
HttpResponsePaginated,
useLocalStorage,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { StateUpdater } from "preact/hooks";
import useSWR, { SWRConfig } from "swr";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { getBankBackendBaseUrl } from "../utils.js";
import { BankFrame } from "./BankFrame.js";
import { Transactions } from "../components/Transactions/index.js";
import { usePublicAccounts } from "../hooks/access.js";
const logger = new Logger("PublicHistoriesPage");
export function PublicHistoriesPage(): VNode {
return (
<SWRWithoutCredentials baseUrl={getBankBackendBaseUrl()}>
<BankFrame>
<PublicHistories />
</BankFrame>
</SWRWithoutCredentials>
);
}
// export function PublicHistoriesPage2(): VNode {
// return (
// <BankFrame>
// <PublicHistories />
// </BankFrame>
// );
// }
function SWRWithoutCredentials({
baseUrl,
children,
}: {
children: ComponentChildren;
baseUrl: string;
}): VNode {
logger.trace("Base URL", baseUrl);
return (
<SWRConfig
value={{
fetcher: (url: string) =>
fetch(baseUrl + url || "").then((r) => {
if (!r.ok) throw { status: r.status, json: r.json() };
return r.json();
}),
}}
>
{children as any}
</SWRConfig>
);
interface Props {
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
}
/**
* Show histories of public accounts.
*/
function PublicHistories(): VNode {
const { pageState, pageStateSetter } = usePageContext();
export function PublicHistoriesPage({ onLoadNotOk }: Props): VNode {
const [showAccount, setShowAccount] = useShowPublicAccount();
const { data, error } = useSWR("access-api/public-accounts");
const { i18n } = useTranslationContext();
if (typeof error !== "undefined") {
switch (error.status) {
case 404:
logger.error("public accounts: 404", error);
route("/account");
pageStateSetter((prevState: PageStateType) => ({
...prevState,
const result = usePublicAccounts();
if (!result.ok) return onLoadNotOk(result);
error: {
title: i18n.str`List of public accounts was not found.`,
debug: JSON.stringify(error),
},
}));
break;
default:
logger.error("public accounts: non-404 error", error);
route("/account");
pageStateSetter((prevState: PageStateType) => ({
...prevState,
const { data } = result;
error: {
title: i18n.str`List of public accounts could not be retrieved.`,
debug: JSON.stringify(error),
},
}));
break;
}
}
if (!data) return <p>Waiting public accounts list...</p>;
const txs: Record<string, h.JSX.Element> = {};
const accountsBar = [];
@ -133,9 +84,7 @@ function PublicHistories(): VNode {
</a>
</li>,
);
txs[account.accountLabel] = (
<Transactions accountLabel={account.accountLabel} pageNumber={0} />
);
txs[account.accountLabel] = <Transactions account={account.accountLabel} />;
}
return (

View File

@ -21,10 +21,10 @@ import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
export function QrCodeSection({
talerWithdrawUri,
abortButton,
onAbort,
}: {
talerWithdrawUri: string;
abortButton: h.JSX.Element;
onAbort: () => void;
}): VNode {
const { i18n } = useTranslationContext();
useEffect(() => {
@ -62,7 +62,10 @@ export function QrCodeSection({
</i18n.Translate>
</p>
<br />
{abortButton}
<a
class="pure-button btn-cancel"
onClick={onAbort}
>{i18n.str`Abort`}</a>
</div>
</article>
</section>

View File

@ -13,38 +13,36 @@
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 { Logger } from "@gnu-taler/taler-util";
import { Fragment, h, VNode } from "preact";
import { route } from "preact-router";
import { StateUpdater, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { HttpStatusCode, Logger } from "@gnu-taler/taler-util";
import {
InternationalizationAPI,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { BackendStateHandler } from "../hooks/backend.js";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { PageStateType } from "../context/pageState.js";
import { useTestingAPI } from "../hooks/access.js";
import { bankUiSettings } from "../settings.js";
import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js";
import { BankFrame } from "./BankFrame.js";
import { undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("RegistrationPage");
export function RegistrationPage(): VNode {
export function RegistrationPage({
onError,
onComplete,
}: {
onComplete: () => void;
onError: (e: PageStateType["error"]) => void;
}): VNode {
const { i18n } = useTranslationContext();
if (!bankUiSettings.allowRegistrations) {
return (
<BankFrame>
<p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
</BankFrame>
<p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
);
}
return (
<BankFrame>
<RegistrationForm />
</BankFrame>
);
return <RegistrationForm onComplete={onComplete} onError={onError} />;
}
export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9]*$/;
@ -53,13 +51,19 @@ export const PASSWORD_REGEX = /^[a-z0-9][a-zA-Z0-9]*$/;
/**
* Collect and submit registration data.
*/
function RegistrationForm(): VNode {
function RegistrationForm({
onComplete,
onError,
}: {
onComplete: () => void;
onError: (e: PageStateType["error"]) => void;
}): VNode {
const backend = useBackendContext();
const { pageState, pageStateSetter } = usePageContext();
const [username, setUsername] = useState<string | undefined>();
const [password, setPassword] = useState<string | undefined>();
const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
const { register } = useTestingAPI();
const { i18n } = useTranslationContext();
const errors = undefinedIfEmpty({
@ -104,6 +108,7 @@ function RegistrationForm(): VNode {
name="register-un"
type="text"
placeholder="Username"
autocomplete="username"
value={username ?? ""}
onInput={(e): void => {
setUsername(e.currentTarget.value);
@ -121,6 +126,7 @@ function RegistrationForm(): VNode {
name="register-pw"
id="register-pw"
placeholder="Password"
autocomplete="new-password"
value={password ?? ""}
required
onInput={(e): void => {
@ -139,6 +145,7 @@ function RegistrationForm(): VNode {
style={{ marginBottom: 8 }}
name="register-repeat"
id="register-repeat"
autocomplete="new-password"
placeholder="Same password"
value={repeatPassword ?? ""}
required
@ -155,19 +162,42 @@ function RegistrationForm(): VNode {
class="pure-button pure-button-primary btn-register"
type="submit"
disabled={!!errors}
onClick={(e) => {
onClick={async (e) => {
e.preventDefault();
if (!username || !password) return;
registrationCall(
{ username, password },
backend, // will store BE URL, if OK.
pageStateSetter,
i18n,
);
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
if (!username || !password) return;
try {
const credentials = { username, password };
await register(credentials);
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
backend.logIn(credentials);
onComplete();
} catch (error) {
if (error instanceof RequestError) {
const errorData: SandboxBackend.SandboxError =
error.info.error;
if (error.info.status === HttpStatusCode.Conflict) {
onError({
title: i18n.str`That username is already taken`,
description: errorData.error.description,
debug: JSON.stringify(error.info),
});
} else {
onError({
title: i18n.str`New registration gave response error`,
description: errorData.error.description,
debug: JSON.stringify(error.info),
});
}
} else if (error instanceof Error) {
onError({
title: i18n.str`Registration failed, please report`,
description: error.message,
});
}
}
}}
>
{i18n.str`Register`}
@ -180,7 +210,7 @@ function RegistrationForm(): VNode {
setUsername(undefined);
setPassword(undefined);
setRepeatPassword(undefined);
route("/account");
onComplete();
}}
>
{i18n.str`Cancel`}
@ -192,83 +222,3 @@ function RegistrationForm(): VNode {
</Fragment>
);
}
/**
* This function requests /register.
*
* This function is responsible to change two states:
* the backend's (to store the login credentials) and
* the page's (to indicate a successful login or a problem).
*/
async function registrationCall(
req: { username: string; password: string },
/**
* FIXME: figure out if the two following
* functions can be retrieved somewhat from
* the state.
*/
backend: BackendStateHandler,
pageStateSetter: StateUpdater<PageStateType>,
i18n: InternationalizationAPI,
): Promise<void> {
const url = getBankBackendBaseUrl();
const headers = new Headers();
headers.append("Content-Type", "application/json");
const registerEndpoint = new URL("access-api/testing/register", url);
let res: Response;
try {
res = await fetch(registerEndpoint.href, {
method: "POST",
body: JSON.stringify({
username: req.username,
password: req.password,
}),
headers,
});
} catch (error) {
logger.error(
`Could not POST new registration to the bank (${registerEndpoint.href})`,
error,
);
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`Registration failed, please report`,
debug: JSON.stringify(error),
},
}));
return;
}
if (!res.ok) {
const response = await res.json();
if (res.status === 409) {
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`That username is already taken`,
debug: JSON.stringify(response),
},
}));
} else {
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`New registration gave response error`,
debug: JSON.stringify(response),
},
}));
}
} else {
// registration was ok
backend.save({
url,
username: req.username,
password: req.password,
});
route("/account");
}
}

View File

@ -14,21 +14,97 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
HttpResponsePaginated,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { createHashHistory } from "history";
import { h, VNode } from "preact";
import Router, { route, Route } from "preact-router";
import { useEffect } from "preact/hooks";
import { AccountPage } from "./AccountPage.js";
import { Loading } from "../components/Loading.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { HomePage } from "./HomePage.js";
import { BankFrame } from "./BankFrame.js";
import { PublicHistoriesPage } from "./PublicHistoriesPage.js";
import { RegistrationPage } from "./RegistrationPage.js";
function handleNotOkResult(
safe: string,
saveError: (state: PageStateType["error"]) => void,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
): <T, E>(result: HttpResponsePaginated<T, E>) => VNode {
return function handleNotOkResult2<T, E>(
result: HttpResponsePaginated<T, E>,
): VNode {
if (result.clientError && result.isUnauthorized) {
route(safe);
return <Loading />;
}
if (result.clientError && result.isNotfound) {
route(safe);
return (
<div>Page not found, you are going to be redirected to {safe}</div>
);
}
if (result.loading) return <Loading />;
if (!result.ok) {
saveError({
title: i18n.str`The backend reported a problem: HTTP status #${result.status}`,
description: i18n.str`Diagnostic from ${result.info?.url} is "${result.message}"`,
debug: JSON.stringify(result.error),
});
route(safe);
}
return <div />;
};
}
export function Routing(): VNode {
const history = createHashHistory();
const { pageStateSetter } = usePageContext();
function saveError(error: PageStateType["error"]): void {
pageStateSetter((prev) => ({ ...prev, error }));
}
const { i18n } = useTranslationContext();
return (
<Router history={history}>
<Route path="/public-accounts" component={PublicHistoriesPage} />
<Route path="/register" component={RegistrationPage} />
<Route path="/account" component={AccountPage} />
<Route
path="/public-accounts"
component={() => (
<BankFrame>
<PublicHistoriesPage
onLoadNotOk={handleNotOkResult("/account", saveError, i18n)}
/>
</BankFrame>
)}
/>
<Route
path="/register"
component={() => (
<BankFrame>
<RegistrationPage
onError={saveError}
onComplete={() => {
route("/account");
}}
/>
</BankFrame>
)}
/>
<Route
path="/account"
component={() => (
<BankFrame>
<HomePage
onRegister={() => {
route("/register");
}}
/>
</BankFrame>
)}
/>
<Route default component={Redirect} to="/account" />
</Router>
);

View File

@ -14,36 +14,54 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Logger } from "@gnu-taler/taler-util";
import { h, VNode } from "preact";
import { StateUpdater, useEffect, useRef } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { Amounts, Logger } from "@gnu-taler/taler-util";
import {
InternationalizationAPI,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { BackendState } from "../hooks/backend.js";
import { prepareHeaders, validateAmount } from "../utils.js";
import { h, VNode } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { useAccessAPI } from "../hooks/access.js";
import { undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("WalletWithdrawForm");
export function WalletWithdrawForm({
focus,
currency,
onError,
onSuccess,
}: {
currency?: string;
currency: string;
focus?: boolean;
onError: (e: PageStateType["error"]) => void;
onSuccess: (
data: SandboxBackend.Access.BankAccountCreateWithdrawalResponse,
) => void;
}): VNode {
const backend = useBackendContext();
const { pageState, pageStateSetter } = usePageContext();
// const backend = useBackendContext();
// const { pageState, pageStateSetter } = usePageContext();
const { i18n } = useTranslationContext();
let submitAmount: string | undefined = "5.00";
const { createWithdrawal } = useAccessAPI();
const [amount, setAmount] = useState<string | undefined>("5.00");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (focus) ref.current?.focus();
}, [focus]);
const amountFloat = amount ? parseFloat(amount) : undefined;
const errors = undefinedIfEmpty({
amount: !amountFloat
? i18n.str`required`
: Number.isNaN(amountFloat)
? i18n.str`should be a number`
: amountFloat < 0
? i18n.str`should be positive`
: undefined,
});
return (
<form
id="reserve-form"
@ -63,8 +81,8 @@ export function WalletWithdrawForm({
type="text"
readonly
class="currency-indicator"
size={currency?.length ?? 5}
maxLength={currency?.length}
size={currency.length}
maxLength={currency.length}
tabIndex={-1}
value={currency}
/>
@ -74,14 +92,15 @@ export function WalletWithdrawForm({
ref={ref}
id="withdraw-amount"
name="withdraw-amount"
value={submitAmount}
value={amount ?? ""}
onChange={(e): void => {
// FIXME: validate using 'parseAmount()',
// deactivate submit button as long as
// amount is not valid
submitAmount = e.currentTarget.value;
setAmount(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.amount}
isDirty={amount !== undefined}
/>
</div>
</p>
<p>
@ -90,22 +109,34 @@ export function WalletWithdrawForm({
id="select-exchange"
class="pure-button pure-button-primary"
type="submit"
disabled={!!errors}
value={i18n.str`Withdraw`}
onClick={(e) => {
onClick={async (e) => {
e.preventDefault();
submitAmount = validateAmount(submitAmount);
/**
* By invalid amounts, the validator prints error messages
* on the console, and the browser colourizes the amount input
* box to indicate a error.
*/
if (!submitAmount && currency) return;
createWithdrawalCall(
`${currency}:${submitAmount}`,
backend.state,
pageStateSetter,
i18n,
);
if (!amountFloat) return;
try {
const result = await createWithdrawal({
amount: Amounts.stringify(
Amounts.fromFloat(amountFloat, currency),
),
});
onSuccess(result.data);
} catch (error) {
if (error instanceof RequestError) {
onError({
title: i18n.str`Could not create withdrawal operation`,
description: (error as any).error.description,
debug: JSON.stringify(error),
});
}
if (error instanceof Error) {
onError({
title: i18n.str`Something when wrong trying to start the withdrawal`,
description: error.message,
});
}
}
}}
/>
</div>
@ -114,84 +145,84 @@ export function WalletWithdrawForm({
);
}
/**
* This function creates a withdrawal operation via the Access API.
*
* After having successfully created the withdrawal operation, the
* user should receive a QR code of the "taler://withdraw/" type and
* supposed to scan it with their phone.
*
* TODO: (1) after the scan, the page should refresh itself and inform
* the user about the operation's outcome. (2) use POST helper. */
async function createWithdrawalCall(
amount: string,
backendState: BackendState,
pageStateSetter: StateUpdater<PageStateType>,
i18n: InternationalizationAPI,
): Promise<void> {
if (backendState?.status === "loggedOut") {
logger.error("Page has a problem: no credentials found in the state.");
pageStateSetter((prevState) => ({
...prevState,
// /**
// * This function creates a withdrawal operation via the Access API.
// *
// * After having successfully created the withdrawal operation, the
// * user should receive a QR code of the "taler://withdraw/" type and
// * supposed to scan it with their phone.
// *
// * TODO: (1) after the scan, the page should refresh itself and inform
// * the user about the operation's outcome. (2) use POST helper. */
// async function createWithdrawalCall(
// amount: string,
// backendState: BackendState,
// pageStateSetter: StateUpdater<PageStateType>,
// i18n: InternationalizationAPI,
// ): Promise<void> {
// if (backendState?.status === "loggedOut") {
// logger.error("Page has a problem: no credentials found in the state.");
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`No credentials given.`,
},
}));
return;
}
// error: {
// title: i18n.str`No credentials given.`,
// },
// }));
// return;
// }
let res: Response;
try {
const { username, password } = backendState;
const headers = prepareHeaders(username, password);
// let res: Response;
// try {
// const { username, password } = backendState;
// const headers = prepareHeaders(username, password);
// Let bank generate withdraw URI:
const url = new URL(
`access-api/accounts/${backendState.username}/withdrawals`,
backendState.url,
);
res = await fetch(url.href, {
method: "POST",
headers,
body: JSON.stringify({ amount }),
});
} catch (error) {
logger.trace("Could not POST withdrawal request to the bank", error);
pageStateSetter((prevState) => ({
...prevState,
// // Let bank generate withdraw URI:
// const url = new URL(
// `access-api/accounts/${backendState.username}/withdrawals`,
// backendState.url,
// );
// res = await fetch(url.href, {
// method: "POST",
// headers,
// body: JSON.stringify({ amount }),
// });
// } catch (error) {
// logger.trace("Could not POST withdrawal request to the bank", error);
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`Could not create withdrawal operation`,
description: (error as any).error.description,
debug: JSON.stringify(error),
},
}));
return;
}
if (!res.ok) {
const response = await res.json();
logger.error(
`Withdrawal creation gave response error: ${response} (${res.status})`,
);
pageStateSetter((prevState) => ({
...prevState,
// error: {
// title: i18n.str`Could not create withdrawal operation`,
// description: (error as any).error.description,
// debug: JSON.stringify(error),
// },
// }));
// return;
// }
// if (!res.ok) {
// const response = await res.json();
// logger.error(
// `Withdrawal creation gave response error: ${response} (${res.status})`,
// );
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`Withdrawal creation gave response error`,
description: response.error.description,
debug: JSON.stringify(response),
},
}));
return;
}
// error: {
// title: i18n.str`Withdrawal creation gave response error`,
// description: response.error.description,
// debug: JSON.stringify(response),
// },
// }));
// return;
// }
logger.trace("Withdrawal operation created!");
const resp = await res.json();
pageStateSetter((prevState: PageStateType) => ({
...prevState,
withdrawalInProgress: true,
talerWithdrawUri: resp.taler_withdraw_uri,
withdrawalId: resp.withdrawal_id,
}));
}
// logger.trace("Withdrawal operation created!");
// const resp = await res.json();
// pageStateSetter((prevState: PageStateType) => ({
// ...prevState,
// withdrawalInProgress: true,
// talerWithdrawUri: resp.taler_withdraw_uri,
// withdrawalId: resp.withdrawal_id,
// }));
// }

View File

@ -15,24 +15,29 @@
*/
import { Logger } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { StateUpdater, useMemo, useState } from "preact/hooks";
import { useMemo, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { PageStateType, usePageContext } from "../context/pageState.js";
import {
InternationalizationAPI,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { BackendState } from "../hooks/backend.js";
import { prepareHeaders } from "../utils.js";
import { usePageContext } from "../context/pageState.js";
import { useAccessAPI } from "../hooks/access.js";
import { undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("WithdrawalConfirmationQuestion");
interface Props {
account: string;
withdrawalId: string;
}
/**
* Additional authentication required to complete the operation.
* Not providing a back button, only abort.
*/
export function WithdrawalConfirmationQuestion(): VNode {
export function WithdrawalConfirmationQuestion({
account,
withdrawalId,
}: Props): VNode {
const { pageState, pageStateSetter } = usePageContext();
const backend = useBackendContext();
const { i18n } = useTranslationContext();
@ -42,10 +47,20 @@ export function WithdrawalConfirmationQuestion(): VNode {
a: Math.floor(Math.random() * 10),
b: Math.floor(Math.random() * 10),
};
}, [pageState.withdrawalId]);
}, []);
const { confirmWithdrawal, abortWithdrawal } = useAccessAPI();
const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
const answer = parseInt(captchaAnswer ?? "", 10);
const errors = undefinedIfEmpty({
answer: !captchaAnswer
? i18n.str`Answer the question before continue`
: Number.isNaN(answer)
? i18n.str`The answer should be a number`
: answer !== captchaNumbers.a + captchaNumbers.b
? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
: undefined,
});
return (
<Fragment>
<h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
@ -82,33 +97,49 @@ export function WithdrawalConfirmationQuestion(): VNode {
setCaptchaAnswer(e.currentTarget.value);
}}
/>
<ShowInputErrorLabel
message={errors?.answer}
isDirty={captchaAnswer !== undefined}
/>
</p>
<p>
<button
type="submit"
class="pure-button pure-button-primary btn-confirm"
disabled={!!errors}
onClick={async (e) => {
e.preventDefault();
if (
captchaAnswer ==
(captchaNumbers.a + captchaNumbers.b).toString()
) {
await confirmWithdrawalCall(
backend.state,
pageState.withdrawalId,
pageStateSetter,
i18n,
);
return;
try {
await confirmWithdrawal(withdrawalId);
pageStateSetter((prevState) => {
const { talerWithdrawUri, ...rest } = prevState;
return {
...rest,
info: i18n.str`Withdrawal confirmed!`,
};
});
} catch (error) {
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`Could not confirm the withdrawal`,
description: (error as any).error.description,
debug: JSON.stringify(error),
},
}));
}
pageStateSetter((prevState: PageStateType) => ({
...prevState,
error: {
title: i18n.str`The answer "${captchaAnswer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`,
},
}));
setCaptchaAnswer(undefined);
// if (
// captchaAnswer ==
// (captchaNumbers.a + captchaNumbers.b).toString()
// ) {
// await confirmWithdrawalCall(
// backend.state,
// pageState.withdrawalId,
// pageStateSetter,
// i18n,
// );
// return;
// }
}}
>
{i18n.str`Confirm`}
@ -118,12 +149,31 @@ export function WithdrawalConfirmationQuestion(): VNode {
class="pure-button pure-button-secondary btn-cancel"
onClick={async (e) => {
e.preventDefault();
await abortWithdrawalCall(
backend.state,
pageState.withdrawalId,
pageStateSetter,
i18n,
);
try {
await abortWithdrawal(withdrawalId);
pageStateSetter((prevState) => {
const { talerWithdrawUri, ...rest } = prevState;
return {
...rest,
info: i18n.str`Withdrawal confirmed!`,
};
});
} catch (error) {
pageStateSetter((prevState) => ({
...prevState,
error: {
title: i18n.str`Could not confirm the withdrawal`,
description: (error as any).error.description,
debug: JSON.stringify(error),
},
}));
}
// await abortWithdrawalCall(
// backend.state,
// pageState.withdrawalId,
// pageStateSetter,
// i18n,
// );
}}
>
{i18n.str`Cancel`}
@ -156,188 +206,188 @@ export function WithdrawalConfirmationQuestion(): VNode {
* This function will set the confirmation status in the
* 'page state' and let the related components refresh.
*/
async function confirmWithdrawalCall(
backendState: BackendState,
withdrawalId: string | undefined,
pageStateSetter: StateUpdater<PageStateType>,
i18n: InternationalizationAPI,
): Promise<void> {
if (backendState.status === "loggedOut") {
logger.error("No credentials found.");
pageStateSetter((prevState) => ({
...prevState,
// async function confirmWithdrawalCall(
// backendState: BackendState,
// withdrawalId: string | undefined,
// pageStateSetter: StateUpdater<PageStateType>,
// i18n: InternationalizationAPI,
// ): Promise<void> {
// if (backendState.status === "loggedOut") {
// logger.error("No credentials found.");
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`No credentials found.`,
},
}));
return;
}
if (typeof withdrawalId === "undefined") {
logger.error("No withdrawal ID found.");
pageStateSetter((prevState) => ({
...prevState,
// error: {
// title: i18n.str`No credentials found.`,
// },
// }));
// return;
// }
// if (typeof withdrawalId === "undefined") {
// logger.error("No withdrawal ID found.");
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`No withdrawal ID found.`,
},
}));
return;
}
let res: Response;
try {
const { username, password } = backendState;
const headers = prepareHeaders(username, password);
/**
* NOTE: tests show that when a same object is being
* POSTed, caching might prevent same requests from being
* made. Hence, trying to POST twice the same amount might
* get silently ignored.
*
* headers.append("cache-control", "no-store");
* headers.append("cache-control", "no-cache");
* headers.append("pragma", "no-cache");
* */
// error: {
// title: i18n.str`No withdrawal ID found.`,
// },
// }));
// return;
// }
// let res: Response;
// try {
// const { username, password } = backendState;
// const headers = prepareHeaders(username, password);
// /**
// * NOTE: tests show that when a same object is being
// * POSTed, caching might prevent same requests from being
// * made. Hence, trying to POST twice the same amount might
// * get silently ignored.
// *
// * headers.append("cache-control", "no-store");
// * headers.append("cache-control", "no-cache");
// * headers.append("pragma", "no-cache");
// * */
// Backend URL must have been stored _with_ a final slash.
const url = new URL(
`access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`,
backendState.url,
);
res = await fetch(url.href, {
method: "POST",
headers,
});
} catch (error) {
logger.error("Could not POST withdrawal confirmation to the bank", error);
pageStateSetter((prevState) => ({
...prevState,
// // Backend URL must have been stored _with_ a final slash.
// const url = new URL(
// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`,
// backendState.url,
// );
// res = await fetch(url.href, {
// method: "POST",
// headers,
// });
// } catch (error) {
// logger.error("Could not POST withdrawal confirmation to the bank", error);
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`Could not confirm the withdrawal`,
description: (error as any).error.description,
debug: JSON.stringify(error),
},
}));
return;
}
if (!res || !res.ok) {
const response = await res.json();
// assume not ok if res is null
logger.error(
`Withdrawal confirmation gave response error (${res.status})`,
res.statusText,
);
pageStateSetter((prevState) => ({
...prevState,
// error: {
// title: i18n.str`Could not confirm the withdrawal`,
// description: (error as any).error.description,
// debug: JSON.stringify(error),
// },
// }));
// return;
// }
// if (!res || !res.ok) {
// const response = await res.json();
// // assume not ok if res is null
// logger.error(
// `Withdrawal confirmation gave response error (${res.status})`,
// res.statusText,
// );
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`Withdrawal confirmation gave response error`,
debug: JSON.stringify(response),
},
}));
return;
}
logger.trace("Withdrawal operation confirmed!");
pageStateSetter((prevState) => {
const { talerWithdrawUri, ...rest } = prevState;
return {
...rest,
// error: {
// title: i18n.str`Withdrawal confirmation gave response error`,
// debug: JSON.stringify(response),
// },
// }));
// return;
// }
// logger.trace("Withdrawal operation confirmed!");
// pageStateSetter((prevState) => {
// const { talerWithdrawUri, ...rest } = prevState;
// return {
// ...rest,
info: i18n.str`Withdrawal confirmed!`,
};
});
}
// info: i18n.str`Withdrawal confirmed!`,
// };
// });
// }
/**
* Abort a withdrawal operation via the Access API's /abort.
*/
async function abortWithdrawalCall(
backendState: BackendState,
withdrawalId: string | undefined,
pageStateSetter: StateUpdater<PageStateType>,
i18n: InternationalizationAPI,
): Promise<void> {
if (backendState.status === "loggedOut") {
logger.error("No credentials found.");
pageStateSetter((prevState) => ({
...prevState,
// /**
// * Abort a withdrawal operation via the Access API's /abort.
// */
// async function abortWithdrawalCall(
// backendState: BackendState,
// withdrawalId: string | undefined,
// pageStateSetter: StateUpdater<PageStateType>,
// i18n: InternationalizationAPI,
// ): Promise<void> {
// if (backendState.status === "loggedOut") {
// logger.error("No credentials found.");
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`No credentials found.`,
},
}));
return;
}
if (typeof withdrawalId === "undefined") {
logger.error("No withdrawal ID found.");
pageStateSetter((prevState) => ({
...prevState,
// error: {
// title: i18n.str`No credentials found.`,
// },
// }));
// return;
// }
// if (typeof withdrawalId === "undefined") {
// logger.error("No withdrawal ID found.");
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`No withdrawal ID found.`,
},
}));
return;
}
let res: Response;
try {
const { username, password } = backendState;
const headers = prepareHeaders(username, password);
/**
* NOTE: tests show that when a same object is being
* POSTed, caching might prevent same requests from being
* made. Hence, trying to POST twice the same amount might
* get silently ignored. Needs more observation!
*
* headers.append("cache-control", "no-store");
* headers.append("cache-control", "no-cache");
* headers.append("pragma", "no-cache");
* */
// error: {
// title: i18n.str`No withdrawal ID found.`,
// },
// }));
// return;
// }
// let res: Response;
// try {
// const { username, password } = backendState;
// const headers = prepareHeaders(username, password);
// /**
// * NOTE: tests show that when a same object is being
// * POSTed, caching might prevent same requests from being
// * made. Hence, trying to POST twice the same amount might
// * get silently ignored. Needs more observation!
// *
// * headers.append("cache-control", "no-store");
// * headers.append("cache-control", "no-cache");
// * headers.append("pragma", "no-cache");
// * */
// Backend URL must have been stored _with_ a final slash.
const url = new URL(
`access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`,
backendState.url,
);
res = await fetch(url.href, { method: "POST", headers });
} catch (error) {
logger.error("Could not abort the withdrawal", error);
pageStateSetter((prevState) => ({
...prevState,
// // Backend URL must have been stored _with_ a final slash.
// const url = new URL(
// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`,
// backendState.url,
// );
// res = await fetch(url.href, { method: "POST", headers });
// } catch (error) {
// logger.error("Could not abort the withdrawal", error);
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`Could not abort the withdrawal.`,
description: (error as any).error.description,
debug: JSON.stringify(error),
},
}));
return;
}
if (!res.ok) {
const response = await res.json();
logger.error(
`Withdrawal abort gave response error (${res.status})`,
res.statusText,
);
pageStateSetter((prevState) => ({
...prevState,
// error: {
// title: i18n.str`Could not abort the withdrawal.`,
// description: (error as any).error.description,
// debug: JSON.stringify(error),
// },
// }));
// return;
// }
// if (!res.ok) {
// const response = await res.json();
// logger.error(
// `Withdrawal abort gave response error (${res.status})`,
// res.statusText,
// );
// pageStateSetter((prevState) => ({
// ...prevState,
error: {
title: i18n.str`Withdrawal abortion failed.`,
description: response.error.description,
debug: JSON.stringify(response),
},
}));
return;
}
logger.trace("Withdrawal operation aborted!");
pageStateSetter((prevState) => {
const { ...rest } = prevState;
return {
...rest,
// error: {
// title: i18n.str`Withdrawal abortion failed.`,
// description: response.error.description,
// debug: JSON.stringify(response),
// },
// }));
// return;
// }
// logger.trace("Withdrawal operation aborted!");
// pageStateSetter((prevState) => {
// const { ...rest } = prevState;
// return {
// ...rest,
info: i18n.str`Withdrawal aborted!`,
};
});
}
// info: i18n.str`Withdrawal aborted!`,
// };
// });
// }

View File

@ -15,106 +15,67 @@
*/
import { Logger } from "@gnu-taler/taler-util";
import {
HttpResponsePaginated,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import useSWR from "swr";
import { PageStateType, usePageContext } from "../context/pageState.js";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { Loading } from "../components/Loading.js";
import { usePageContext } from "../context/pageState.js";
import { useWithdrawalDetails } from "../hooks/access.js";
import { QrCodeSection } from "./QrCodeSection.js";
import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
const logger = new Logger("WithdrawalQRCode");
interface Props {
account: string;
withdrawalId: string;
talerWithdrawUri: string;
onAbort: () => void;
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
}
/**
* Offer the QR code (and a clickable taler://-link) to
* permit the passing of exchange and reserve details to
* the bank. Poll the backend until such operation is done.
*/
export function WithdrawalQRCode({
account,
withdrawalId,
talerWithdrawUri,
}: {
withdrawalId: string;
talerWithdrawUri: string;
}): VNode {
// turns true when the wallet POSTed the reserve details:
const { pageState, pageStateSetter } = usePageContext();
const { i18n } = useTranslationContext();
const abortButton = (
<a
class="pure-button btn-cancel"
onClick={() => {
pageStateSetter((prevState: PageStateType) => {
return {
...prevState,
withdrawalId: undefined,
talerWithdrawUri: undefined,
withdrawalInProgress: false,
};
});
}}
>{i18n.str`Abort`}</a>
);
onAbort,
onLoadNotOk,
}: Props): VNode {
logger.trace(`Showing withdraw URI: ${talerWithdrawUri}`);
// waiting for the wallet:
const { data, error } = useSWR(
`integration-api/withdrawal-operation/${withdrawalId}`,
{ refreshInterval: 1000 },
);
if (typeof error !== "undefined") {
logger.error(
`withdrawal (${withdrawalId}) was never (correctly) created at the bank...`,
error,
);
pageStateSetter((prevState: PageStateType) => ({
...prevState,
error: {
title: i18n.str`withdrawal (${withdrawalId}) was never (correctly) created at the bank...`,
},
}));
return (
<Fragment>
<br />
<br />
{abortButton}
</Fragment>
);
const result = useWithdrawalDetails(account, withdrawalId);
if (!result.ok) {
return onLoadNotOk(result);
}
const { data } = result;
// data didn't arrive yet and wallet didn't communicate:
if (typeof data === "undefined")
return <p>{i18n.str`Waiting the bank to create the operation...`}</p>;
/**
* Wallet didn't communicate withdrawal details yet:
*/
logger.trace("withdrawal status", data);
if (data.aborted)
pageStateSetter((prevState: PageStateType) => {
const { withdrawalId, talerWithdrawUri, ...rest } = prevState;
return {
...rest,
withdrawalInProgress: false,
error: {
title: i18n.str`This withdrawal was aborted!`,
},
};
});
if (data.aborted) {
//signal that this withdrawal is aborted
//will redirect to account info
onAbort();
return <Loading />;
}
if (!data.selection_done) {
return (
<QrCodeSection
talerWithdrawUri={talerWithdrawUri}
abortButton={abortButton}
/>
<QrCodeSection talerWithdrawUri={talerWithdrawUri} onAbort={onAbort} />
);
}
/**
* Wallet POSTed the withdrawal details! Ask the
* user to authorize the operation (here CAPTCHA).
*/
return <WithdrawalConfirmationQuestion />;
return (
<WithdrawalConfirmationQuestion
account={account}
withdrawalId={talerWithdrawUri}
/>
);
}

View File

@ -268,3 +268,10 @@ html {
h1.nav {
text-align: center;
}
.pure-form > fieldset > label {
display: block;
}
.pure-form > fieldset > input[disabled] {
color: black !important;
}

View File

@ -43,30 +43,42 @@ export function getIbanFromPayto(url: string): string {
return iban;
}
const maybeRootPath = "https://bank.demo.taler.net/demobanks/default/";
export function getBankBackendBaseUrl(): string {
const overrideUrl = localStorage.getItem("bank-base-url");
return canonicalizeBaseUrl(overrideUrl ? overrideUrl : maybeRootPath);
}
export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
? obj
: undefined;
}
export type PartialButDefined<T> = {
[P in keyof T]: T[P] | undefined;
};
export type WithIntermediate<Type extends object> = {
[prop in keyof Type]: Type[prop] extends object ? WithIntermediate<Type[prop]> : (Type[prop] | undefined);
}
// export function partialWithObjects<T extends object>(obj: T | undefined, () => complete): WithIntermediate<T> {
// const root = obj === undefined ? {} : obj;
// return Object.entries(root).([key, value]) => {
// })
// return undefined as any
// }
/**
* Craft headers with Authorization and Content-Type.
*/
export function prepareHeaders(username?: string, password?: string): Headers {
const headers = new Headers();
if (username && password) {
headers.append(
"Authorization",
`Basic ${window.btoa(`${username}:${password}`)}`,
);
}
headers.append("Content-Type", "application/json");
return headers;
}
// export function prepareHeaders(username?: string, password?: string): Headers {
// const headers = new Headers();
// if (username && password) {
// headers.append(
// "Authorization",
// `Basic ${window.btoa(`${username}:${password}`)}`,
// );
// }
// headers.append("Content-Type", "application/json");
// return headers;
// }
export const PAGE_SIZE = 20;
export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1;