summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx')
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx690
1 files changed, 690 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx
new file mode 100644
index 000000000..53380e263
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx
@@ -0,0 +1,690 @@
+/*
+ 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,
+ CoinDumpJson,
+ CoinStatus,
+ ExchangeListItem,
+ ExchangeTosStatus,
+ LogLevel,
+ NotificationType,
+ ScopeType,
+ parseWithdrawUri,
+ stringifyWithdrawExchange,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { Checkbox } from "../components/Checkbox.js";
+import { SelectList } from "../components/SelectList.js";
+import { Time } from "../components/Time.js";
+import { DestructiveText, LinkPrimary, NotifyUpdateFadeOut, SubTitle, SuccessText, WarningText } from "../components/styled/index.js";
+import { useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { useSettings } from "../hooks/useSettings.js";
+import { Button } from "../mui/Button.js";
+import { Grid } from "../mui/Grid.js";
+import { Paper } from "../mui/Paper.js";
+import { TextField } from "../mui/TextField.js";
+import { Pages } from "../NavigationBar.js";
+import { CoinInfo } from "@gnu-taler/taler-wallet-core/dbless";
+import { ActiveTasksTable } from "../components/WalletActivity.js";
+
+type CoinsInfo = CoinDumpJson["coins"];
+type CalculatedCoinfInfo = {
+ // ageKeysCount: number | undefined;
+ denom_value: number;
+ denom_fraction: number;
+ //remain_value: number;
+ status: string;
+ from_refresh: boolean;
+ id: string;
+};
+
+type SplitedCoinInfo = {
+ spent: CalculatedCoinfInfo[];
+ usable: CalculatedCoinfInfo[];
+};
+
+export interface Props {
+ // FIXME: Pending operations don't exist anymore.
+}
+
+function hashObjectId(o: any): string {
+ return JSON.stringify(o);
+}
+
+export function DeveloperPage({ }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [downloadedDatabase, setDownloadedDatabase] = useState<
+ { time: Date; content: string } | undefined
+ >(undefined);
+ async function onExportDatabase(): Promise<void> {
+ const db = await api.wallet.call(WalletApiOperation.ExportDb, {});
+ const content = JSON.stringify(db);
+ setDownloadedDatabase({
+ time: new Date(),
+ content,
+ });
+ }
+ const api = useBackendContext();
+
+ const fileRef = useRef<HTMLInputElement>(null);
+ async function onImportDatabase(str: string): Promise<void> {
+ await api.wallet.call(WalletApiOperation.ImportDb, {
+ dump: JSON.parse(str),
+ });
+ }
+ const [settings, updateSettings] = useSettings();
+ const { safely } = useAlertContext();
+
+ const listenAllEvents = Array.from<NotificationType>({ length: 1 });
+ // listenAllEvents.includes = () => true
+
+ const hook = useAsyncAsHook(async () => {
+ const list = await api.wallet.call(WalletApiOperation.ListExchanges, {});
+ const version = await api.wallet.call(WalletApiOperation.GetVersion, {});
+ const coins = await api.wallet.call(WalletApiOperation.DumpCoins, {});
+ return { exchanges: list.exchanges, version, coins };
+ });
+ const exchangeList = hook && !hook.hasError ? hook.response.exchanges : [];
+ const coins = hook && !hook.hasError ? hook.response.coins.coins : [];
+
+ useEffect(() => {
+ return api.listener.onUpdateNotification(listenAllEvents, (ev) => {
+ console.log("event", ev)
+ return hook?.retry()
+ });
+ });
+
+ const currencies: { [ex: string]: string } = {};
+ const money_by_exchange = coins.reduce(
+ (prev, cur) => {
+ const denom = Amounts.parseOrThrow(cur.denom_value);
+ if (!prev[cur.exchange_base_url]) {
+ prev[cur.exchange_base_url] = [];
+ currencies[cur.exchange_base_url] = denom.currency;
+ }
+ prev[cur.exchange_base_url].push({
+ // ageKeysCount: cur.ageCommitmentProof?.proof.privateKeys.length,
+ denom_value: denom.value,
+ denom_fraction: denom.fraction,
+ // remain_value: parseFloat(
+ // Amounts.stringifyValue(Amounts.parseOrThrow(cur.remaining_value)),
+ // ),
+ status: cur.coin_status,
+ from_refresh: cur.refresh_parent_coin_pub !== undefined,
+ id: cur.coin_pub,
+ });
+ return prev;
+ },
+ {} as {
+ [exchange_name: string]: CalculatedCoinfInfo[];
+ },
+ );
+
+ const [tagName, setTagName] = useState("");
+ const [logLevel, setLogLevel] = useState("info");
+ return (
+ <div>
+ <p>
+ <i18n.Translate>Debug tools</i18n.Translate>:
+ </p>
+ <Grid container justifyContent="space-between" spacing={1} size={4}>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={() =>
+ confirmReset(
+ i18n.str`Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?`,
+ () => api.background.call("resetDb", undefined),
+ )
+ }
+ >
+ <i18n.Translate>reset</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={() =>
+ confirmReset(
+ i18n.str`TESTING: This may delete all your coin, proceed with caution`,
+ () => api.background.call("runGarbageCollector", undefined),
+ )
+ }
+ >
+ <i18n.Translate>run gc</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={async () => fileRef?.current?.click()}
+ >
+ <i18n.Translate>import database</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <input
+ ref={fileRef}
+ style={{ display: "none" }}
+ type="file"
+ onChange={async (e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return Promise.reject();
+ }
+ const buf = await f[0].arrayBuffer();
+ const str = new Uint8Array(buf).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ );
+ return onImportDatabase(str);
+ }}
+ />
+ <Button variant="contained" onClick={onExportDatabase}>
+ <i18n.Translate>export database</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={async () => {
+ const result = await Promise.all(
+ exchangeList.map(async (exchange) => {
+ const url = exchange.exchangeBaseUrl;
+ const oldKeys = JSON.stringify(
+ await (await fetch(`${url}keys`)).json(),
+ );
+ const newKeys = JSON.stringify(
+ await (
+ await fetch(`${url}keys`, { cache: "no-cache" })
+ ).json(),
+ );
+ return oldKeys !== newKeys;
+ }),
+ );
+ const ex = exchangeList.filter((e, i) => result[i]);
+ if (!ex.length) {
+ alert("no exchange was outdated");
+ } else {
+ alert(`found some exchange out of date: ${result.join(", ")}`);
+ }
+ }}
+ >
+ <i18n.Translate>Clear exchange key cache</i18n.Translate>
+ </Button>
+ </Grid>{" "}
+ </Grid>
+ {downloadedDatabase && (
+ <div>
+ <i18n.Translate>
+ Database exported at{" "}
+ <Time
+ timestamp={AbsoluteTime.fromMilliseconds(
+ downloadedDatabase.time.getTime(),
+ )}
+ format="yyyy/MM/dd HH:mm:ss"
+ />{" "}
+ <a
+ href={`data:text/plain;charset=utf-8;base64,${toBase64(
+ downloadedDatabase.content,
+ )}`}
+ download={`taler-wallet-database-${format(
+ downloadedDatabase.time,
+ "yyyy/MM/dd_HH:mm",
+ )}.json`}
+ >
+ <i18n.Translate>click here</i18n.Translate>
+ </a>{" "}
+ to download
+ </i18n.Translate>
+ </div>
+ )}
+ <Checkbox
+ label={i18n.str`Inject Taler support in all pages`}
+ name="inject"
+ description={
+ <i18n.Translate>
+ Enabling this option will make `window.taler` be available in all
+ sites
+ </i18n.Translate>
+ }
+ enabled={settings.injectTalerSupport!}
+ onToggle={safely("update support injection", async () => {
+ updateSettings("injectTalerSupport", !settings.injectTalerSupport);
+ })}
+ />
+
+
+ <SubTitle>
+ <i18n.Translate>Exchange Entries</i18n.Translate>
+ </SubTitle>
+ {!exchangeList || !exchangeList.length ? (
+ <div>
+ <i18n.Translate>No exchange yet</i18n.Translate>
+ </div>
+ ) : (
+ <Fragment>
+ <table>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Currency</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>URL</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Status</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Terms of Service</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Last Update</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Actions</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {exchangeList.map((e, idx) => {
+ function TosStatus(): VNode {
+ switch (e.tosStatus) {
+ case ExchangeTosStatus.Accepted:
+ return (
+ <SuccessText>
+ <i18n.Translate>ok</i18n.Translate>
+ </SuccessText>
+ );
+ case ExchangeTosStatus.Pending:
+ return (
+ <WarningText>
+ <i18n.Translate>pending</i18n.Translate>
+ </WarningText>
+ );
+ case ExchangeTosStatus.Proposed:
+ return <i18n.Translate>proposed</i18n.Translate>;
+ default:
+ return (
+ <DestructiveText>
+ <i18n.Translate>
+ unknown (exchange status should be updated)
+ </i18n.Translate>
+ </DestructiveText>
+ );
+ }
+ }
+ const uri = !e.masterPub ? undefined : stringifyWithdrawExchange({
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ exchangePub: e.masterPub,
+ });
+ return (
+ <tr key={idx}>
+ <td>
+ <a href={!uri ? undefined : Pages.defaultCta({ uri })}>
+ {e.scopeInfo ? `${e.scopeInfo.currency} (${e.scopeInfo.type === ScopeType.Global ? "global" : "regional"})` : e.currency}
+ </a>
+ </td>
+ <td>
+ <a href={new URL(`/keys`, e.exchangeBaseUrl).href} target="_blank">{e.exchangeBaseUrl}</a>
+ </td>
+ <td>
+ {e.exchangeEntryStatus} / {e.exchangeUpdateStatus}
+ </td>
+ <td>
+ <TosStatus />
+ </td>
+ <td>
+ {e.lastUpdateTimestamp
+ ? AbsoluteTime.toIsoString(
+ AbsoluteTime.fromPreciseTimestamp(
+ e.lastUpdateTimestamp,
+ ),
+ )
+ : "never"}
+ </td>
+ <td>
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.UpdateExchangeEntry,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ force: true,
+ },
+ );
+ }}
+ >
+ Reload
+ </button>
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.DeleteExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ },
+ );
+ }}
+ >
+ Delete
+ </button>
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.DeleteExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ purge: true,
+ },
+ );
+ }}
+ >
+ Purge
+ </button>
+ {e.scopeInfo && e.masterPub && e.currency ?
+ (e.scopeInfo.type === ScopeType.Global ?
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.RemoveGlobalCurrencyExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ currency: e.currency!,
+ exchangeMasterPub: e.masterPub!,
+ },
+ );
+ }}
+ >
+
+ Make regional
+ </button>
+ : e.scopeInfo.type === ScopeType.Auditor ?
+ undefined
+
+ : e.scopeInfo.type === ScopeType.Exchange ?
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.AddGlobalCurrencyExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ currency: e.currency!,
+ exchangeMasterPub: e.masterPub!,
+ },
+ );
+ }}
+ >
+
+ Make global
+ </button>
+ : undefined) : undefined
+ }
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.SetExchangeTosForgotten,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ },
+ );
+ }}
+ >
+ Forget ToS
+ </button>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </Fragment>
+ )}
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div />
+ <LinkPrimary href={Pages.settingsExchangeAdd({})}>
+ <i18n.Translate>Add an exchange</i18n.Translate>
+ </LinkPrimary>
+ </div>
+
+
+ <Paper style={{ padding: 10, margin: 10 }}>
+ <h3>Logging</h3>
+ <div>
+ <TextField
+ label="Tag name"
+ placeholder="wallet.ts"
+ variant="filled"
+ // error={subject.error}
+ required
+ value={tagName}
+ onChange={setTagName}
+ />
+ <SelectList
+ label={i18n.str`Log levels`}
+ list={{
+ trace: "TRACE",
+ info: "INFO",
+ error: "ERROR",
+ }}
+ name="logLevel"
+ value={logLevel}
+ onChange={(v) => setLogLevel(v)}
+ />
+ </div>
+ <Button
+ variant="contained"
+ onClick={async () => {
+ api.background.call("setLoggingLevel", {
+ tag: tagName,
+ level: logLevel as LogLevel,
+ });
+ }}
+ >
+ Set log level
+ </Button>
+ </Paper>
+
+ <br />
+ <p>
+ <i18n.Translate>Coins</i18n.Translate>:
+ </p>
+ {Object.keys(money_by_exchange).map((ex, idx) => {
+ const allcoins = money_by_exchange[ex];
+ allcoins.sort((a, b) => {
+ if (b.denom_value !== a.denom_value) {
+ return b.denom_value - a.denom_value;
+ }
+ return b.denom_fraction - a.denom_fraction;
+ });
+
+ const coins = allcoins.reduce(
+ (prev, cur) => {
+ if (cur.status === CoinStatus.Fresh) prev.usable.push(cur);
+ if (cur.status === CoinStatus.Dormant) prev.spent.push(cur);
+ return prev;
+ },
+ {
+ spent: [],
+ usable: [],
+ } as SplitedCoinInfo,
+ );
+
+ return (
+ <ShowAllCoins
+ key={idx}
+ coins={coins}
+ ex={ex}
+ currencies={currencies}
+ />
+ );
+ })}
+ <br />
+ <NotifyUpdateFadeOut>
+ <ActiveTasksTable />
+ </NotifyUpdateFadeOut>
+ </div>
+ );
+}
+
+function ShowAllCoins({
+ ex,
+ coins,
+ currencies,
+}: {
+ ex: string;
+ coins: SplitedCoinInfo;
+ currencies: { [ex: string]: string };
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [collapsedSpent, setCollapsedSpent] = useState(true);
+ const [collapsedUnspent, setCollapsedUnspent] = useState(false);
+ const totalUsable = coins.usable.reduce(
+ (prev, cur) =>
+ Amounts.add(prev, {
+ currency: "NONE",
+ fraction: cur.denom_fraction,
+ value: cur.denom_value,
+ }).amount,
+ Amounts.zeroOfCurrency("NONE"),
+ );
+ const totalSpent = coins.spent.reduce(
+ (prev, cur) =>
+ Amounts.add(prev, {
+ currency: "NONE",
+ fraction: cur.denom_fraction,
+ value: cur.denom_value,
+ }).amount,
+ Amounts.zeroOfCurrency("NONE"),
+ );
+ return (
+ <Fragment>
+ <p>
+ <b>{ex}</b>: {Amounts.stringifyValue(totalUsable)} {currencies[ex]}
+ </p>
+ <p>
+ spent: {Amounts.stringifyValue(totalSpent)} {currencies[ex]}
+ </p>
+ <p onClick={() => setCollapsedUnspent(true)}>
+ <b>
+ <i18n.Translate>usable coins</i18n.Translate>
+ </b>
+ </p>
+ {collapsedUnspent ? (
+ <div onClick={() => setCollapsedUnspent(false)}>click to show</div>
+ ) : (
+ <table>
+ <tr>
+ <td>
+ <i18n.Translate>id</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>denom</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>status</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>from refresh?</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>age key count</i18n.Translate>
+ </td>
+ </tr>
+ {coins.usable.map((c, idx) => {
+ return (
+ <tr key={idx}>
+ <td>{c.id.substring(0, 5)}</td>
+ <td>
+ {Amounts.stringifyValue({
+ value: c.denom_value,
+ fraction: c.denom_fraction,
+ currency: "ANY",
+ })}
+ </td>
+ <td>{c.status}</td>
+ <td>{c.from_refresh ? "true" : "false"}</td>
+ {/* <td>{String(c.ageKeysCount)}</td> */}
+ </tr>
+ );
+ })}
+ </table>
+ )}
+ <p onClick={() => setCollapsedSpent(true)}>
+ <i18n.Translate>spent coins</i18n.Translate>
+ </p>
+ {collapsedSpent ? (
+ <div onClick={() => setCollapsedSpent(false)}>
+ <i18n.Translate>click to show</i18n.Translate>
+ </div>
+ ) : (
+ <table>
+ <tr>
+ <td>
+ <i18n.Translate>id</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>denom</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>status</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>from refresh?</i18n.Translate>
+ </td>
+ </tr>
+ {coins.spent.map((c, idx) => {
+ return (
+ <tr key={idx}>
+ <td>{c.id.substring(0, 5)}</td>
+ <td>{c.denom_value}</td>
+ <td>{c.status}</td>
+ <td>{c.from_refresh ? "true" : "false"}</td>
+ </tr>
+ );
+ })}
+ </table>
+ )}
+ </Fragment>
+ );
+}
+
+function toBase64(str: string): string {
+ return btoa(
+ encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
+ return String.fromCharCode(parseInt(p1, 16));
+ }),
+ );
+}
+
+export async function confirmReset(
+ confirmTheResetMessage: string,
+ cb: () => Promise<void>,
+): Promise<void> {
+ if (confirm(confirmTheResetMessage)) {
+ await cb();
+ window.close();
+ }
+}