aboutsummaryrefslogtreecommitdiff
path: root/src/webex/pages/confirm-create-reserve.tsx
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2017-05-28 23:15:41 +0200
committerFlorian Dold <florian.dold@gmail.com>2017-05-28 23:15:41 +0200
commitb6e774585d32017e5f1ceeeb2b2e2a5e350354d3 (patch)
tree080cb5afe3b48c0428abd2d7de1ff7fe34d9b9b1 /src/webex/pages/confirm-create-reserve.tsx
parent38a74188d759444d7e1abac856f78ae710e2a4c5 (diff)
downloadwallet-core-b6e774585d32017e5f1ceeeb2b2e2a5e350354d3.tar.xz
move webex specific things in their own directory
Diffstat (limited to 'src/webex/pages/confirm-create-reserve.tsx')
-rw-r--r--src/webex/pages/confirm-create-reserve.tsx641
1 files changed, 641 insertions, 0 deletions
diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx
new file mode 100644
index 000000000..6ece92e21
--- /dev/null
+++ b/src/webex/pages/confirm-create-reserve.tsx
@@ -0,0 +1,641 @@
+/*
+ This file is part of TALER
+ (C) 2015-2016 GNUnet e.V.
+
+ 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.
+
+ 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
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+
+/**
+ * Page shown to the user to confirm creation
+ * of a reserve, usually requested by the bank.
+ *
+ * @author Florian Dold
+ */
+
+import {amountToPretty, canonicalizeBaseUrl} from "../../helpers";
+import {
+ AmountJson, CreateReserveResponse,
+ ReserveCreationInfo, Amounts,
+ Denomination, DenominationRecord, CurrencyRecord
+} from "../../types";
+import * as i18n from "../../i18n";
+
+import {getReserveCreationInfo, getCurrency, getExchangeInfo} from "../wxApi";
+import {ImplicitStateComponent, StateHolder} from "../components";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import URI = require("urijs");
+import * as moment from "moment";
+
+
+function delay<T>(delayMs: number, value: T): Promise<T> {
+ return new Promise<T>((resolve, reject) => {
+ setTimeout(() => resolve(value), delayMs);
+ });
+}
+
+class EventTrigger {
+ triggerResolve: any;
+ triggerPromise: Promise<boolean>;
+
+ constructor() {
+ this.reset();
+ }
+
+ private reset() {
+ this.triggerPromise = new Promise<boolean>((resolve, reject) => {
+ this.triggerResolve = resolve;
+ });
+ }
+
+ trigger() {
+ this.triggerResolve(false);
+ this.reset();
+ }
+
+ async wait(delayMs: number): Promise<boolean> {
+ return await Promise.race([this.triggerPromise, delay(delayMs, true)]);
+ }
+}
+
+
+interface CollapsibleState {
+ collapsed: boolean;
+}
+
+interface CollapsibleProps {
+ initiallyCollapsed: boolean;
+ title: string;
+}
+
+class Collapsible extends React.Component<CollapsibleProps, CollapsibleState> {
+ constructor(props: CollapsibleProps) {
+ super(props);
+ this.state = { collapsed: props.initiallyCollapsed };
+ }
+ render() {
+ const doOpen = (e: any) => {
+ this.setState({collapsed: false})
+ e.preventDefault()
+ };
+ const doClose = (e: any) => {
+ this.setState({collapsed: true})
+ e.preventDefault();
+ };
+ if (this.state.collapsed) {
+ return <h2><a className="opener opener-collapsed" href="#" onClick={doOpen}>{this.props.title}</a></h2>;
+ }
+ return (
+ <div>
+ <h2><a className="opener opener-open" href="#" onClick={doClose}>{this.props.title}</a></h2>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+function renderAuditorDetails(rci: ReserveCreationInfo|null) {
+ if (!rci) {
+ return (
+ <p>
+ Details will be displayed when a valid exchange provider URL is entered.
+ </p>
+ );
+ }
+ if (rci.exchangeInfo.auditors.length == 0) {
+ return (
+ <p>
+ The exchange is not audited by any auditors.
+ </p>
+ );
+ }
+ return (
+ <div>
+ {rci.exchangeInfo.auditors.map(a => (
+ <h3>Auditor {a.url}</h3>
+ ))}
+ </div>
+ );
+}
+
+function renderReserveCreationDetails(rci: ReserveCreationInfo|null) {
+ if (!rci) {
+ return (
+ <p>
+ Details will be displayed when a valid exchange provider URL is entered.
+ </p>
+ );
+ }
+
+ let denoms = rci.selectedDenoms;
+
+ let countByPub: {[s: string]: number} = {};
+ let uniq: DenominationRecord[] = [];
+
+ denoms.forEach((x: DenominationRecord) => {
+ let c = countByPub[x.denomPub] || 0;
+ if (c == 0) {
+ uniq.push(x);
+ }
+ c += 1;
+ countByPub[x.denomPub] = c;
+ });
+
+ function row(denom: DenominationRecord) {
+ return (
+ <tr>
+ <td>{countByPub[denom.denomPub] + "x"}</td>
+ <td>{amountToPretty(denom.value)}</td>
+ <td>{amountToPretty(denom.feeWithdraw)}</td>
+ <td>{amountToPretty(denom.feeRefresh)}</td>
+ <td>{amountToPretty(denom.feeDeposit)}</td>
+ </tr>
+ );
+ }
+
+ function wireFee(s: string) {
+ return [
+ <thead>
+ <tr>
+ <th colSpan={3}>Wire Method {s}</th>
+ </tr>
+ <tr>
+ <th>Applies Until</th>
+ <th>Wire Fee</th>
+ <th>Closing Fee</th>
+ </tr>
+ </thead>,
+ <tbody>
+ {rci!.wireFees.feesForType[s].map(f => (
+ <tr>
+ <td>{moment.unix(f.endStamp).format("llll")}</td>
+ <td>{amountToPretty(f.wireFee)}</td>
+ <td>{amountToPretty(f.closingFee)}</td>
+ </tr>
+ ))}
+ </tbody>
+ ];
+ }
+
+ let withdrawFeeStr = amountToPretty(rci.withdrawFee);
+ let overheadStr = amountToPretty(rci.overhead);
+
+ return (
+ <div>
+ <h3>Overview</h3>
+ <p>{i18n.str`Withdrawal fees: ${withdrawFeeStr}`}</p>
+ <p>{i18n.str`Rounding loss: ${overheadStr}`}</p>
+ <p>{i18n.str`Earliest expiration (for deposit): ${moment.unix(rci.earliestDepositExpiration).fromNow()}`}</p>
+ <h3>Coin Fees</h3>
+ <table className="pure-table">
+ <thead>
+ <tr>
+ <th>{i18n.str`# Coins`}</th>
+ <th>{i18n.str`Value`}</th>
+ <th>{i18n.str`Withdraw Fee`}</th>
+ <th>{i18n.str`Refresh Fee`}</th>
+ <th>{i18n.str`Deposit Fee`}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {uniq.map(row)}
+ </tbody>
+ </table>
+ <h3>Wire Fees</h3>
+ <table className="pure-table">
+ {Object.keys(rci.wireFees.feesForType).map(wireFee)}
+ </table>
+ </div>
+ );
+}
+
+
+function getSuggestedExchange(currency: string): Promise<string> {
+ // TODO: make this request go to the wallet backend
+ // Right now, this is a stub.
+ const defaultExchange: {[s: string]: string} = {
+ "KUDOS": "https://exchange.demo.taler.net",
+ "PUDOS": "https://exchange.test.taler.net",
+ };
+
+ let exchange = defaultExchange[currency];
+
+ if (!exchange) {
+ exchange = ""
+ }
+
+ return Promise.resolve(exchange);
+}
+
+
+function WithdrawFee(props: {reserveCreationInfo: ReserveCreationInfo|null}): JSX.Element {
+ if (props.reserveCreationInfo) {
+ let {overhead, withdrawFee} = props.reserveCreationInfo;
+ let totalCost = Amounts.add(overhead, withdrawFee).amount;
+ return <p>{i18n.str`Withdraw fees:`} {amountToPretty(totalCost)}</p>;
+ }
+ return <p />;
+}
+
+
+interface ExchangeSelectionProps {
+ suggestedExchangeUrl: string;
+ amount: AmountJson;
+ callback_url: string;
+ wt_types: string[];
+ currencyRecord: CurrencyRecord|null;
+}
+
+interface ManualSelectionProps {
+ onSelect(url: string): void;
+ initialUrl: string;
+}
+
+class ManualSelection extends ImplicitStateComponent<ManualSelectionProps> {
+ url: StateHolder<string> = this.makeState("");
+ errorMessage: StateHolder<string|null> = this.makeState(null);
+ isOkay: StateHolder<boolean> = this.makeState(false);
+ updateEvent = new EventTrigger();
+ constructor(p: ManualSelectionProps) {
+ super(p);
+ this.url(p.initialUrl);
+ this.update();
+ }
+ render() {
+ return (
+ <div className="pure-g pure-form pure-form-stacked">
+ <div className="pure-u-1">
+ <label>URL</label>
+ <input className="url" type="text" spellCheck={false}
+ value={this.url()}
+ key="exchange-url-input"
+ onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)} />
+ </div>
+ <div className="pure-u-1">
+ <button className="pure-button button-success"
+ disabled={!this.isOkay()}
+ onClick={() => this.props.onSelect(this.url())}>
+ {i18n.str`Select`}
+ </button>
+ {this.errorMessage()}
+ </div>
+ </div>
+ );
+ }
+
+ async update() {
+ this.errorMessage(null);
+ this.isOkay(false);
+ if (!this.url()) {
+ return;
+ }
+ let parsedUrl = new URI(this.url()!);
+ if (parsedUrl.is("relative")) {
+ this.errorMessage(i18n.str`Error: URL may not be relative`);
+ this.isOkay(false);
+ return;
+ }
+ try {
+ let url = canonicalizeBaseUrl(this.url()!);
+ let r = await getExchangeInfo(url)
+ console.log("getExchangeInfo returned")
+ this.isOkay(true);
+ } catch (e) {
+ console.log("got error", e);
+ if (e.hasOwnProperty("httpStatus")) {
+ this.errorMessage(`Error: request failed with status ${e.httpStatus}`);
+ } else if (e.hasOwnProperty("errorResponse")) {
+ let resp = e.errorResponse;
+ this.errorMessage(`Error: ${resp.error} (${resp.hint})`);
+ } else {
+ this.errorMessage("invalid exchange URL");
+ }
+ }
+ }
+
+ async onUrlChanged(s: string) {
+ this.url(s);
+ this.errorMessage(null);
+ this.isOkay(false);
+ this.updateEvent.trigger();
+ let waited = await this.updateEvent.wait(200);
+ if (waited) {
+ // Run the actual update if nobody else preempted us.
+ this.update();
+ }
+ }
+}
+
+
+class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
+ statusString: StateHolder<string|null> = this.makeState(null);
+ reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState(
+ null);
+ url: StateHolder<string|null> = this.makeState(null);
+
+ selectingExchange: StateHolder<boolean> = this.makeState(false);
+
+ constructor(props: ExchangeSelectionProps) {
+ super(props);
+ let prefilledExchangesUrls = [];
+ if (props.currencyRecord) {
+ let exchanges = props.currencyRecord.exchanges.map((x) => x.baseUrl);
+ prefilledExchangesUrls.push(...exchanges);
+ }
+ if (props.suggestedExchangeUrl) {
+ prefilledExchangesUrls.push(props.suggestedExchangeUrl);
+ }
+ if (prefilledExchangesUrls.length != 0) {
+ this.url(prefilledExchangesUrls[0]);
+ this.forceReserveUpdate();
+ } else {
+ this.selectingExchange(true);
+ }
+ }
+
+ renderFeeStatus() {
+ let rci = this.reserveCreationInfo();
+ if (rci) {
+ let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount;
+ let trustMessage;
+ if (rci.isTrusted) {
+ trustMessage = (
+ <i18n.Translate wrap="p">
+ The exchange is trusted by the wallet.
+ </i18n.Translate>
+ );
+ } else if (rci.isAudited) {
+ trustMessage = (
+ <i18n.Translate wrap="p">
+ The exchange is audited by a trusted auditor.
+ </i18n.Translate>
+ );
+ } else {
+ trustMessage = (
+ <i18n.Translate wrap="p">
+ Warning: The exchange is neither directly trusted nor audited by a trusted auditor.
+ If you withdraw from this exchange, it will be trusted in the future.
+ </i18n.Translate>
+ );
+ }
+ return (
+ <div>
+ <i18n.Translate wrap="p">
+ Using exchange provider <strong>{this.url()}</strong>.
+ The exchange provider will charge
+ {" "}
+ <span>{amountToPretty(totalCost)}</span>
+ {" "}
+ in fees.
+ </i18n.Translate>
+ {trustMessage}
+ </div>
+ );
+ }
+ if (this.url() && !this.statusString()) {
+ let shortName = new URI(this.url()!).host();
+ return (
+ <i18n.Translate wrap="p">
+ Waiting for a response from
+ {" "}
+ <em>{shortName}</em>
+ </i18n.Translate>
+ );
+ }
+ if (this.statusString()) {
+ return (
+ <p>
+ <strong style={{color: "red"}}>{i18n.str`A problem occured, see below. ${this.statusString()}`}</strong>
+ </p>
+ );
+ }
+ return (
+ <p>
+ {i18n.str`Information about fees will be available when an exchange provider is selected.`}
+ </p>
+ );
+ }
+
+ renderConfirm() {
+ return (
+ <div>
+ {this.renderFeeStatus()}
+ <button className="pure-button button-success"
+ disabled={this.reserveCreationInfo() == null}
+ onClick={() => this.confirmReserve()}>
+ {i18n.str`Accept fees and withdraw`}
+ </button>
+ { " " }
+ <button className="pure-button button-secondary"
+ onClick={() => this.selectingExchange(true)}>
+ {i18n.str`Change Exchange Provider`}
+ </button>
+ <br/>
+ <Collapsible initiallyCollapsed={true} title="Fee and Spending Details">
+ {renderReserveCreationDetails(this.reserveCreationInfo())}
+ </Collapsible>
+ <Collapsible initiallyCollapsed={true} title="Auditor Details">
+ {renderAuditorDetails(this.reserveCreationInfo())}
+ </Collapsible>
+ </div>
+ );
+ }
+
+ select(url: string) {
+ this.reserveCreationInfo(null);
+ this.url(url);
+ this.selectingExchange(false);
+ this.forceReserveUpdate();
+ }
+
+ renderSelect() {
+ let exchanges = (this.props.currencyRecord && this.props.currencyRecord.exchanges) || [];
+ console.log(exchanges);
+ return (
+ <div>
+ Please select an exchange. You can review the details before after your selection.
+
+ {this.props.suggestedExchangeUrl && (
+ <div>
+ <h2>Bank Suggestion</h2>
+ <button className="pure-button button-success" onClick={() => this.select(this.props.suggestedExchangeUrl)}>
+ Select <strong>{this.props.suggestedExchangeUrl}</strong>
+ </button>
+ </div>
+ )}
+
+ {exchanges.length > 0 && (
+ <div>
+ <h2>Known Exchanges</h2>
+ {exchanges.map(e => (
+ <button className="pure-button button-success" onClick={() => this.select(e.baseUrl)}>
+ Select <strong>{e.baseUrl}</strong>
+ </button>
+ ))}
+ </div>
+ )}
+
+ <h2>Manual Selection</h2>
+ <ManualSelection initialUrl={this.url() || ""} onSelect={(url: string) => this.select(url)} />
+ </div>
+ );
+ }
+
+ render(): JSX.Element {
+ return (
+ <div>
+ <i18n.Translate wrap="p">
+ {"You are about to withdraw "}
+ <strong>{amountToPretty(this.props.amount)}</strong>
+ {" from your bank account into your wallet."}
+ </i18n.Translate>
+ {this.selectingExchange() ? this.renderSelect() : this.renderConfirm()}
+ </div>
+ );
+ }
+
+
+ confirmReserve() {
+ this.confirmReserveImpl(this.reserveCreationInfo()!,
+ this.url()!,
+ this.props.amount,
+ this.props.callback_url);
+ }
+
+ /**
+ * Do an update of the reserve creation info, without any debouncing.
+ */
+ async forceReserveUpdate() {
+ this.reserveCreationInfo(null);
+ try {
+ let url = canonicalizeBaseUrl(this.url()!);
+ let r = await getReserveCreationInfo(url,
+ this.props.amount);
+ console.log("get exchange info resolved");
+ this.reserveCreationInfo(r);
+ console.dir(r);
+ } catch (e) {
+ console.log("get exchange info rejected", e);
+ if (e.hasOwnProperty("httpStatus")) {
+ this.statusString(`Error: request failed with status ${e.httpStatus}`);
+ } else if (e.hasOwnProperty("errorResponse")) {
+ let resp = e.errorResponse;
+ this.statusString(`Error: ${resp.error} (${resp.hint})`);
+ }
+ }
+ }
+
+ confirmReserveImpl(rci: ReserveCreationInfo,
+ exchange: string,
+ amount: AmountJson,
+ callback_url: string) {
+ const d = {exchange: canonicalizeBaseUrl(exchange), amount};
+ const cb = (rawResp: any) => {
+ if (!rawResp) {
+ throw Error("empty response");
+ }
+ // FIXME: filter out types that bank/exchange don't have in common
+ let wireDetails = rci.wireInfo;
+ let filteredWireDetails: any = {};
+ for (let wireType in wireDetails) {
+ if (this.props.wt_types.findIndex((x) => x.toLowerCase() == wireType.toLowerCase()) < 0) {
+ continue;
+ }
+ let obj = Object.assign({}, wireDetails[wireType]);
+ // The bank doesn't need to know about fees
+ delete obj.fees;
+ // Consequently the bank can't verify signatures anyway, so
+ // we delete this extra data, to make the request URL shorter.
+ delete obj.salt;
+ delete obj.sig;
+ filteredWireDetails[wireType] = obj;
+ }
+ if (!rawResp.error) {
+ const resp = CreateReserveResponse.checked(rawResp);
+ let q: {[name: string]: string|number} = {
+ wire_details: JSON.stringify(filteredWireDetails),
+ exchange: resp.exchange,
+ reserve_pub: resp.reservePub,
+ amount_value: amount.value,
+ amount_fraction: amount.fraction,
+ amount_currency: amount.currency,
+ };
+ let url = new URI(callback_url).addQuery(q);
+ if (!url.is("absolute")) {
+ throw Error("callback url is not absolute");
+ }
+ console.log("going to", url.href());
+ document.location.href = url.href();
+ } else {
+ this.statusString(
+ i18n.str`Oops, something went wrong. The wallet responded with error status (${rawResp.error}).`);
+ }
+ };
+ chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb);
+ }
+
+ renderStatus(): any {
+ if (this.statusString()) {
+ return <p><strong style={{color: "red"}}>{this.statusString()}</strong></p>;
+ } else if (!this.reserveCreationInfo()) {
+ return <p>{i18n.str`Checking URL, please wait ...`}</p>;
+ }
+ return "";
+ }
+}
+
+export async function main() {
+ try {
+ const url = new URI(document.location.href);
+ const query: any = URI.parseQuery(url.query());
+ let amount;
+ try {
+ amount = AmountJson.checked(JSON.parse(query.amount));
+ } catch (e) {
+ throw Error(i18n.str`Can't parse amount: ${e.message}`);
+ }
+ const callback_url = query.callback_url;
+ const bank_url = query.bank_url;
+ let wt_types;
+ try {
+ wt_types = JSON.parse(query.wt_types);
+ } catch (e) {
+ throw Error(i18n.str`Can't parse wire_types: ${e.message}`);
+ }
+
+ let suggestedExchangeUrl = query.suggested_exchange_url;
+ let currencyRecord = await getCurrency(amount.currency);
+
+ let args = {
+ wt_types,
+ suggestedExchangeUrl,
+ callback_url,
+ amount,
+ currencyRecord,
+ };
+
+ ReactDOM.render(<ExchangeSelection {...args} />, document.getElementById(
+ "exchange-selection")!);
+
+ } catch (e) {
+ // TODO: provide more context information, maybe factor it out into a
+ // TODO:generic error reporting function or component.
+ document.body.innerText = i18n.str`Fatal error: "${e.message}".`;
+ console.error(`got error "${e.message}"`, e);
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ main();
+});