diff options
Diffstat (limited to 'src/webex')
-rw-r--r-- | src/webex/messages.ts | 16 | ||||
-rw-r--r-- | src/webex/notify.ts | 187 | ||||
-rw-r--r-- | src/webex/pages/confirm-create-reserve.tsx | 47 | ||||
-rw-r--r-- | src/webex/pages/error.tsx | 97 | ||||
-rw-r--r-- | src/webex/pages/refund.html | 18 | ||||
-rw-r--r-- | src/webex/pages/refund.tsx | 138 | ||||
-rw-r--r-- | src/webex/renderHtml.tsx | 39 | ||||
-rw-r--r-- | src/webex/wxApi.ts | 24 | ||||
-rw-r--r-- | src/webex/wxBackend.ts | 27 |
9 files changed, 418 insertions, 175 deletions
diff --git a/src/webex/messages.ts b/src/webex/messages.ts index d7ecd06a1..7de28b9e9 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -176,6 +176,22 @@ export interface MessageMap { request: { }; response: void; }; + "log-and-display-error": { + request: any; + response: void; + }; + "get-report": { + request: { reportUid: string }; + response: void; + }; + "accept-refund": { + request: any; + response: void; + }; + "get-purchase": { + request: any; + response: void; + } } /** diff --git a/src/webex/notify.ts b/src/webex/notify.ts index 51abdb0e0..5e024d619 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -30,6 +30,8 @@ import wxApi = require("./wxApi"); import { QueryPaymentResult } from "../types"; +import axios from 'axios'; + declare var cloneInto: any; let logVerbose: boolean = false; @@ -98,85 +100,39 @@ function setStyles(installed: boolean) { } -function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) { +async function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) { if (!maybeFoundResponse.found) { console.log("pay-failed", {hint: "payment not found in the wallet"}); return; } const walletResp = maybeFoundResponse; - /** - * Handle a failed payment. - * - * Try to notify the wallet first, before we show a potentially - * synchronous error message (such as an alert) or leave the page. - */ - async function handleFailedPayment(r: XMLHttpRequest) { - let timeoutHandle: number|null = null; - function err() { - // FIXME: proper error reporting! - console.log("pay-failed", {status: r.status, response: r.responseText}); - } - function onTimeout() { - timeoutHandle = null; - err(); - } - timeoutHandle = window.setTimeout(onTimeout, 200); - - await wxApi.paymentFailed(walletResp.contractTermsHash); - if (timeoutHandle !== null) { - clearTimeout(timeoutHandle); - timeoutHandle = null; - } - err(); - } logVerbose && console.log("handling taler-notify-payment: ", walletResp); - // Payment timeout in ms. - let timeout_ms = 1000; - // Current request. - let r: XMLHttpRequest|null; - let timeoutHandle: number|null = null; - function sendPay() { - r = new XMLHttpRequest(); - r.open("post", walletResp.contractTerms.pay_url); - r.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); - r.send(JSON.stringify(walletResp.payReq)); - r.onload = async () => { - if (!r) { - return; - } - switch (r.status) { - case 200: - const merchantResp = JSON.parse(r.responseText); - logVerbose && console.log("got success from pay_url"); - await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig); - const nextUrl = walletResp.contractTerms.fulfillment_url; - logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl); - window.location.href = nextUrl; - window.location.reload(true); - break; - default: - handleFailedPayment(r); - break; - } - r = null; - if (timeoutHandle !== null) { - clearTimeout(timeoutHandle!); - timeoutHandle = null; - } - }; - function retry() { - if (r) { - r.abort(); - r = null; - } - timeout_ms = Math.min(timeout_ms * 2, 10 * 1000); - logVerbose && console.log("sendPay timed out, retrying in ", timeout_ms, "ms"); - sendPay(); + let resp; + try { + const config = { + timeout: 5000, /* 5 seconds */ + headers: { "Content-Type": "application/json;charset=UTF-8" }, + validateStatus: (s: number) => s == 200, } - timeoutHandle = window.setTimeout(retry, timeout_ms); + resp = await axios.post(walletResp.contractTerms.pay_url, walletResp.payReq, config); + } catch (e) { + // Gives the user the option to retry / abort and refresh + wxApi.logAndDisplayError({ + name: "pay-post-failed", + contractTerms: walletResp.contractTerms, + message: e.message, + response: e.response, + }); + throw e; } - sendPay(); + const merchantResp = resp.data; + logVerbose && console.log("got success from pay_url"); + await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig); + const nextUrl = walletResp.contractTerms.fulfillment_url; + logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl); + window.location.href = nextUrl; + window.location.reload(true); } @@ -233,53 +189,24 @@ function init() { type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void; -function downloadContract(url: string, nonce: string): Promise<any> { +async function downloadContract(url: string, nonce: string): Promise<any> { const parsed_url = new URI(url); url = parsed_url.setQuery({nonce}).href(); - // FIXME: include and check nonce! - return new Promise((resolve, reject) => { - const contract_request = new XMLHttpRequest(); - console.log("downloading contract from '" + url + "'"); - contract_request.open("GET", url, true); - contract_request.onload = (e) => { - if (contract_request.readyState === 4) { - if (contract_request.status === 200) { - console.log("response text:", - contract_request.responseText); - const contract_wrapper = JSON.parse(contract_request.responseText); - if (!contract_wrapper) { - console.error("response text was invalid json"); - const detail = { - body: contract_request.responseText, - hint: "invalid json", - status: contract_request.status, - }; - reject(detail); - return; - } - resolve(contract_wrapper); - } else { - const detail = { - body: contract_request.responseText, - hint: "contract download failed", - status: contract_request.status, - }; - reject(detail); - return; - } - } - }; - contract_request.onerror = (e) => { - const detail = { - body: contract_request.responseText, - hint: "contract download failed", - status: contract_request.status, - }; - reject(detail); - return; - }; - contract_request.send(); - }); + console.log("downloading contract from '" + url + "'"); + let resp; + try { + resp = await axios.get(url, { validateStatus: (s) => s == 200 }); + } catch (e) { + wxApi.logAndDisplayError({ + name: "contract-download-failed", + message: e.message, + response: e.response, + sameTab: true, + }); + throw e; + } + console.log("got response", resp); + return resp.data; } async function processProposal(proposal: any) { @@ -328,8 +255,38 @@ async function processProposal(proposal: any) { document.location.replace(target); } + +/** + * Handle a payment request (coming either from an HTTP 402 or + * the JS wallet API). + */ function talerPay(msg: any): Promise<any> { + // Use a promise directly instead of of an async + // function since some paths never resolve the promise. return new Promise(async(resolve, reject) => { + if (msg.refund_url) { + console.log("processing refund"); + let resp; + try { + const config = { + validateStatus: (s: number) => s == 200, + } + resp = await axios.get(msg.refund_url, config); + } catch (e) { + wxApi.logAndDisplayError({ + name: "refund-download-failed", + message: e.message, + response: e.response, + sameTab: true, + }); + throw e; + } + await wxApi.acceptRefund(resp.data); + const hc = resp.data.refund_permissions[0].h_contract_terms; + document.location.href = chrome.extension.getURL(`/src/webex/pages/refund.html?contractTermsHash=${hc}`); + return; + } + // current URL without fragment const url = new URI(document.location.href).fragment("").href(); const res = await wxApi.queryPayment(url); diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx index 4e3b6748f..7d543860f 100644 --- a/src/webex/pages/confirm-create-reserve.tsx +++ b/src/webex/pages/confirm-create-reserve.tsx @@ -41,7 +41,7 @@ import { getReserveCreationInfo, } from "../wxApi"; -import {renderAmount} from "../renderHtml"; +import {Collapsible, renderAmount} from "../renderHtml"; import * as React from "react"; import * as ReactDOM from "react-dom"; @@ -80,40 +80,6 @@ class EventTrigger { } -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) { console.log("rci", rci); @@ -405,7 +371,7 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { if (this.statusString()) { return ( <p> - <strong style={{color: "red"}}>{i18n.str`A problem occured, see below. ${this.statusString()}`}</strong> + <strong style={{color: "red"}}>{this.statusString()}</strong> </p> ); } @@ -549,12 +515,9 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { 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")) { - const resp = e.errorResponse; - this.statusString(`Error: ${resp.error} (${resp.hint})`); - } + this.statusString(`Error: ${e.message}`); + // Re-try every 5 seconds as long as there is a problem + setTimeout(() => this.statusString() ? this.forceReserveUpdate() : undefined, 5000); } } diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx index e86b6cf4c..2edef5e5b 100644 --- a/src/webex/pages/error.tsx +++ b/src/webex/pages/error.tsx @@ -22,40 +22,103 @@ * @author Florian Dold */ + import * as React from "react"; import * as ReactDOM from "react-dom"; import URI = require("urijs"); +import * as wxApi from "../wxApi"; + +import { Collapsible } from "../renderHtml"; + interface ErrorProps { - message: string; + report: any; } class ErrorView extends React.Component<ErrorProps, { }> { render(): JSX.Element { - return ( - <div> - An error occurred: {this.props.message} - </div> - ); + const report = this.props.report; + if (!report) { + return ( + <div id="main"> + <h1>Error Report Not Found</h1> + <p>This page is supposed to display an error reported by the GNU Taler wallet, + but the corresponding error report can't be found.</p> + <p>Maybe the error occured before the browser was restarted or the wallet was reloaded.</p> + </div> + ); + } + try { + switch (report.name) { + case "pay-post-failed": { + const summary = report.contractTerms.summary || report.contractTerms.order_id; + return ( + <div id="main"> + <h1>Failed to send payment</h1> + <p>Failed to send payment for <strong>{summary}</strong> to merchant <strong>{report.contractTerms.merchant.name}</strong>.</p> + <p>You can <a href={report.contractTerms.fulfillment_url}>retry</a> the payment. If this problem persists, + please contact the mechant with the error details below.</p> + <Collapsible initiallyCollapsed={true} title="Error Details"> + <pre> + {JSON.stringify(report, null, " ")} + </pre> + </Collapsible> + </div> + ); + } + default: + return ( + <div id="main"> + <h1>Unknown Error</h1> + The GNU Taler wallet reported an unknown error. Here are the details: + <pre> + {JSON.stringify(report, null, " ")} + </pre> + </div> + ); + } + } catch (e) { + return ( + <div id="main"> + <h1>Error</h1> + The GNU Taler wallet reported an error. Here are the details: + <pre> + {JSON.stringify(report, null, " ")} + </pre> + A detailed error report could not be generated: + <pre> + {e.toString()} + </pre> + </div> + ); + } } } async function main() { - try { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); - const message: string = query.message || "unknown error"; + const container = document.getElementById("container"); + if (!container) { + console.error("fatal: can't mount component, countainer missing"); + return; + } - ReactDOM.render(<ErrorView message={message} />, document.getElementById( - "container")!); + // report that we'll render, either looked up from the + // logging module or synthesized here for fixed/fatal errors + let report; - } catch (e) { - // TODO: provide more context information, maybe factor it out into a - // TODO:generic error reporting function or component. - document.body.innerText = `Fatal error: "${e.message}".`; - console.error(`got error "${e.message}"`, e); + const reportUid: string = query.reportUid; + if (!reportUid) { + report = { + name: "missing-error", + }; + } else { + report = await wxApi.getReport(reportUid); } + + ReactDOM.render(<ErrorView report={report} />, container); } document.addEventListener("DOMContentLoaded", () => main()); diff --git a/src/webex/pages/refund.html b/src/webex/pages/refund.html new file mode 100644 index 000000000..f97dc9d6c --- /dev/null +++ b/src/webex/pages/refund.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Refund Status</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/refund-bundle.js"></script> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx new file mode 100644 index 000000000..b9506bf29 --- /dev/null +++ b/src/webex/pages/refund.tsx @@ -0,0 +1,138 @@ +/* + 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 that shows refund status for purchases. + * + * @author Florian Dold + */ + + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +import * as wxApi from "../wxApi"; +import * as types from "../../types"; + +import { AmountDisplay } from "../renderHtml"; + +interface RefundStatusViewProps { + contractTermsHash: string; +} + +interface RefundStatusViewState { + purchase?: types.PurchaseRecord; + gotResult: boolean; +} + + +const RefundDetail = ({purchase}: {purchase: types.PurchaseRecord}) => { + const pendingKeys = Object.keys(purchase.refundsPending); + const doneKeys = Object.keys(purchase.refundsDone); + if (pendingKeys.length == 0 && doneKeys.length == 0) { + return <p>No refunds</p>; + } + + const currency = { ...purchase.refundsDone, ...purchase.refundsPending }[([...pendingKeys, ...doneKeys][0])].refund_amount.currency; + if (!currency) { + throw Error("invariant"); + } + + let amountPending = types.Amounts.getZero(currency); + let feesPending = types.Amounts.getZero(currency) + for (let k of pendingKeys) { + amountPending = types.Amounts.add(amountPending, purchase.refundsPending[k].refund_amount).amount; + feesPending = types.Amounts.add(feesPending, purchase.refundsPending[k].refund_fee).amount; + } + let amountDone = types.Amounts.getZero(currency); + let feesDone = types.Amounts.getZero(currency); + for (let k of doneKeys) { + amountDone = types.Amounts.add(amountDone, purchase.refundsDone[k].refund_amount).amount; + feesDone = types.Amounts.add(feesDone, purchase.refundsDone[k].refund_fee).amount; + } + + return ( + <div> + <p>Refund fully received: <AmountDisplay amount={amountDone} /> (refund fees: <AmountDisplay amount={feesDone} />)</p> + <p>Refund incoming: <AmountDisplay amount={amountPending} /> (refund fees: <AmountDisplay amount={feesPending} />)</p> + </div> + ); +}; + +class RefundStatusView extends React.Component<RefundStatusViewProps, RefundStatusViewState> { + + constructor(props: RefundStatusViewProps) { + super(props); + this.state = { gotResult: false }; + } + + componentDidMount() { + this.update(); + const port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + } + + render(): JSX.Element { + const purchase = this.state.purchase; + if (!purchase) { + if (this.state.gotResult) { + return <span>No purchase with contract terms hash {this.props.contractTermsHash} found</span>; + } else { + return <span>...</span>; + } + } + const merchantName = purchase.contractTerms.merchant.name || "(unknown)"; + const summary = purchase.contractTerms.summary || purchase.contractTerms.order_id; + return ( + <div id="main"> + <h1>Refund Status</h1> + <p>Status of purchase <strong>{summary}</strong> from merchant <strong>{merchantName}</strong> (order id {purchase.contractTerms.order_id}).</p> + <p>Total amount: <AmountDisplay amount={purchase.contractTerms.amount} /></p> + {purchase.finished ? <RefundDetail purchase={purchase} /> : <p>Purchase not completed.</p>} + </div> + ); + } + + async update() { + const purchase = await wxApi.getPurchase(this.props.contractTermsHash); + console.log("got purchase", purchase); + this.setState({ purchase, gotResult: true }); + } +} + + +async function main() { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + + const container = document.getElementById("container"); + if (!container) { + console.error("fatal: can't mount component, countainer missing"); + return; + } + + const contractTermsHash = query.contractTermsHash || "(none)"; + ReactDOM.render(<RefundStatusView contractTermsHash={contractTermsHash} />, container); +} + +document.addEventListener("DOMContentLoaded", () => main()); diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index 51f9019ef..2a5b50533 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -73,6 +73,8 @@ export function renderAmount(amount: AmountJson) { return <span>{x} {amount.currency}</span>; } +export const AmountDisplay = ({amount}: {amount: AmountJson}) => renderAmount(amount); + /** * Abbreviate a string to a given length, and show the full @@ -89,3 +91,40 @@ export function abbrev(s: string, n: number = 5) { </span> ); } + + + +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +export 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> + ); + } +} diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index 1371e27e4..1423da53b 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -31,6 +31,7 @@ import { DenominationRecord, ExchangeRecord, PreCoinRecord, + PurchaseRecord, QueryPaymentResult, ReserveCreationInfo, ReserveRecord, @@ -321,3 +322,26 @@ export function getSenderWireInfos(): Promise<SenderWireInfos> { export function returnCoins(args: { amount: AmountJson, exchange: string, senderWire: object }): Promise<void> { return callBackend("return-coins", args); } + + +/** + * Record an error report and display it in a tabl. + * + * If sameTab is set, the error report will be opened in the current tab, + * otherwise in a new tab. + */ +export function logAndDisplayError(args: any): Promise<void> { + return callBackend("log-and-display-error", args); +} + +export function getReport(reportUid: string): Promise<void> { + return callBackend("get-report", { reportUid }); +} + +export function acceptRefund(refundData: any): Promise<number> { + return callBackend("accept-refund", refundData); +} + +export function getPurchase(contractTermsHash: string): Promise<PurchaseRecord> { + return callBackend("get-purchase", { contractTermsHash }); +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 974bcb3c2..2f249af44 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -303,6 +303,26 @@ function handleMessage(sender: MessageSender, } return resp; } + case "log-and-display-error": + logging.storeReport(detail).then((reportUid) => { + const url = chrome.extension.getURL(`/src/webex/pages/error.html?reportUid=${reportUid}`); + if (detail.sameTab && sender && sender.tab && sender.tab.id) { + chrome.tabs.update(detail.tabId, { url }); + } else { + chrome.tabs.create({ url }); + } + }); + return; + case "get-report": + return logging.getReport(detail.reportUid); + case "accept-refund": + return needsWallet().acceptRefund(detail.refund_permissions); + case "get-purchase": + const contractTermsHash = detail.contractTermsHash; + if (!contractTermsHash) { + throw Error("contractTermsHash missing"); + } + return needsWallet().getPurchase(contractTermsHash); default: // Exhaustiveness check. // See https://www.typescriptlang.org/docs/handbook/advanced-types.html @@ -332,7 +352,7 @@ async function dispatch(req: any, sender: any, sendResponse: any): Promise<void> try { sendResponse({ error: "exception", - hint: e.message, + message: e.message, stack, }); } catch (e) { @@ -371,6 +391,9 @@ class ChromeNotifier implements Notifier { /** * Mapping from tab ID to payment information (if any). + * + * Used to pass information from an intercepted HTTP header to the content + * script on the page. */ const paymentRequestCookies: { [n: number]: any } = {}; @@ -392,6 +415,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri const fields = { contract_url: headers["x-taler-contract-url"], offer_url: headers["x-taler-offer-url"], + refund_url: headers["x-taler-refund-url"], }; const talerHeaderFound = Object.keys(fields).filter((x: any) => (fields as any)[x]).length !== 0; @@ -406,6 +430,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri const payDetail = { contract_url: fields.contract_url, offer_url: fields.offer_url, + refund_url: fields.refund_url, }; console.log("got pay detail", payDetail); |