diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx')
-rw-r--r-- | packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx | 690 |
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(); + } +} |