impl accout management and refactor
This commit is contained in:
parent
9b0d887a1b
commit
a8c5a9696c
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
69
packages/demobank-ui/src/components/Cashouts/index.ts
Normal file
69
packages/demobank-ui/src/components/Cashouts/index.ts
Normal 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,
|
||||
);
|
44
packages/demobank-ui/src/components/Cashouts/state.ts
Normal file
44
packages/demobank-ui/src/components/Cashouts/state.ts
Normal 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,
|
||||
};
|
||||
}
|
45
packages/demobank-ui/src/components/Cashouts/stories.tsx
Normal file
45
packages/demobank-ui/src/components/Cashouts/stories.tsx
Normal 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(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
179
packages/demobank-ui/src/components/Cashouts/test.ts
Normal file
179
packages/demobank-ui/src/components/Cashouts/test.ts
Normal 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" });
|
||||
});
|
||||
});
|
66
packages/demobank-ui/src/components/Cashouts/views.tsx
Normal file
66
packages/demobank-ui/src/components/Cashouts/views.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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",
|
||||
|
@ -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, {});
|
||||
|
@ -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;
|
||||
|
@ -31,10 +31,10 @@ export type Type = BackendStateHandler;
|
||||
|
||||
const initial: Type = {
|
||||
state: defaultState,
|
||||
clear() {
|
||||
logOut() {
|
||||
null;
|
||||
},
|
||||
save(info) {
|
||||
logIn(info) {
|
||||
null;
|
||||
},
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
}
|
||||
|
362
packages/demobank-ui/src/declaration.d.ts
vendored
362
packages/demobank-ui/src/declaration.d.ts
vendored
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
330
packages/demobank-ui/src/hooks/access.ts
Normal file
330
packages/demobank-ui/src/hooks/access.ts
Normal 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 };
|
||||
}
|
@ -62,7 +62,6 @@ export function useAsync<T>(
|
||||
};
|
||||
|
||||
function cancel() {
|
||||
// cancelPendingRequest()
|
||||
setLoading(false);
|
||||
setSlow(false);
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
317
packages/demobank-ui/src/hooks/circuit.ts
Normal file
317
packages/demobank-ui/src/hooks/circuit.ts
Normal 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 };
|
||||
}
|
@ -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 "{data.paytoUri}"</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>
|
||||
<span class="value">{`${Amounts.stringifyValue(
|
||||
balance,
|
||||
)}`}</span>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
707
packages/demobank-ui/src/pages/AdminPage.tsx
Normal file
707
packages/demobank-ui/src/pages/AdminPage.tsx
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
149
packages/demobank-ui/src/pages/HomePage.tsx
Normal file
149
packages/demobank-ui/src/pages/HomePage.tsx
Normal 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 />;
|
||||
};
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
// }));
|
||||
// }
|
||||
|
@ -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!`,
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -268,3 +268,10 @@ html {
|
||||
h1.nav {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pure-form > fieldset > label {
|
||||
display: block;
|
||||
}
|
||||
.pure-form > fieldset > input[disabled] {
|
||||
color: black !important;
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user