diff options
author | Florian Dold <florian.dold@gmail.com> | 2017-05-28 23:15:41 +0200 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2017-05-28 23:15:41 +0200 |
commit | b6e774585d32017e5f1ceeeb2b2e2a5e350354d3 (patch) | |
tree | 080cb5afe3b48c0428abd2d7de1ff7fe34d9b9b1 /src/webex/pages/confirm-create-reserve.tsx | |
parent | 38a74188d759444d7e1abac856f78ae710e2a4c5 (diff) | |
download | wallet-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.tsx | 641 |
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(); +}); |