impl accout management and refactor
This commit is contained in:
parent
9b0d887a1b
commit
a8c5a9696c
@ -25,7 +25,7 @@
|
|||||||
"preact": "10.11.3",
|
"preact": "10.11.3",
|
||||||
"preact-router": "3.2.1",
|
"preact-router": "3.2.1",
|
||||||
"qrcode-generator": "^1.4.4",
|
"qrcode-generator": "^1.4.4",
|
||||||
"swr": "1.3.0"
|
"swr": "2.0.3"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
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";
|
import { h, VNode } from "preact";
|
||||||
|
|
||||||
export function Loading(): VNode {
|
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/>
|
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 { Loading } from "../Loading.js";
|
||||||
import { HookError, utils } from "@gnu-taler/web-util/lib/index.browser";
|
|
||||||
// import { compose, StateViewMap } from "../../utils/index.js";
|
// import { compose, StateViewMap } from "../../utils/index.js";
|
||||||
// import { wxApi } from "../../wxApi.js";
|
// import { wxApi } from "../../wxApi.js";
|
||||||
|
import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
|
||||||
import { useComponentState } from "./state.js";
|
import { useComponentState } from "./state.js";
|
||||||
import { LoadingUriView, ReadyView } from "./views.js";
|
import { LoadingUriView, ReadyView } from "./views.js";
|
||||||
import { AbsoluteTime, AmountJson } from "@gnu-taler/taler-util";
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
pageNumber: number;
|
account: string;
|
||||||
accountLabel: string;
|
|
||||||
balanceValue?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type State = State.Loading | State.LoadingUriError | State.Ready;
|
export type State = State.Loading | State.LoadingUriError | State.Ready;
|
||||||
@ -38,7 +36,7 @@ export namespace State {
|
|||||||
|
|
||||||
export interface LoadingUriError {
|
export interface LoadingUriError {
|
||||||
status: "loading-error";
|
status: "loading-error";
|
||||||
error: HookError;
|
error: HttpError<SandboxBackend.SandboxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseInfo {
|
export interface BaseInfo {
|
||||||
|
@ -15,66 +15,65 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
|
import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util";
|
||||||
import { parse } from "date-fns";
|
import { useTransactions } from "../../hooks/access.js";
|
||||||
import { useEffect } from "preact/hooks";
|
import { Props, State, Transaction } from "./index.js";
|
||||||
import useSWR from "swr";
|
|
||||||
import { Props, State } from "./index.js";
|
|
||||||
|
|
||||||
export function useComponentState({
|
export function useComponentState({
|
||||||
accountLabel,
|
account,
|
||||||
pageNumber,
|
|
||||||
balanceValue,
|
|
||||||
}: Props): State {
|
}: Props): State {
|
||||||
const { data, error, mutate } = useSWR(
|
const result = useTransactions(account)
|
||||||
`access-api/accounts/${accountLabel}/transactions?page=${pageNumber}`,
|
if (result.loading) {
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return {
|
return {
|
||||||
status: "loading",
|
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 (
|
if (
|
||||||
!item ||
|
!item ||
|
||||||
typeof item !== "object" ||
|
typeof item !== "object" ||
|
||||||
@ -120,7 +119,7 @@ export function useComponentState({
|
|||||||
amount,
|
amount,
|
||||||
subject,
|
subject,
|
||||||
};
|
};
|
||||||
});
|
}).filter((x): x is Transaction => x !== undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
@ -31,8 +31,7 @@ describe("Transaction states", () => {
|
|||||||
const env = new SwrMockEnvironment();
|
const env = new SwrMockEnvironment();
|
||||||
|
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
accountLabel: "myAccount",
|
account: "myAccount",
|
||||||
pageNumber: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
|
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
|
||||||
@ -116,8 +115,7 @@ describe("Transaction states", () => {
|
|||||||
const env = new SwrMockEnvironment();
|
const env = new SwrMockEnvironment();
|
||||||
|
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
accountLabel: "myAccount",
|
account: "myAccount",
|
||||||
pageNumber: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
|
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
|
||||||
@ -150,8 +148,7 @@ describe("Transaction states", () => {
|
|||||||
const env = new SwrMockEnvironment(false);
|
const env = new SwrMockEnvironment(false);
|
||||||
|
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
accountLabel: "myAccount",
|
account: "myAccount",
|
||||||
pageNumber: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});
|
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});
|
||||||
|
@ -24,6 +24,9 @@ import { PageStateProvider } from "../context/pageState.js";
|
|||||||
import { Routing } from "../pages/Routing.js";
|
import { Routing } from "../pages/Routing.js";
|
||||||
import { strings } from "../i18n/strings.js";
|
import { strings } from "../i18n/strings.js";
|
||||||
import { TranslationProvider } from "@gnu-taler/web-util/lib/index.browser";
|
import { TranslationProvider } from "@gnu-taler/web-util/lib/index.browser";
|
||||||
|
import { SWRConfig } from "swr";
|
||||||
|
|
||||||
|
const WITH_LOCAL_STORAGE_CACHE = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FIXME:
|
* FIXME:
|
||||||
@ -47,7 +50,15 @@ const App: FunctionalComponent = () => {
|
|||||||
<TranslationProvider source={strings}>
|
<TranslationProvider source={strings}>
|
||||||
<PageStateProvider>
|
<PageStateProvider>
|
||||||
<BackendStateProvider>
|
<BackendStateProvider>
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
provider: WITH_LOCAL_STORAGE_CACHE
|
||||||
|
? localStorageProvider
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Routing />
|
<Routing />
|
||||||
|
</SWRConfig>
|
||||||
</BackendStateProvider>
|
</BackendStateProvider>
|
||||||
</PageStateProvider>
|
</PageStateProvider>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
@ -58,4 +69,14 @@ const App: FunctionalComponent = () => {
|
|||||||
return globalLogLevel;
|
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;
|
export default App;
|
||||||
|
@ -31,10 +31,10 @@ export type Type = BackendStateHandler;
|
|||||||
|
|
||||||
const initial: Type = {
|
const initial: Type = {
|
||||||
state: defaultState,
|
state: defaultState,
|
||||||
clear() {
|
logOut() {
|
||||||
null;
|
null;
|
||||||
},
|
},
|
||||||
save(info) {
|
logIn(info) {
|
||||||
null;
|
null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
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 { useNotNullLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
|
||||||
import { ComponentChildren, createContext, h, VNode } from "preact";
|
import { ComponentChildren, createContext, h, VNode } from "preact";
|
||||||
import { StateUpdater, useContext } from "preact/hooks";
|
import { StateUpdater, useContext } from "preact/hooks";
|
||||||
@ -29,7 +30,6 @@ export type Type = {
|
|||||||
};
|
};
|
||||||
const initial: Type = {
|
const initial: Type = {
|
||||||
pageState: {
|
pageState: {
|
||||||
isRawPayto: false,
|
|
||||||
withdrawalInProgress: false,
|
withdrawalInProgress: false,
|
||||||
},
|
},
|
||||||
pageStateSetter: () => {
|
pageStateSetter: () => {
|
||||||
@ -58,7 +58,6 @@ export const PageStateProvider = ({
|
|||||||
*/
|
*/
|
||||||
function usePageState(
|
function usePageState(
|
||||||
state: PageStateType = {
|
state: PageStateType = {
|
||||||
isRawPayto: false,
|
|
||||||
withdrawalInProgress: false,
|
withdrawalInProgress: false,
|
||||||
},
|
},
|
||||||
): [PageStateType, StateUpdater<PageStateType>] {
|
): [PageStateType, StateUpdater<PageStateType>] {
|
||||||
@ -92,24 +91,24 @@ function usePageState(
|
|||||||
return [retObj, removeLatestInfo];
|
return [retObj, removeLatestInfo];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ErrorMessage = {
|
||||||
|
description?: string;
|
||||||
|
title: TranslatedString;
|
||||||
|
debug?: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Track page state.
|
* Track page state.
|
||||||
*/
|
*/
|
||||||
export interface PageStateType {
|
export interface PageStateType {
|
||||||
isRawPayto: boolean;
|
error?: ErrorMessage;
|
||||||
withdrawalInProgress: boolean;
|
info?: TranslatedString;
|
||||||
error?: {
|
|
||||||
description?: string;
|
|
||||||
title: string;
|
|
||||||
debug?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
info?: string;
|
withdrawalInProgress: boolean;
|
||||||
talerWithdrawUri?: string;
|
talerWithdrawUri?: string;
|
||||||
/**
|
/**
|
||||||
* Not strictly a presentational value, could
|
* Not strictly a presentational value, could
|
||||||
* be moved in a future "withdrawal state" object.
|
* be moved in a future "withdrawal state" object.
|
||||||
*/
|
*/
|
||||||
withdrawalId?: string;
|
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;
|
const content: any;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
declare module "jed" {
|
|
||||||
const x: any;
|
|
||||||
export = x;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**********************************************
|
/**********************************************
|
||||||
* Type definitions for states and API calls. *
|
* Type definitions for states and API calls. *
|
||||||
@ -73,3 +69,361 @@ interface WireTransferRequestType {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
amount?: 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() {
|
function cancel() {
|
||||||
// cancelPendingRequest()
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setSlow(false);
|
setSlow(false);
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,17 @@
|
|||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
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 { 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
|
* 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 type BackendState = LoggedIn | LoggedOut;
|
||||||
|
|
||||||
export interface BackendInfo {
|
export interface BackendCredentials {
|
||||||
url: string;
|
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoggedIn extends BackendInfo {
|
interface LoggedIn extends BackendCredentials {
|
||||||
|
url: string;
|
||||||
status: "loggedIn";
|
status: "loggedIn";
|
||||||
|
isUserAdministrator: boolean;
|
||||||
}
|
}
|
||||||
interface LoggedOut {
|
interface LoggedOut {
|
||||||
|
url: string;
|
||||||
status: "loggedOut";
|
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 {
|
export interface BackendStateHandler {
|
||||||
state: BackendState;
|
state: BackendState;
|
||||||
clear(): void;
|
logOut(): void;
|
||||||
save(info: BackendInfo): void;
|
logIn(info: BackendCredentials): void;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Return getters and setters for
|
* Return getters and setters for
|
||||||
@ -52,7 +75,7 @@ export function useBackendState(): BackendStateHandler {
|
|||||||
"backend-state",
|
"backend-state",
|
||||||
JSON.stringify(defaultState),
|
JSON.stringify(defaultState),
|
||||||
);
|
);
|
||||||
// const parsed = value !== undefined ? JSON.parse(value) : value;
|
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(value!);
|
parsed = JSON.parse(value!);
|
||||||
@ -63,12 +86,162 @@ export function useBackendState(): BackendStateHandler {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
clear() {
|
logOut() {
|
||||||
update(JSON.stringify(defaultState));
|
update(JSON.stringify({ ...defaultState, url: state.url }));
|
||||||
},
|
},
|
||||||
save(info) {
|
logIn(info) {
|
||||||
const nextState: BackendState = { status: "loggedIn", ...info };
|
//admin is defined by the username
|
||||||
|
const nextState: BackendState = { status: "loggedIn", url: state.url, ...info, isUserAdministrator: info.username === "admin" };
|
||||||
update(JSON.stringify(nextState));
|
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/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Amounts, HttpStatusCode, Logger } from "@gnu-taler/taler-util";
|
import { Amounts, parsePaytoUri } from "@gnu-taler/taler-util";
|
||||||
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
|
import {
|
||||||
import { ComponentChildren, Fragment, h, VNode } from "preact";
|
HttpResponsePaginated,
|
||||||
import { useEffect } from "preact/hooks";
|
useTranslationContext,
|
||||||
import useSWR, { SWRConfig, useSWRConfig } from "swr";
|
} from "@gnu-taler/web-util/lib/index.browser";
|
||||||
import { useBackendContext } from "../context/backend.js";
|
import { Fragment, h, VNode } from "preact";
|
||||||
import { PageStateType, usePageContext } from "../context/pageState.js";
|
import { useState } from "preact/hooks";
|
||||||
import { BackendInfo } from "../hooks/backend.js";
|
import { Cashouts } from "../components/Cashouts/index.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 { Transactions } from "../components/Transactions/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 {
|
interface Props {
|
||||||
const backend = useBackendContext();
|
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();
|
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 (
|
return (
|
||||||
<BankFrame>
|
<div>Payto from server is not valid "{data.paytoUri}"</div>
|
||||||
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
|
|
||||||
<LoginForm />
|
|
||||||
</BankFrame>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const accountNumber = payto.iban;
|
||||||
|
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SWRWithCredentials info={backend.state}>
|
<Fragment>
|
||||||
<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>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="nav welcome-text">
|
<h1 class="nav welcome-text">
|
||||||
<i18n.Translate>
|
<i18n.Translate>
|
||||||
Welcome,
|
Welcome,
|
||||||
{accountNumber
|
{accountNumber ? `${account} (${accountNumber})` : account}!
|
||||||
? `${accountLabel} (${accountNumber})`
|
|
||||||
: accountLabel}
|
|
||||||
!
|
|
||||||
</i18n.Translate>
|
</i18n.Translate>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@ -239,7 +85,10 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {
|
|||||||
) : (
|
) : (
|
||||||
<div class="large-amount amount">
|
<div class="large-amount amount">
|
||||||
{balanceIsDebit ? <b>-</b> : null}
|
{balanceIsDebit ? <b>-</b> : null}
|
||||||
<span class="value">{`${balanceValue}`}</span>
|
<span class="value">{`${Amounts.stringifyValue(
|
||||||
|
balance,
|
||||||
|
)}`}</span>
|
||||||
|
|
||||||
<span class="currency">{`${balance.currency}`}</span>
|
<span class="currency">{`${balance.currency}`}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -248,34 +97,56 @@ function Account({ accountLabel }: { accountLabel: string }): VNode {
|
|||||||
<section id="payments">
|
<section id="payments">
|
||||||
<div class="payments">
|
<div class="payments">
|
||||||
<h2>{i18n.str`Payments`}</h2>
|
<h2>{i18n.str`Payments`}</h2>
|
||||||
<PaymentOptions currency={balance?.currency} />
|
<PaymentOptions currency={balance.currency} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
<section id="main">
|
|
||||||
<article>
|
<section style={{ marginTop: "2em" }}>
|
||||||
<h2>{i18n.str`Latest transactions:`}</h2>
|
<Moves account={account} />
|
||||||
<Transactions
|
|
||||||
balanceValue={balanceValue}
|
|
||||||
pageNumber={0}
|
|
||||||
accountLabel={accountLabel}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
</BankFrame>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// function useTransactionPageNumber(): [number, StateUpdater<number>] {
|
function Moves({ account }: { account: string }): VNode {
|
||||||
// const ret = useNotNullLocalStorage("transaction-page", "0");
|
const [tab, setTab] = useState<"transactions" | "cashouts">("transactions");
|
||||||
// const retObj = JSON.parse(ret[0]);
|
const { i18n } = useTranslationContext();
|
||||||
// const retSetter: StateUpdater<number> = function (val) {
|
return (
|
||||||
// const newVal =
|
<article>
|
||||||
// val instanceof Function
|
<div class="payments">
|
||||||
// ? JSON.stringify(val(retObj))
|
<div class="tab">
|
||||||
// : JSON.stringify(val);
|
<button
|
||||||
// ret[1](newVal);
|
class={tab === "transactions" ? "tablinks active" : "tablinks"}
|
||||||
// };
|
onClick={(): void => {
|
||||||
// return [retObj, retSetter];
|
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 talerLogo from "../assets/logo-white.svg";
|
||||||
import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js";
|
import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js";
|
||||||
import { useBackendContext } from "../context/backend.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 { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
|
||||||
import { bankUiSettings } from "../settings.js";
|
import { bankUiSettings } from "../settings.js";
|
||||||
|
|
||||||
@ -42,7 +46,7 @@ export function BankFrame({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
pageStateSetter((prevState: PageStateType) => {
|
pageStateSetter((prevState: PageStateType) => {
|
||||||
const { talerWithdrawUri, withdrawalId, ...rest } = prevState;
|
const { talerWithdrawUri, withdrawalId, ...rest } = prevState;
|
||||||
backend.clear();
|
backend.logOut();
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
withdrawalInProgress: false,
|
withdrawalInProgress: false,
|
||||||
@ -107,7 +111,14 @@ export function BankFrame({
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<section id="main" class="content">
|
<section id="main" class="content">
|
||||||
<ErrorBanner />
|
{pageState.error && (
|
||||||
|
<ErrorBanner
|
||||||
|
error={pageState.error}
|
||||||
|
onClear={() => {
|
||||||
|
pageStateSetter((prev) => ({ ...prev, error: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StatusBanner />
|
<StatusBanner />
|
||||||
{backend.state.status === "loggedIn" ? logOut : null}
|
{backend.state.status === "loggedIn" ? logOut : null}
|
||||||
{children}
|
{children}
|
||||||
@ -136,33 +147,34 @@ function maybeDemoContent(content: VNode): VNode {
|
|||||||
return <Fragment />;
|
return <Fragment />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorBanner(): VNode | null {
|
export function ErrorBanner({
|
||||||
const { pageState, pageStateSetter } = usePageContext();
|
error,
|
||||||
|
onClear,
|
||||||
if (!pageState.error) return null;
|
}: {
|
||||||
|
error: ErrorMessage;
|
||||||
const rval = (
|
onClear: () => void;
|
||||||
|
}): VNode | null {
|
||||||
|
return (
|
||||||
<div class="informational informational-fail" style={{ marginTop: 8 }}>
|
<div class="informational informational-fail" style={{ marginTop: 8 }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
<p>
|
<p>
|
||||||
<b>{pageState.error.title}</b>
|
<b>{error.title}</b>
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="button"
|
type="button"
|
||||||
class="pure-button"
|
class="pure-button"
|
||||||
value="Clear"
|
value="Clear"
|
||||||
onClick={async () => {
|
onClick={(e) => {
|
||||||
pageStateSetter((prev) => ({ ...prev, error: undefined }));
|
e.preventDefault();
|
||||||
|
onClear();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>{pageState.error.description}</p>
|
<p>{error.description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
delete pageState.error;
|
|
||||||
return rval;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBanner(): VNode | null {
|
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/>
|
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 { route } from "preact-router";
|
import { Fragment, h, VNode } from "preact";
|
||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
import { useBackendContext } from "../context/backend.js";
|
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 { 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 { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
||||||
import { USERNAME_REGEX, PASSWORD_REGEX } from "./RegistrationPage.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect and submit login data.
|
* Collect and submit login data.
|
||||||
*/
|
*/
|
||||||
export function LoginForm(): VNode {
|
export function LoginForm({ onRegister }: { onRegister: () => void }): VNode {
|
||||||
const backend = useBackendContext();
|
const backend = useBackendContext();
|
||||||
const [username, setUsername] = useState<string | undefined>();
|
const [username, setUsername] = useState<string | undefined>();
|
||||||
const [password, setPassword] = useState<string | undefined>();
|
const [password, setPassword] = useState<string | undefined>();
|
||||||
@ -52,6 +50,9 @@ export function LoginForm(): VNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
|
||||||
|
|
||||||
<div class="login-div">
|
<div class="login-div">
|
||||||
<form
|
<form
|
||||||
class="login-form"
|
class="login-form"
|
||||||
@ -75,6 +76,7 @@ export function LoginForm(): VNode {
|
|||||||
id="username"
|
id="username"
|
||||||
value={username ?? ""}
|
value={username ?? ""}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
onInput={(e): void => {
|
onInput={(e): void => {
|
||||||
setUsername(e.currentTarget.value);
|
setUsername(e.currentTarget.value);
|
||||||
@ -91,6 +93,7 @@ export function LoginForm(): VNode {
|
|||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
id="password"
|
id="password"
|
||||||
|
autocomplete="current-password"
|
||||||
value={password ?? ""}
|
value={password ?? ""}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
required
|
required
|
||||||
@ -110,7 +113,7 @@ export function LoginForm(): VNode {
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username || !password) return;
|
if (!username || !password) return;
|
||||||
loginCall({ username, password }, backend);
|
backend.logIn({ username, password });
|
||||||
setUsername(undefined);
|
setUsername(undefined);
|
||||||
setPassword(undefined);
|
setPassword(undefined);
|
||||||
}}
|
}}
|
||||||
@ -123,7 +126,7 @@ export function LoginForm(): VNode {
|
|||||||
class="pure-button pure-button-secondary btn-cancel"
|
class="pure-button pure-button-secondary btn-cancel"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
route("/register");
|
onRegister();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i18n.str`Register`}
|
{i18n.str`Register`}
|
||||||
@ -134,25 +137,6 @@ export function LoginForm(): VNode {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
|
||||||
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
|
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
|
||||||
import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
|
import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
|
||||||
|
import { PageStateType, usePageContext } from "../context/pageState.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Let the user choose a payment option,
|
* Let the user choose a payment option,
|
||||||
* then specify the details trigger the action.
|
* 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 { i18n } = useTranslationContext();
|
||||||
|
const { pageStateSetter } = usePageContext();
|
||||||
|
|
||||||
const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">(
|
const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">(
|
||||||
"charge-wallet",
|
"charge-wallet",
|
||||||
);
|
);
|
||||||
|
function saveError(error: PageStateType["error"]): void {
|
||||||
|
pageStateSetter((prev) => ({ ...prev, error }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
@ -55,13 +60,35 @@ export function PaymentOptions({ currency }: { currency?: string }): VNode {
|
|||||||
{tab === "charge-wallet" && (
|
{tab === "charge-wallet" && (
|
||||||
<div id="charge-wallet" class="tabcontent active">
|
<div id="charge-wallet" class="tabcontent active">
|
||||||
<h3>{i18n.str`Obtain digital cash`}</h3>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tab === "wire-transfer" && (
|
{tab === "wire-transfer" && (
|
||||||
<div id="wire-transfer" class="tabcontent active">
|
<div id="wire-transfer" class="tabcontent active">
|
||||||
<h3>{i18n.str`Transfer to bank account`}</h3>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,64 +14,81 @@
|
|||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Amounts, Logger, parsePaytoUri } from "@gnu-taler/taler-util";
|
import {
|
||||||
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
|
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 { h, VNode } from "preact";
|
||||||
import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||||
import { useBackendContext } from "../context/backend.js";
|
import { useBackendContext } from "../context/backend.js";
|
||||||
import { PageStateType, usePageContext } from "../context/pageState.js";
|
import { PageStateType, usePageContext } from "../context/pageState.js";
|
||||||
import {
|
import { useAccessAPI } from "../hooks/access.js";
|
||||||
InternationalizationAPI,
|
|
||||||
useTranslationContext,
|
|
||||||
} from "@gnu-taler/web-util/lib/index.browser";
|
|
||||||
import { BackendState } from "../hooks/backend.js";
|
import { BackendState } from "../hooks/backend.js";
|
||||||
import { prepareHeaders, undefinedIfEmpty } from "../utils.js";
|
import { undefinedIfEmpty } from "../utils.js";
|
||||||
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
||||||
|
|
||||||
const logger = new Logger("PaytoWireTransferForm");
|
const logger = new Logger("PaytoWireTransferForm");
|
||||||
|
|
||||||
export function PaytoWireTransferForm({
|
export function PaytoWireTransferForm({
|
||||||
focus,
|
focus,
|
||||||
|
onError,
|
||||||
|
onSuccess,
|
||||||
currency,
|
currency,
|
||||||
}: {
|
}: {
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
currency?: string;
|
onError: (e: PageStateType["error"]) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
currency: string;
|
||||||
}): VNode {
|
}): VNode {
|
||||||
const backend = useBackendContext();
|
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>(
|
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
const ibanRegex = "^[A-Z][A-Z][0-9]+$";
|
const ibanRegex = "^[A-Z][A-Z][0-9]+$";
|
||||||
let transactionData: TransactionRequestType;
|
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focus) ref.current?.focus();
|
if (focus) ref.current?.focus();
|
||||||
}, [focus, pageState.isRawPayto]);
|
}, [focus, isRawPayto]);
|
||||||
|
|
||||||
let parsedAmount = undefined;
|
let parsedAmount = undefined;
|
||||||
|
const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
|
||||||
|
|
||||||
const errorsWire = undefinedIfEmpty({
|
const errorsWire = undefinedIfEmpty({
|
||||||
iban: !submitData?.iban
|
iban: !iban
|
||||||
? i18n.str`Missing 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`
|
? i18n.str`IBAN should have just uppercased letters and numbers`
|
||||||
: undefined,
|
: undefined,
|
||||||
subject: !submitData?.subject ? i18n.str`Missing subject` : undefined,
|
subject: !subject ? i18n.str`Missing subject` : undefined,
|
||||||
amount: !submitData?.amount
|
amount: !amount
|
||||||
? i18n.str`Missing amount`
|
? i18n.str`Missing amount`
|
||||||
: !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`))
|
: !(parsedAmount = Amounts.parse(`${currency}:${amount}`))
|
||||||
? i18n.str`Amount is not valid`
|
? i18n.str`Amount is not valid`
|
||||||
: Amounts.isZero(parsedAmount)
|
: Amounts.isZero(parsedAmount)
|
||||||
? i18n.str`Should be greater than 0`
|
? i18n.str`Should be greater than 0`
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!pageState.isRawPayto)
|
const { createTransaction } = useAccessAPI();
|
||||||
|
|
||||||
|
if (!isRawPayto)
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<form
|
<form
|
||||||
@ -90,21 +107,18 @@ export function PaytoWireTransferForm({
|
|||||||
type="text"
|
type="text"
|
||||||
id="iban"
|
id="iban"
|
||||||
name="iban"
|
name="iban"
|
||||||
value={submitData?.iban ?? ""}
|
value={iban ?? ""}
|
||||||
placeholder="CC0123456789"
|
placeholder="CC0123456789"
|
||||||
required
|
required
|
||||||
pattern={ibanRegex}
|
pattern={ibanRegex}
|
||||||
onInput={(e): void => {
|
onInput={(e): void => {
|
||||||
submitDataSetter((submitData) => ({
|
setIban(e.currentTarget.value);
|
||||||
...submitData,
|
|
||||||
iban: e.currentTarget.value,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<ShowInputErrorLabel
|
<ShowInputErrorLabel
|
||||||
message={errorsWire?.iban}
|
message={errorsWire?.iban}
|
||||||
isDirty={submitData?.iban !== undefined}
|
isDirty={iban !== undefined}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<label for="subject">{i18n.str`Transfer subject:`}</label>
|
<label for="subject">{i18n.str`Transfer subject:`}</label>
|
||||||
@ -113,19 +127,16 @@ export function PaytoWireTransferForm({
|
|||||||
name="subject"
|
name="subject"
|
||||||
id="subject"
|
id="subject"
|
||||||
placeholder="subject"
|
placeholder="subject"
|
||||||
value={submitData?.subject ?? ""}
|
value={subject ?? ""}
|
||||||
required
|
required
|
||||||
onInput={(e): void => {
|
onInput={(e): void => {
|
||||||
submitDataSetter((submitData) => ({
|
setSubject(e.currentTarget.value);
|
||||||
...submitData,
|
|
||||||
subject: e.currentTarget.value,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<ShowInputErrorLabel
|
<ShowInputErrorLabel
|
||||||
message={errorsWire?.subject}
|
message={errorsWire?.subject}
|
||||||
isDirty={submitData?.subject !== undefined}
|
isDirty={subject !== undefined}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<label for="amount">{i18n.str`Amount:`}</label>
|
<label for="amount">{i18n.str`Amount:`}</label>
|
||||||
@ -146,18 +157,15 @@ export function PaytoWireTransferForm({
|
|||||||
id="amount"
|
id="amount"
|
||||||
placeholder="amount"
|
placeholder="amount"
|
||||||
required
|
required
|
||||||
value={submitData?.amount ?? ""}
|
value={amount ?? ""}
|
||||||
onInput={(e): void => {
|
onInput={(e): void => {
|
||||||
submitDataSetter((submitData) => ({
|
setAmount(e.currentTarget.value);
|
||||||
...submitData,
|
|
||||||
amount: e.currentTarget.value,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ShowInputErrorLabel
|
<ShowInputErrorLabel
|
||||||
message={errorsWire?.amount}
|
message={errorsWire?.amount}
|
||||||
isDirty={submitData?.amount !== undefined}
|
isDirty={amount !== undefined}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -169,43 +177,28 @@ export function PaytoWireTransferForm({
|
|||||||
value="Send"
|
value="Send"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (
|
if (!(iban && subject && amount)) {
|
||||||
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.`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
transactionData = {
|
const ibanPayto = buildPayto("iban", iban, undefined);
|
||||||
paytoUri: `payto://iban/${
|
ibanPayto.params.message = encodeURIComponent(subject);
|
||||||
submitData.iban
|
const paytoUri = stringifyPaytoUri(ibanPayto);
|
||||||
}?message=${encodeURIComponent(submitData.subject)}`,
|
|
||||||
amount: `${currency}:${submitData.amount}`,
|
await createTransaction({
|
||||||
};
|
paytoUri,
|
||||||
return await createTransactionCall(
|
amount: `${currency}:${amount}`,
|
||||||
transactionData,
|
});
|
||||||
backend.state,
|
// return await createTransactionCall(
|
||||||
pageStateSetter,
|
// transactionData,
|
||||||
() =>
|
// backend.state,
|
||||||
submitDataSetter((p) => ({
|
// pageStateSetter,
|
||||||
amount: undefined,
|
// () => {
|
||||||
iban: undefined,
|
// setAmount(undefined);
|
||||||
subject: undefined,
|
// setIban(undefined);
|
||||||
})),
|
// setSubject(undefined);
|
||||||
i18n,
|
// },
|
||||||
);
|
// i18n,
|
||||||
|
// );
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@ -214,11 +207,9 @@ export function PaytoWireTransferForm({
|
|||||||
value="Clear"
|
value="Clear"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitDataSetter((p) => ({
|
setAmount(undefined);
|
||||||
amount: undefined,
|
setIban(undefined);
|
||||||
iban: undefined,
|
setSubject(undefined);
|
||||||
subject: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
@ -227,11 +218,7 @@ export function PaytoWireTransferForm({
|
|||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
logger.trace("switch to raw payto form");
|
setIsRawPayto(true);
|
||||||
pageStateSetter((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
isRawPayto: true,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i18n.str`Want to try the raw payto://-format?`}
|
{i18n.str`Want to try the raw payto://-format?`}
|
||||||
@ -240,11 +227,23 @@ export function PaytoWireTransferForm({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
|
||||||
|
|
||||||
const errorsPayto = undefinedIfEmpty({
|
const errorsPayto = undefinedIfEmpty({
|
||||||
rawPaytoInput: !rawPaytoInput
|
rawPaytoInput: !rawPaytoInput
|
||||||
? i18n.str`Missing payto address`
|
? i18n.str`required`
|
||||||
: !parsePaytoUri(rawPaytoInput)
|
: !parsed
|
||||||
? i18n.str`Payto does not follow the pattern`
|
? 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,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -296,25 +295,29 @@ export function PaytoWireTransferForm({
|
|||||||
disabled={!!errorsPayto}
|
disabled={!!errorsPayto}
|
||||||
value={i18n.str`Send`}
|
value={i18n.str`Send`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// empty string evaluates to false.
|
|
||||||
if (!rawPaytoInput) {
|
if (!rawPaytoInput) {
|
||||||
logger.error("Didn't get any raw Payto string!");
|
logger.error("Didn't get any raw Payto string!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
transactionData = { paytoUri: rawPaytoInput };
|
|
||||||
if (
|
|
||||||
typeof transactionData.paytoUri === "undefined" ||
|
|
||||||
transactionData.paytoUri.length === 0
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
return await createTransactionCall(
|
try {
|
||||||
transactionData,
|
await createTransaction({
|
||||||
backend.state,
|
paytoUri: rawPaytoInput,
|
||||||
pageStateSetter,
|
});
|
||||||
() => rawPaytoInputSetter(undefined),
|
onSuccess();
|
||||||
i18n,
|
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>
|
</p>
|
||||||
@ -322,11 +325,7 @@ export function PaytoWireTransferForm({
|
|||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
logger.trace("switch to wire-transfer-form");
|
setIsRawPayto(false);
|
||||||
pageStateSetter((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
isRawPayto: false,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i18n.str`Use wire-transfer form?`}
|
{i18n.str`Use wire-transfer form?`}
|
||||||
@ -336,115 +335,3 @@ export function PaytoWireTransferForm({
|
|||||||
</div>
|
</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 { Logger } from "@gnu-taler/taler-util";
|
||||||
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
|
import {
|
||||||
import { ComponentChildren, Fragment, h, VNode } from "preact";
|
HttpResponsePaginated,
|
||||||
import { route } from "preact-router";
|
useLocalStorage,
|
||||||
|
useTranslationContext,
|
||||||
|
} from "@gnu-taler/web-util/lib/index.browser";
|
||||||
|
import { Fragment, h, VNode } from "preact";
|
||||||
import { StateUpdater } from "preact/hooks";
|
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 { Transactions } from "../components/Transactions/index.js";
|
||||||
|
import { usePublicAccounts } from "../hooks/access.js";
|
||||||
|
|
||||||
const logger = new Logger("PublicHistoriesPage");
|
const logger = new Logger("PublicHistoriesPage");
|
||||||
|
|
||||||
export function PublicHistoriesPage(): VNode {
|
// export function PublicHistoriesPage2(): VNode {
|
||||||
return (
|
// return (
|
||||||
<SWRWithoutCredentials baseUrl={getBankBackendBaseUrl()}>
|
// <BankFrame>
|
||||||
<BankFrame>
|
// <PublicHistories />
|
||||||
<PublicHistories />
|
// </BankFrame>
|
||||||
</BankFrame>
|
// );
|
||||||
</SWRWithoutCredentials>
|
// }
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SWRWithoutCredentials({
|
interface Props {
|
||||||
baseUrl,
|
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show histories of public accounts.
|
* Show histories of public accounts.
|
||||||
*/
|
*/
|
||||||
function PublicHistories(): VNode {
|
export function PublicHistoriesPage({ onLoadNotOk }: Props): VNode {
|
||||||
const { pageState, pageStateSetter } = usePageContext();
|
|
||||||
const [showAccount, setShowAccount] = useShowPublicAccount();
|
const [showAccount, setShowAccount] = useShowPublicAccount();
|
||||||
const { data, error } = useSWR("access-api/public-accounts");
|
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
|
|
||||||
if (typeof error !== "undefined") {
|
const result = usePublicAccounts();
|
||||||
switch (error.status) {
|
if (!result.ok) return onLoadNotOk(result);
|
||||||
case 404:
|
|
||||||
logger.error("public accounts: 404", error);
|
|
||||||
route("/account");
|
|
||||||
pageStateSetter((prevState: PageStateType) => ({
|
|
||||||
...prevState,
|
|
||||||
|
|
||||||
error: {
|
const { data } = result;
|
||||||
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,
|
|
||||||
|
|
||||||
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 txs: Record<string, h.JSX.Element> = {};
|
||||||
const accountsBar = [];
|
const accountsBar = [];
|
||||||
|
|
||||||
@ -133,9 +84,7 @@ function PublicHistories(): VNode {
|
|||||||
</a>
|
</a>
|
||||||
</li>,
|
</li>,
|
||||||
);
|
);
|
||||||
txs[account.accountLabel] = (
|
txs[account.accountLabel] = <Transactions account={account.accountLabel} />;
|
||||||
<Transactions accountLabel={account.accountLabel} pageNumber={0} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -21,10 +21,10 @@ import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
|
|||||||
|
|
||||||
export function QrCodeSection({
|
export function QrCodeSection({
|
||||||
talerWithdrawUri,
|
talerWithdrawUri,
|
||||||
abortButton,
|
onAbort,
|
||||||
}: {
|
}: {
|
||||||
talerWithdrawUri: string;
|
talerWithdrawUri: string;
|
||||||
abortButton: h.JSX.Element;
|
onAbort: () => void;
|
||||||
}): VNode {
|
}): VNode {
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -62,7 +62,10 @@ export function QrCodeSection({
|
|||||||
</i18n.Translate>
|
</i18n.Translate>
|
||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
{abortButton}
|
<a
|
||||||
|
class="pure-button btn-cancel"
|
||||||
|
onClick={onAbort}
|
||||||
|
>{i18n.str`Abort`}</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
@ -13,38 +13,36 @@
|
|||||||
You should have received a copy of the GNU General Public License along with
|
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/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
import { Logger } from "@gnu-taler/taler-util";
|
import { HttpStatusCode, 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 {
|
import {
|
||||||
InternationalizationAPI,
|
RequestError,
|
||||||
useTranslationContext,
|
useTranslationContext,
|
||||||
} from "@gnu-taler/web-util/lib/index.browser";
|
} 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 { bankUiSettings } from "../settings.js";
|
||||||
import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js";
|
import { undefinedIfEmpty } from "../utils.js";
|
||||||
import { BankFrame } from "./BankFrame.js";
|
|
||||||
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
||||||
|
|
||||||
const logger = new Logger("RegistrationPage");
|
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();
|
const { i18n } = useTranslationContext();
|
||||||
if (!bankUiSettings.allowRegistrations) {
|
if (!bankUiSettings.allowRegistrations) {
|
||||||
return (
|
return (
|
||||||
<BankFrame>
|
|
||||||
<p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
|
<p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
|
||||||
</BankFrame>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <RegistrationForm onComplete={onComplete} onError={onError} />;
|
||||||
<BankFrame>
|
|
||||||
<RegistrationForm />
|
|
||||||
</BankFrame>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9]*$/;
|
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.
|
* Collect and submit registration data.
|
||||||
*/
|
*/
|
||||||
function RegistrationForm(): VNode {
|
function RegistrationForm({
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onComplete: () => void;
|
||||||
|
onError: (e: PageStateType["error"]) => void;
|
||||||
|
}): VNode {
|
||||||
const backend = useBackendContext();
|
const backend = useBackendContext();
|
||||||
const { pageState, pageStateSetter } = usePageContext();
|
|
||||||
const [username, setUsername] = useState<string | undefined>();
|
const [username, setUsername] = useState<string | undefined>();
|
||||||
const [password, setPassword] = useState<string | undefined>();
|
const [password, setPassword] = useState<string | undefined>();
|
||||||
const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
|
const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { register } = useTestingAPI();
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
|
|
||||||
const errors = undefinedIfEmpty({
|
const errors = undefinedIfEmpty({
|
||||||
@ -104,6 +108,7 @@ function RegistrationForm(): VNode {
|
|||||||
name="register-un"
|
name="register-un"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
|
autocomplete="username"
|
||||||
value={username ?? ""}
|
value={username ?? ""}
|
||||||
onInput={(e): void => {
|
onInput={(e): void => {
|
||||||
setUsername(e.currentTarget.value);
|
setUsername(e.currentTarget.value);
|
||||||
@ -121,6 +126,7 @@ function RegistrationForm(): VNode {
|
|||||||
name="register-pw"
|
name="register-pw"
|
||||||
id="register-pw"
|
id="register-pw"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
|
autocomplete="new-password"
|
||||||
value={password ?? ""}
|
value={password ?? ""}
|
||||||
required
|
required
|
||||||
onInput={(e): void => {
|
onInput={(e): void => {
|
||||||
@ -139,6 +145,7 @@ function RegistrationForm(): VNode {
|
|||||||
style={{ marginBottom: 8 }}
|
style={{ marginBottom: 8 }}
|
||||||
name="register-repeat"
|
name="register-repeat"
|
||||||
id="register-repeat"
|
id="register-repeat"
|
||||||
|
autocomplete="new-password"
|
||||||
placeholder="Same password"
|
placeholder="Same password"
|
||||||
value={repeatPassword ?? ""}
|
value={repeatPassword ?? ""}
|
||||||
required
|
required
|
||||||
@ -155,19 +162,42 @@ function RegistrationForm(): VNode {
|
|||||||
class="pure-button pure-button-primary btn-register"
|
class="pure-button pure-button-primary btn-register"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!!errors}
|
disabled={!!errors}
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username || !password) return;
|
|
||||||
registrationCall(
|
|
||||||
{ username, password },
|
|
||||||
backend, // will store BE URL, if OK.
|
|
||||||
pageStateSetter,
|
|
||||||
i18n,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (!username || !password) return;
|
||||||
|
try {
|
||||||
|
const credentials = { username, password };
|
||||||
|
await register(credentials);
|
||||||
setUsername(undefined);
|
setUsername(undefined);
|
||||||
setPassword(undefined);
|
setPassword(undefined);
|
||||||
setRepeatPassword(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`}
|
{i18n.str`Register`}
|
||||||
@ -180,7 +210,7 @@ function RegistrationForm(): VNode {
|
|||||||
setUsername(undefined);
|
setUsername(undefined);
|
||||||
setPassword(undefined);
|
setPassword(undefined);
|
||||||
setRepeatPassword(undefined);
|
setRepeatPassword(undefined);
|
||||||
route("/account");
|
onComplete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i18n.str`Cancel`}
|
{i18n.str`Cancel`}
|
||||||
@ -192,83 +222,3 @@ function RegistrationForm(): VNode {
|
|||||||
</Fragment>
|
</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/>
|
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 { createHashHistory } from "history";
|
||||||
import { h, VNode } from "preact";
|
import { h, VNode } from "preact";
|
||||||
import Router, { route, Route } from "preact-router";
|
import Router, { route, Route } from "preact-router";
|
||||||
import { useEffect } from "preact/hooks";
|
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 { PublicHistoriesPage } from "./PublicHistoriesPage.js";
|
||||||
import { RegistrationPage } from "./RegistrationPage.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 {
|
export function Routing(): VNode {
|
||||||
const history = createHashHistory();
|
const history = createHashHistory();
|
||||||
|
const { pageStateSetter } = usePageContext();
|
||||||
|
|
||||||
|
function saveError(error: PageStateType["error"]): void {
|
||||||
|
pageStateSetter((prev) => ({ ...prev, error }));
|
||||||
|
}
|
||||||
|
const { i18n } = useTranslationContext();
|
||||||
return (
|
return (
|
||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<Route path="/public-accounts" component={PublicHistoriesPage} />
|
<Route
|
||||||
<Route path="/register" component={RegistrationPage} />
|
path="/public-accounts"
|
||||||
<Route path="/account" component={AccountPage} />
|
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" />
|
<Route default component={Redirect} to="/account" />
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
@ -14,36 +14,54 @@
|
|||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Logger } from "@gnu-taler/taler-util";
|
import { Amounts, 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 {
|
import {
|
||||||
InternationalizationAPI,
|
RequestError,
|
||||||
useTranslationContext,
|
useTranslationContext,
|
||||||
} from "@gnu-taler/web-util/lib/index.browser";
|
} from "@gnu-taler/web-util/lib/index.browser";
|
||||||
import { BackendState } from "../hooks/backend.js";
|
import { h, VNode } from "preact";
|
||||||
import { prepareHeaders, validateAmount } from "../utils.js";
|
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");
|
const logger = new Logger("WalletWithdrawForm");
|
||||||
|
|
||||||
export function WalletWithdrawForm({
|
export function WalletWithdrawForm({
|
||||||
focus,
|
focus,
|
||||||
currency,
|
currency,
|
||||||
|
onError,
|
||||||
|
onSuccess,
|
||||||
}: {
|
}: {
|
||||||
currency?: string;
|
currency: string;
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
|
onError: (e: PageStateType["error"]) => void;
|
||||||
|
onSuccess: (
|
||||||
|
data: SandboxBackend.Access.BankAccountCreateWithdrawalResponse,
|
||||||
|
) => void;
|
||||||
}): VNode {
|
}): VNode {
|
||||||
const backend = useBackendContext();
|
// const backend = useBackendContext();
|
||||||
const { pageState, pageStateSetter } = usePageContext();
|
// const { pageState, pageStateSetter } = usePageContext();
|
||||||
const { i18n } = useTranslationContext();
|
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);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focus) ref.current?.focus();
|
if (focus) ref.current?.focus();
|
||||||
}, [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 (
|
return (
|
||||||
<form
|
<form
|
||||||
id="reserve-form"
|
id="reserve-form"
|
||||||
@ -63,8 +81,8 @@ export function WalletWithdrawForm({
|
|||||||
type="text"
|
type="text"
|
||||||
readonly
|
readonly
|
||||||
class="currency-indicator"
|
class="currency-indicator"
|
||||||
size={currency?.length ?? 5}
|
size={currency.length}
|
||||||
maxLength={currency?.length}
|
maxLength={currency.length}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
value={currency}
|
value={currency}
|
||||||
/>
|
/>
|
||||||
@ -74,14 +92,15 @@ export function WalletWithdrawForm({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
id="withdraw-amount"
|
id="withdraw-amount"
|
||||||
name="withdraw-amount"
|
name="withdraw-amount"
|
||||||
value={submitAmount}
|
value={amount ?? ""}
|
||||||
onChange={(e): void => {
|
onChange={(e): void => {
|
||||||
// FIXME: validate using 'parseAmount()',
|
setAmount(e.currentTarget.value);
|
||||||
// deactivate submit button as long as
|
|
||||||
// amount is not valid
|
|
||||||
submitAmount = e.currentTarget.value;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ShowInputErrorLabel
|
||||||
|
message={errors?.amount}
|
||||||
|
isDirty={amount !== undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@ -90,22 +109,34 @@ export function WalletWithdrawForm({
|
|||||||
id="select-exchange"
|
id="select-exchange"
|
||||||
class="pure-button pure-button-primary"
|
class="pure-button pure-button-primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={!!errors}
|
||||||
value={i18n.str`Withdraw`}
|
value={i18n.str`Withdraw`}
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitAmount = validateAmount(submitAmount);
|
if (!amountFloat) return;
|
||||||
/**
|
try {
|
||||||
* By invalid amounts, the validator prints error messages
|
const result = await createWithdrawal({
|
||||||
* on the console, and the browser colourizes the amount input
|
amount: Amounts.stringify(
|
||||||
* box to indicate a error.
|
Amounts.fromFloat(amountFloat, currency),
|
||||||
*/
|
),
|
||||||
if (!submitAmount && currency) return;
|
});
|
||||||
createWithdrawalCall(
|
|
||||||
`${currency}:${submitAmount}`,
|
onSuccess(result.data);
|
||||||
backend.state,
|
} catch (error) {
|
||||||
pageStateSetter,
|
if (error instanceof RequestError) {
|
||||||
i18n,
|
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>
|
</div>
|
||||||
@ -114,84 +145,84 @@ export function WalletWithdrawForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* This function creates a withdrawal operation via the Access API.
|
// * This function creates a withdrawal operation via the Access API.
|
||||||
*
|
// *
|
||||||
* After having successfully created the withdrawal operation, the
|
// * After having successfully created the withdrawal operation, the
|
||||||
* user should receive a QR code of the "taler://withdraw/" type and
|
// * user should receive a QR code of the "taler://withdraw/" type and
|
||||||
* supposed to scan it with their phone.
|
// * supposed to scan it with their phone.
|
||||||
*
|
// *
|
||||||
* TODO: (1) after the scan, the page should refresh itself and inform
|
// * TODO: (1) after the scan, the page should refresh itself and inform
|
||||||
* the user about the operation's outcome. (2) use POST helper. */
|
// * the user about the operation's outcome. (2) use POST helper. */
|
||||||
async function createWithdrawalCall(
|
// async function createWithdrawalCall(
|
||||||
amount: string,
|
// amount: string,
|
||||||
backendState: BackendState,
|
// backendState: BackendState,
|
||||||
pageStateSetter: StateUpdater<PageStateType>,
|
// pageStateSetter: StateUpdater<PageStateType>,
|
||||||
i18n: InternationalizationAPI,
|
// i18n: InternationalizationAPI,
|
||||||
): Promise<void> {
|
// ): Promise<void> {
|
||||||
if (backendState?.status === "loggedOut") {
|
// if (backendState?.status === "loggedOut") {
|
||||||
logger.error("Page has a problem: no credentials found in the state.");
|
// logger.error("Page has a problem: no credentials found in the state.");
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`No credentials given.`,
|
// title: i18n.str`No credentials given.`,
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
let res: Response;
|
// let res: Response;
|
||||||
try {
|
// try {
|
||||||
const { username, password } = backendState;
|
// const { username, password } = backendState;
|
||||||
const headers = prepareHeaders(username, password);
|
// const headers = prepareHeaders(username, password);
|
||||||
|
|
||||||
// Let bank generate withdraw URI:
|
// // Let bank generate withdraw URI:
|
||||||
const url = new URL(
|
// const url = new URL(
|
||||||
`access-api/accounts/${backendState.username}/withdrawals`,
|
// `access-api/accounts/${backendState.username}/withdrawals`,
|
||||||
backendState.url,
|
// backendState.url,
|
||||||
);
|
// );
|
||||||
res = await fetch(url.href, {
|
// res = await fetch(url.href, {
|
||||||
method: "POST",
|
// method: "POST",
|
||||||
headers,
|
// headers,
|
||||||
body: JSON.stringify({ amount }),
|
// body: JSON.stringify({ amount }),
|
||||||
});
|
// });
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
logger.trace("Could not POST withdrawal request to the bank", error);
|
// logger.trace("Could not POST withdrawal request to the bank", error);
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`Could not create withdrawal operation`,
|
// title: i18n.str`Could not create withdrawal operation`,
|
||||||
description: (error as any).error.description,
|
// description: (error as any).error.description,
|
||||||
debug: JSON.stringify(error),
|
// debug: JSON.stringify(error),
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
if (!res.ok) {
|
// if (!res.ok) {
|
||||||
const response = await res.json();
|
// const response = await res.json();
|
||||||
logger.error(
|
// logger.error(
|
||||||
`Withdrawal creation gave response error: ${response} (${res.status})`,
|
// `Withdrawal creation gave response error: ${response} (${res.status})`,
|
||||||
);
|
// );
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`Withdrawal creation gave response error`,
|
// title: i18n.str`Withdrawal creation gave response error`,
|
||||||
description: response.error.description,
|
// description: response.error.description,
|
||||||
debug: JSON.stringify(response),
|
// debug: JSON.stringify(response),
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
logger.trace("Withdrawal operation created!");
|
// logger.trace("Withdrawal operation created!");
|
||||||
const resp = await res.json();
|
// const resp = await res.json();
|
||||||
pageStateSetter((prevState: PageStateType) => ({
|
// pageStateSetter((prevState: PageStateType) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
withdrawalInProgress: true,
|
// withdrawalInProgress: true,
|
||||||
talerWithdrawUri: resp.taler_withdraw_uri,
|
// talerWithdrawUri: resp.taler_withdraw_uri,
|
||||||
withdrawalId: resp.withdrawal_id,
|
// withdrawalId: resp.withdrawal_id,
|
||||||
}));
|
// }));
|
||||||
}
|
// }
|
||||||
|
@ -15,24 +15,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Logger } from "@gnu-taler/taler-util";
|
import { Logger } from "@gnu-taler/taler-util";
|
||||||
|
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
|
||||||
import { Fragment, h, VNode } from "preact";
|
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 { useBackendContext } from "../context/backend.js";
|
||||||
import { PageStateType, usePageContext } from "../context/pageState.js";
|
import { usePageContext } from "../context/pageState.js";
|
||||||
import {
|
import { useAccessAPI } from "../hooks/access.js";
|
||||||
InternationalizationAPI,
|
import { undefinedIfEmpty } from "../utils.js";
|
||||||
useTranslationContext,
|
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
||||||
} from "@gnu-taler/web-util/lib/index.browser";
|
|
||||||
import { BackendState } from "../hooks/backend.js";
|
|
||||||
import { prepareHeaders } from "../utils.js";
|
|
||||||
|
|
||||||
const logger = new Logger("WithdrawalConfirmationQuestion");
|
const logger = new Logger("WithdrawalConfirmationQuestion");
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
account: string;
|
||||||
|
withdrawalId: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Additional authentication required to complete the operation.
|
* Additional authentication required to complete the operation.
|
||||||
* Not providing a back button, only abort.
|
* Not providing a back button, only abort.
|
||||||
*/
|
*/
|
||||||
export function WithdrawalConfirmationQuestion(): VNode {
|
export function WithdrawalConfirmationQuestion({
|
||||||
|
account,
|
||||||
|
withdrawalId,
|
||||||
|
}: Props): VNode {
|
||||||
const { pageState, pageStateSetter } = usePageContext();
|
const { pageState, pageStateSetter } = usePageContext();
|
||||||
const backend = useBackendContext();
|
const backend = useBackendContext();
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
@ -42,10 +47,20 @@ export function WithdrawalConfirmationQuestion(): VNode {
|
|||||||
a: Math.floor(Math.random() * 10),
|
a: Math.floor(Math.random() * 10),
|
||||||
b: Math.floor(Math.random() * 10),
|
b: Math.floor(Math.random() * 10),
|
||||||
};
|
};
|
||||||
}, [pageState.withdrawalId]);
|
}, []);
|
||||||
|
|
||||||
|
const { confirmWithdrawal, abortWithdrawal } = useAccessAPI();
|
||||||
const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
|
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 (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
|
<h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
|
||||||
@ -82,33 +97,49 @@ export function WithdrawalConfirmationQuestion(): VNode {
|
|||||||
setCaptchaAnswer(e.currentTarget.value);
|
setCaptchaAnswer(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ShowInputErrorLabel
|
||||||
|
message={errors?.answer}
|
||||||
|
isDirty={captchaAnswer !== undefined}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="pure-button pure-button-primary btn-confirm"
|
class="pure-button pure-button-primary btn-confirm"
|
||||||
|
disabled={!!errors}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (
|
try {
|
||||||
captchaAnswer ==
|
await confirmWithdrawal(withdrawalId);
|
||||||
(captchaNumbers.a + captchaNumbers.b).toString()
|
pageStateSetter((prevState) => {
|
||||||
) {
|
const { talerWithdrawUri, ...rest } = prevState;
|
||||||
await confirmWithdrawalCall(
|
return {
|
||||||
backend.state,
|
...rest,
|
||||||
pageState.withdrawalId,
|
info: i18n.str`Withdrawal confirmed!`,
|
||||||
pageStateSetter,
|
};
|
||||||
i18n,
|
});
|
||||||
);
|
} catch (error) {
|
||||||
return;
|
pageStateSetter((prevState) => ({
|
||||||
}
|
|
||||||
pageStateSetter((prevState: PageStateType) => ({
|
|
||||||
...prevState,
|
...prevState,
|
||||||
|
|
||||||
error: {
|
error: {
|
||||||
title: i18n.str`The answer "${captchaAnswer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`,
|
title: i18n.str`Could not confirm the withdrawal`,
|
||||||
|
description: (error as any).error.description,
|
||||||
|
debug: JSON.stringify(error),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
setCaptchaAnswer(undefined);
|
}
|
||||||
|
// if (
|
||||||
|
// captchaAnswer ==
|
||||||
|
// (captchaNumbers.a + captchaNumbers.b).toString()
|
||||||
|
// ) {
|
||||||
|
// await confirmWithdrawalCall(
|
||||||
|
// backend.state,
|
||||||
|
// pageState.withdrawalId,
|
||||||
|
// pageStateSetter,
|
||||||
|
// i18n,
|
||||||
|
// );
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i18n.str`Confirm`}
|
{i18n.str`Confirm`}
|
||||||
@ -118,12 +149,31 @@ export function WithdrawalConfirmationQuestion(): VNode {
|
|||||||
class="pure-button pure-button-secondary btn-cancel"
|
class="pure-button pure-button-secondary btn-cancel"
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await abortWithdrawalCall(
|
try {
|
||||||
backend.state,
|
await abortWithdrawal(withdrawalId);
|
||||||
pageState.withdrawalId,
|
pageStateSetter((prevState) => {
|
||||||
pageStateSetter,
|
const { talerWithdrawUri, ...rest } = prevState;
|
||||||
i18n,
|
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`}
|
{i18n.str`Cancel`}
|
||||||
@ -156,188 +206,188 @@ export function WithdrawalConfirmationQuestion(): VNode {
|
|||||||
* This function will set the confirmation status in the
|
* This function will set the confirmation status in the
|
||||||
* 'page state' and let the related components refresh.
|
* 'page state' and let the related components refresh.
|
||||||
*/
|
*/
|
||||||
async function confirmWithdrawalCall(
|
// async function confirmWithdrawalCall(
|
||||||
backendState: BackendState,
|
// backendState: BackendState,
|
||||||
withdrawalId: string | undefined,
|
// withdrawalId: string | undefined,
|
||||||
pageStateSetter: StateUpdater<PageStateType>,
|
// pageStateSetter: StateUpdater<PageStateType>,
|
||||||
i18n: InternationalizationAPI,
|
// i18n: InternationalizationAPI,
|
||||||
): Promise<void> {
|
// ): Promise<void> {
|
||||||
if (backendState.status === "loggedOut") {
|
// if (backendState.status === "loggedOut") {
|
||||||
logger.error("No credentials found.");
|
// logger.error("No credentials found.");
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`No credentials found.`,
|
// title: i18n.str`No credentials found.`,
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
if (typeof withdrawalId === "undefined") {
|
// if (typeof withdrawalId === "undefined") {
|
||||||
logger.error("No withdrawal ID found.");
|
// logger.error("No withdrawal ID found.");
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`No withdrawal ID found.`,
|
// title: i18n.str`No withdrawal ID found.`,
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
let res: Response;
|
// let res: Response;
|
||||||
try {
|
// try {
|
||||||
const { username, password } = backendState;
|
// const { username, password } = backendState;
|
||||||
const headers = prepareHeaders(username, password);
|
// const headers = prepareHeaders(username, password);
|
||||||
/**
|
// /**
|
||||||
* NOTE: tests show that when a same object is being
|
// * NOTE: tests show that when a same object is being
|
||||||
* POSTed, caching might prevent same requests from being
|
// * POSTed, caching might prevent same requests from being
|
||||||
* made. Hence, trying to POST twice the same amount might
|
// * made. Hence, trying to POST twice the same amount might
|
||||||
* get silently ignored.
|
// * get silently ignored.
|
||||||
*
|
// *
|
||||||
* headers.append("cache-control", "no-store");
|
// * headers.append("cache-control", "no-store");
|
||||||
* headers.append("cache-control", "no-cache");
|
// * headers.append("cache-control", "no-cache");
|
||||||
* headers.append("pragma", "no-cache");
|
// * headers.append("pragma", "no-cache");
|
||||||
* */
|
// * */
|
||||||
|
|
||||||
// Backend URL must have been stored _with_ a final slash.
|
// // Backend URL must have been stored _with_ a final slash.
|
||||||
const url = new URL(
|
// const url = new URL(
|
||||||
`access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`,
|
// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`,
|
||||||
backendState.url,
|
// backendState.url,
|
||||||
);
|
// );
|
||||||
res = await fetch(url.href, {
|
// res = await fetch(url.href, {
|
||||||
method: "POST",
|
// method: "POST",
|
||||||
headers,
|
// headers,
|
||||||
});
|
// });
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
logger.error("Could not POST withdrawal confirmation to the bank", error);
|
// logger.error("Could not POST withdrawal confirmation to the bank", error);
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`Could not confirm the withdrawal`,
|
// title: i18n.str`Could not confirm the withdrawal`,
|
||||||
description: (error as any).error.description,
|
// description: (error as any).error.description,
|
||||||
debug: JSON.stringify(error),
|
// debug: JSON.stringify(error),
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
if (!res || !res.ok) {
|
// if (!res || !res.ok) {
|
||||||
const response = await res.json();
|
// const response = await res.json();
|
||||||
// assume not ok if res is null
|
// // assume not ok if res is null
|
||||||
logger.error(
|
// logger.error(
|
||||||
`Withdrawal confirmation gave response error (${res.status})`,
|
// `Withdrawal confirmation gave response error (${res.status})`,
|
||||||
res.statusText,
|
// res.statusText,
|
||||||
);
|
// );
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`Withdrawal confirmation gave response error`,
|
// title: i18n.str`Withdrawal confirmation gave response error`,
|
||||||
debug: JSON.stringify(response),
|
// debug: JSON.stringify(response),
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
logger.trace("Withdrawal operation confirmed!");
|
// logger.trace("Withdrawal operation confirmed!");
|
||||||
pageStateSetter((prevState) => {
|
// pageStateSetter((prevState) => {
|
||||||
const { talerWithdrawUri, ...rest } = prevState;
|
// const { talerWithdrawUri, ...rest } = prevState;
|
||||||
return {
|
// return {
|
||||||
...rest,
|
// ...rest,
|
||||||
|
|
||||||
info: i18n.str`Withdrawal confirmed!`,
|
// info: i18n.str`Withdrawal confirmed!`,
|
||||||
};
|
// };
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Abort a withdrawal operation via the Access API's /abort.
|
// * Abort a withdrawal operation via the Access API's /abort.
|
||||||
*/
|
// */
|
||||||
async function abortWithdrawalCall(
|
// async function abortWithdrawalCall(
|
||||||
backendState: BackendState,
|
// backendState: BackendState,
|
||||||
withdrawalId: string | undefined,
|
// withdrawalId: string | undefined,
|
||||||
pageStateSetter: StateUpdater<PageStateType>,
|
// pageStateSetter: StateUpdater<PageStateType>,
|
||||||
i18n: InternationalizationAPI,
|
// i18n: InternationalizationAPI,
|
||||||
): Promise<void> {
|
// ): Promise<void> {
|
||||||
if (backendState.status === "loggedOut") {
|
// if (backendState.status === "loggedOut") {
|
||||||
logger.error("No credentials found.");
|
// logger.error("No credentials found.");
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`No credentials found.`,
|
// title: i18n.str`No credentials found.`,
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
if (typeof withdrawalId === "undefined") {
|
// if (typeof withdrawalId === "undefined") {
|
||||||
logger.error("No withdrawal ID found.");
|
// logger.error("No withdrawal ID found.");
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`No withdrawal ID found.`,
|
// title: i18n.str`No withdrawal ID found.`,
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
let res: Response;
|
// let res: Response;
|
||||||
try {
|
// try {
|
||||||
const { username, password } = backendState;
|
// const { username, password } = backendState;
|
||||||
const headers = prepareHeaders(username, password);
|
// const headers = prepareHeaders(username, password);
|
||||||
/**
|
// /**
|
||||||
* NOTE: tests show that when a same object is being
|
// * NOTE: tests show that when a same object is being
|
||||||
* POSTed, caching might prevent same requests from being
|
// * POSTed, caching might prevent same requests from being
|
||||||
* made. Hence, trying to POST twice the same amount might
|
// * made. Hence, trying to POST twice the same amount might
|
||||||
* get silently ignored. Needs more observation!
|
// * get silently ignored. Needs more observation!
|
||||||
*
|
// *
|
||||||
* headers.append("cache-control", "no-store");
|
// * headers.append("cache-control", "no-store");
|
||||||
* headers.append("cache-control", "no-cache");
|
// * headers.append("cache-control", "no-cache");
|
||||||
* headers.append("pragma", "no-cache");
|
// * headers.append("pragma", "no-cache");
|
||||||
* */
|
// * */
|
||||||
|
|
||||||
// Backend URL must have been stored _with_ a final slash.
|
// // Backend URL must have been stored _with_ a final slash.
|
||||||
const url = new URL(
|
// const url = new URL(
|
||||||
`access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`,
|
// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`,
|
||||||
backendState.url,
|
// backendState.url,
|
||||||
);
|
// );
|
||||||
res = await fetch(url.href, { method: "POST", headers });
|
// res = await fetch(url.href, { method: "POST", headers });
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
logger.error("Could not abort the withdrawal", error);
|
// logger.error("Could not abort the withdrawal", error);
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`Could not abort the withdrawal.`,
|
// title: i18n.str`Could not abort the withdrawal.`,
|
||||||
description: (error as any).error.description,
|
// description: (error as any).error.description,
|
||||||
debug: JSON.stringify(error),
|
// debug: JSON.stringify(error),
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
if (!res.ok) {
|
// if (!res.ok) {
|
||||||
const response = await res.json();
|
// const response = await res.json();
|
||||||
logger.error(
|
// logger.error(
|
||||||
`Withdrawal abort gave response error (${res.status})`,
|
// `Withdrawal abort gave response error (${res.status})`,
|
||||||
res.statusText,
|
// res.statusText,
|
||||||
);
|
// );
|
||||||
pageStateSetter((prevState) => ({
|
// pageStateSetter((prevState) => ({
|
||||||
...prevState,
|
// ...prevState,
|
||||||
|
|
||||||
error: {
|
// error: {
|
||||||
title: i18n.str`Withdrawal abortion failed.`,
|
// title: i18n.str`Withdrawal abortion failed.`,
|
||||||
description: response.error.description,
|
// description: response.error.description,
|
||||||
debug: JSON.stringify(response),
|
// debug: JSON.stringify(response),
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
logger.trace("Withdrawal operation aborted!");
|
// logger.trace("Withdrawal operation aborted!");
|
||||||
pageStateSetter((prevState) => {
|
// pageStateSetter((prevState) => {
|
||||||
const { ...rest } = prevState;
|
// const { ...rest } = prevState;
|
||||||
return {
|
// return {
|
||||||
...rest,
|
// ...rest,
|
||||||
|
|
||||||
info: i18n.str`Withdrawal aborted!`,
|
// info: i18n.str`Withdrawal aborted!`,
|
||||||
};
|
// };
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
@ -15,106 +15,67 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Logger } from "@gnu-taler/taler-util";
|
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 { Fragment, h, VNode } from "preact";
|
||||||
import useSWR from "swr";
|
import { Loading } from "../components/Loading.js";
|
||||||
import { PageStateType, usePageContext } from "../context/pageState.js";
|
import { usePageContext } from "../context/pageState.js";
|
||||||
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
|
import { useWithdrawalDetails } from "../hooks/access.js";
|
||||||
import { QrCodeSection } from "./QrCodeSection.js";
|
import { QrCodeSection } from "./QrCodeSection.js";
|
||||||
import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
|
import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
|
||||||
|
|
||||||
const logger = new Logger("WithdrawalQRCode");
|
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
|
* Offer the QR code (and a clickable taler://-link) to
|
||||||
* permit the passing of exchange and reserve details to
|
* permit the passing of exchange and reserve details to
|
||||||
* the bank. Poll the backend until such operation is done.
|
* the bank. Poll the backend until such operation is done.
|
||||||
*/
|
*/
|
||||||
export function WithdrawalQRCode({
|
export function WithdrawalQRCode({
|
||||||
|
account,
|
||||||
withdrawalId,
|
withdrawalId,
|
||||||
talerWithdrawUri,
|
talerWithdrawUri,
|
||||||
}: {
|
onAbort,
|
||||||
withdrawalId: string;
|
onLoadNotOk,
|
||||||
talerWithdrawUri: string;
|
}: Props): VNode {
|
||||||
}): 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.trace(`Showing withdraw URI: ${talerWithdrawUri}`);
|
logger.trace(`Showing withdraw URI: ${talerWithdrawUri}`);
|
||||||
// waiting for the wallet:
|
|
||||||
|
|
||||||
const { data, error } = useSWR(
|
const result = useWithdrawalDetails(account, withdrawalId);
|
||||||
`integration-api/withdrawal-operation/${withdrawalId}`,
|
if (!result.ok) {
|
||||||
{ refreshInterval: 1000 },
|
return onLoadNotOk(result);
|
||||||
);
|
|
||||||
|
|
||||||
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 { 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);
|
logger.trace("withdrawal status", data);
|
||||||
if (data.aborted)
|
if (data.aborted) {
|
||||||
pageStateSetter((prevState: PageStateType) => {
|
//signal that this withdrawal is aborted
|
||||||
const { withdrawalId, talerWithdrawUri, ...rest } = prevState;
|
//will redirect to account info
|
||||||
return {
|
onAbort();
|
||||||
...rest,
|
return <Loading />;
|
||||||
withdrawalInProgress: false,
|
}
|
||||||
|
|
||||||
error: {
|
|
||||||
title: i18n.str`This withdrawal was aborted!`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data.selection_done) {
|
if (!data.selection_done) {
|
||||||
return (
|
return (
|
||||||
<QrCodeSection
|
<QrCodeSection talerWithdrawUri={talerWithdrawUri} onAbort={onAbort} />
|
||||||
talerWithdrawUri={talerWithdrawUri}
|
|
||||||
abortButton={abortButton}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Wallet POSTed the withdrawal details! Ask the
|
* Wallet POSTed the withdrawal details! Ask the
|
||||||
* user to authorize the operation (here CAPTCHA).
|
* user to authorize the operation (here CAPTCHA).
|
||||||
*/
|
*/
|
||||||
return <WithdrawalConfirmationQuestion />;
|
return (
|
||||||
|
<WithdrawalConfirmationQuestion
|
||||||
|
account={account}
|
||||||
|
withdrawalId={talerWithdrawUri}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -268,3 +268,10 @@ html {
|
|||||||
h1.nav {
|
h1.nav {
|
||||||
text-align: center;
|
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;
|
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 {
|
export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
|
||||||
return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
|
return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
|
||||||
? obj
|
? obj
|
||||||
: undefined;
|
: 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.
|
* Craft headers with Authorization and Content-Type.
|
||||||
*/
|
*/
|
||||||
export function prepareHeaders(username?: string, password?: string): Headers {
|
// export function prepareHeaders(username?: string, password?: string): Headers {
|
||||||
const headers = new Headers();
|
// const headers = new Headers();
|
||||||
if (username && password) {
|
// if (username && password) {
|
||||||
headers.append(
|
// headers.append(
|
||||||
"Authorization",
|
// "Authorization",
|
||||||
`Basic ${window.btoa(`${username}:${password}`)}`,
|
// `Basic ${window.btoa(`${username}:${password}`)}`,
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
headers.append("Content-Type", "application/json");
|
// headers.append("Content-Type", "application/json");
|
||||||
return headers;
|
// return headers;
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 20;
|
||||||
|
export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1;
|
||||||
|
Loading…
Reference in New Issue
Block a user