From b6e774585d32017e5f1ceeeb2b2e2a5e350354d3 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 28 May 2017 23:15:41 +0200 Subject: move webex specific things in their own directory --- src/webex/pages/confirm-create-reserve.tsx | 641 +++++++++++++++++++++++++++++ 1 file changed, 641 insertions(+) create mode 100644 src/webex/pages/confirm-create-reserve.tsx (limited to 'src/webex/pages/confirm-create-reserve.tsx') 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 + */ + + +/** + * 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(delayMs: number, value: T): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(value), delayMs); + }); +} + +class EventTrigger { + triggerResolve: any; + triggerPromise: Promise; + + constructor() { + this.reset(); + } + + private reset() { + this.triggerPromise = new Promise((resolve, reject) => { + this.triggerResolve = resolve; + }); + } + + trigger() { + this.triggerResolve(false); + this.reset(); + } + + async wait(delayMs: number): Promise { + return await Promise.race([this.triggerPromise, delay(delayMs, true)]); + } +} + + +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +class Collapsible extends React.Component { + 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

{this.props.title}

; + } + return ( +
+

{this.props.title}

+ {this.props.children} +
+ ); + } +} + +function renderAuditorDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( +

+ Details will be displayed when a valid exchange provider URL is entered. +

+ ); + } + if (rci.exchangeInfo.auditors.length == 0) { + return ( +

+ The exchange is not audited by any auditors. +

+ ); + } + return ( +
+ {rci.exchangeInfo.auditors.map(a => ( +

Auditor {a.url}

+ ))} +
+ ); +} + +function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( +

+ Details will be displayed when a valid exchange provider URL is entered. +

+ ); + } + + 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 ( + + {countByPub[denom.denomPub] + "x"} + {amountToPretty(denom.value)} + {amountToPretty(denom.feeWithdraw)} + {amountToPretty(denom.feeRefresh)} + {amountToPretty(denom.feeDeposit)} + + ); + } + + function wireFee(s: string) { + return [ + + + Wire Method {s} + + + Applies Until + Wire Fee + Closing Fee + + , + + {rci!.wireFees.feesForType[s].map(f => ( + + {moment.unix(f.endStamp).format("llll")} + {amountToPretty(f.wireFee)} + {amountToPretty(f.closingFee)} + + ))} + + ]; + } + + let withdrawFeeStr = amountToPretty(rci.withdrawFee); + let overheadStr = amountToPretty(rci.overhead); + + return ( +
+

Overview

+

{i18n.str`Withdrawal fees: ${withdrawFeeStr}`}

+

{i18n.str`Rounding loss: ${overheadStr}`}

+

{i18n.str`Earliest expiration (for deposit): ${moment.unix(rci.earliestDepositExpiration).fromNow()}`}

+

Coin Fees

+ + + + + + + + + + + + {uniq.map(row)} + +
{i18n.str`# Coins`}{i18n.str`Value`}{i18n.str`Withdraw Fee`}{i18n.str`Refresh Fee`}{i18n.str`Deposit Fee`}
+

Wire Fees

+ + {Object.keys(rci.wireFees.feesForType).map(wireFee)} +
+
+ ); +} + + +function getSuggestedExchange(currency: string): Promise { + // 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

{i18n.str`Withdraw fees:`} {amountToPretty(totalCost)}

; + } + return

; +} + + +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 { + url: StateHolder = this.makeState(""); + errorMessage: StateHolder = this.makeState(null); + isOkay: StateHolder = this.makeState(false); + updateEvent = new EventTrigger(); + constructor(p: ManualSelectionProps) { + super(p); + this.url(p.initialUrl); + this.update(); + } + render() { + return ( +

+
+ + this.onUrlChanged((e.target as HTMLInputElement).value)} /> +
+
+ + {this.errorMessage()} +
+
+ ); + } + + 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 { + statusString: StateHolder = this.makeState(null); + reserveCreationInfo: StateHolder = this.makeState( + null); + url: StateHolder = this.makeState(null); + + selectingExchange: StateHolder = 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 = ( + + The exchange is trusted by the wallet. + + ); + } else if (rci.isAudited) { + trustMessage = ( + + The exchange is audited by a trusted auditor. + + ); + } else { + trustMessage = ( + + 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. + + ); + } + return ( +
+ + Using exchange provider {this.url()}. + The exchange provider will charge + {" "} + {amountToPretty(totalCost)} + {" "} + in fees. + + {trustMessage} +
+ ); + } + if (this.url() && !this.statusString()) { + let shortName = new URI(this.url()!).host(); + return ( + + Waiting for a response from + {" "} + {shortName} + + ); + } + if (this.statusString()) { + return ( +

+ {i18n.str`A problem occured, see below. ${this.statusString()}`} +

+ ); + } + return ( +

+ {i18n.str`Information about fees will be available when an exchange provider is selected.`} +

+ ); + } + + renderConfirm() { + return ( +
+ {this.renderFeeStatus()} + + { " " } + +
+ + {renderReserveCreationDetails(this.reserveCreationInfo())} + + + {renderAuditorDetails(this.reserveCreationInfo())} + +
+ ); + } + + 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 ( +
+ Please select an exchange. You can review the details before after your selection. + + {this.props.suggestedExchangeUrl && ( +
+

Bank Suggestion

+ +
+ )} + + {exchanges.length > 0 && ( +
+

Known Exchanges

+ {exchanges.map(e => ( + + ))} +
+ )} + +

Manual Selection

+ this.select(url)} /> +
+ ); + } + + render(): JSX.Element { + return ( +
+ + {"You are about to withdraw "} + {amountToPretty(this.props.amount)} + {" from your bank account into your wallet."} + + {this.selectingExchange() ? this.renderSelect() : this.renderConfirm()} +
+ ); + } + + + 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

{this.statusString()}

; + } else if (!this.reserveCreationInfo()) { + return

{i18n.str`Checking URL, please wait ...`}

; + } + 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(, 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(); +}); -- cgit v1.2.3