diff options
Diffstat (limited to 'src/webex')
-rw-r--r-- | src/webex/messages.ts | 16 | ||||
-rw-r--r-- | src/webex/notify.ts | 85 | ||||
-rw-r--r-- | src/webex/pages/confirm-create-reserve.tsx | 135 | ||||
-rw-r--r-- | src/webex/pages/tip.html | 24 | ||||
-rw-r--r-- | src/webex/pages/tip.tsx | 155 | ||||
-rw-r--r-- | src/webex/renderHtml.tsx | 145 | ||||
-rw-r--r-- | src/webex/style/wallet.css | 15 | ||||
-rw-r--r-- | src/webex/wxApi.ts | 23 | ||||
-rw-r--r-- | src/webex/wxBackend.ts | 25 |
9 files changed, 490 insertions, 133 deletions
diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 0ca903154..7cc6c4259 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -192,6 +192,22 @@ export interface MessageMap { request: { refundPermissions: types.RefundPermission[] }; response: void; }; + "get-tip-planchets": { + request: types.GetTipPlanchetsRequest; + response: void; + }; + "process-tip-response": { + request: types.ProcessTipResponseRequest; + response: void; + }; + "accept-tip": { + request: types.AcceptTipRequest; + response: void; + }; + "get-tip-status": { + request: types.TipStatusRequest; + response: void; + }; } /** diff --git a/src/webex/notify.ts b/src/webex/notify.ts index cc8086ceb..ecc04e8a2 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -28,7 +28,9 @@ import URI = require("urijs"); import wxApi = require("./wxApi"); -import { QueryPaymentResult } from "../types"; +import { getTalerStampSec } from "../helpers"; +import { TipToken, QueryPaymentResult } from "../types"; + import axios from "axios"; @@ -260,6 +262,87 @@ 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.tip) { + const tipToken = TipToken.checked(JSON.parse(msg.tip)); + + console.log("got tip token", tipToken); + + const deadlineSec = getTalerStampSec(tipToken.expiration); + if (!deadlineSec) { + wxApi.logAndDisplayError({ + message: "invalid expiration", + name: "tipping-failed", + sameTab: true, + }); + return; + } + + const merchantDomain = new URI(document.location.href).origin(); + let walletResp; + try { + walletResp = await wxApi.getTipPlanchets(merchantDomain, tipToken.tip_id, tipToken.amount, deadlineSec, tipToken.exchange_url); + } catch (e) { + wxApi.logAndDisplayError({ + message: e.message, + name: "tipping-failed", + response: e.response, + sameTab: true, + }); + throw e; + } + + let planchets = walletResp; + + if (!planchets) { + wxApi.logAndDisplayError({ + message: "processing tip failed", + detail: walletResp, + name: "tipping-failed", + sameTab: true, + }); + return; + } + + let merchantResp; + + try { + const config = { + validateStatus: (s: number) => s === 200, + }; + const req = { planchets, tip_id: tipToken.tip_id }; + merchantResp = await axios.post(tipToken.pickup_url, req, config); + } catch (e) { + wxApi.logAndDisplayError({ + message: e.message, + name: "tipping-failed", + response: e.response, + sameTab: true, + }); + throw e; + } + + try { + wxApi.processTipResponse(merchantDomain, tipToken.tip_id, merchantResp.data); + } catch (e) { + wxApi.logAndDisplayError({ + message: e.message, + name: "tipping-failed", + response: e.response, + sameTab: true, + }); + throw e; + } + + // Go to tip dialog page, where the user can confirm the tip or + // decline if they are not happy with the exchange. + const uri = new URI(chrome.extension.getURL("/src/webex/pages/tip.html")); + const params = { tip_id: tipToken.tip_id, merchant_domain: merchantDomain }; + const redirectUrl = uri.query(params).href(); + window.location.href = redirectUrl; + + return; + } + if (msg.refund_url) { console.log("processing refund"); let resp; diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx index 0e1cb17df..53b0d635f 100644 --- a/src/webex/pages/confirm-create-reserve.tsx +++ b/src/webex/pages/confirm-create-reserve.tsx @@ -22,18 +22,17 @@ * @author Florian Dold */ -import {canonicalizeBaseUrl} from "../../helpers"; +import { canonicalizeBaseUrl } from "../../helpers"; import * as i18n from "../../i18n"; import { AmountJson, Amounts, CreateReserveResponse, CurrencyRecord, - DenominationRecord, ReserveCreationInfo, } from "../../types"; -import {ImplicitStateComponent, StateHolder} from "../components"; +import { ImplicitStateComponent, StateHolder } from "../components"; import { createReserve, getCurrency, @@ -41,9 +40,8 @@ import { getReserveCreationInfo, } from "../wxApi"; -import {Collapsible, renderAmount} from "../renderHtml"; +import { renderAmount, WithdrawDetailView } from "../renderHtml"; -import * as moment from "moment"; import * as React from "react"; import * as ReactDOM from "react-dom"; import URI = require("urijs"); @@ -80,126 +78,6 @@ class EventTrigger { } -function renderAuditorDetails(rci: ReserveCreationInfo|null) { - console.log("rci", rci); - 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) => ( - <div> - <h3>Auditor {a.auditor_url}</h3> - <p>Public key: {a.auditor_pub}</p> - <p>Trusted: {rci.trustedAuditorPubs.indexOf(a.auditor_pub) >= 0 ? "yes" : "no"}</p> - <p>Audits {a.denomination_keys.length} of {rci.numOfferedDenoms} denominations</p> - </div> - ))} - </div> - ); -} - -function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { - if (!rci) { - return ( - <p> - Details will be displayed when a valid exchange provider URL is entered. - </p> - ); - } - - const denoms = rci.selectedDenoms; - - const countByPub: {[s: string]: number} = {}; - const 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>{renderAmount(denom.value)}</td> - <td>{renderAmount(denom.feeWithdraw)}</td> - <td>{renderAmount(denom.feeRefresh)}</td> - <td>{renderAmount(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>{renderAmount(f.wireFee)}</td> - <td>{renderAmount(f.closingFee)}</td> - </tr> - ))} - </tbody>, - ]; - } - - const withdrawFee = renderAmount(rci.withdrawFee); - const overhead = renderAmount(rci.overhead); - - return ( - <div> - <h3>Overview</h3> - <p>{i18n.str`Withdrawal fees:`} {withdrawFee}</p> - <p>{i18n.str`Rounding loss:`} {overhead}</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> - ); -} interface ExchangeSelectionProps { @@ -428,12 +306,7 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { </button> </p> {this.renderUpdateStatus()} - <Collapsible initiallyCollapsed={true} title="Fee and Spending Details"> - {renderReserveCreationDetails(this.reserveCreationInfo())} - </Collapsible> - <Collapsible initiallyCollapsed={true} title="Auditor Details"> - {renderAuditorDetails(this.reserveCreationInfo())} - </Collapsible> + <WithdrawDetailView rci={this.reserveCreationInfo()} /> </div> ); } diff --git a/src/webex/pages/tip.html b/src/webex/pages/tip.html new file mode 100644 index 000000000..72d91a123 --- /dev/null +++ b/src/webex/pages/tip.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Received Tip</title> + + <link rel="icon" href="/img/icon.png"> + <link rel="stylesheet" type="text/css" href="../style/pure.css"> + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/tip-bundle.js"></script> + +</head> + +<body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <div id="container"></div> + </section> +</body> + +</html> diff --git a/src/webex/pages/tip.tsx b/src/webex/pages/tip.tsx new file mode 100644 index 000000000..7f3a7c1fe --- /dev/null +++ b/src/webex/pages/tip.tsx @@ -0,0 +1,155 @@ +/* + This file is part of TALER + (C) 2017 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 * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +import * as i18n from "../../i18n"; + +import { + acceptTip, + getTipStatus, +} from "../wxApi"; + +import { renderAmount, WithdrawDetailView } from "../renderHtml"; + +import { Amounts, TipStatus } from "../../types"; + +interface TipDisplayProps { + merchantDomain: string; + tipId: string; +} + +interface TipDisplayState { + tipStatus?: TipStatus; + working: boolean; +} + +class TipDisplay extends React.Component<TipDisplayProps, TipDisplayState> { + constructor(props: TipDisplayProps) { + super(props); + this.state = { working: false }; + } + + async update() { + let tipStatus = await getTipStatus(this.props.merchantDomain, this.props.tipId); + this.setState({ tipStatus }); + } + + componentDidMount() { + this.update(); + const port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + } + + renderExchangeInfo(ts: TipStatus) { + const rci = ts.rci; + if (!rci) { + return <p>Waiting for info about exchange ...</p> + } + const totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount; + return ( + <div> + <p> + The tip is handled by the exchange <strong>{rci.exchangeInfo.baseUrl}</strong>.{" "} + The exchange provider will charge + {" "} + <strong>{renderAmount(totalCost)}</strong> + {" "}. + </p> + <WithdrawDetailView rci={rci} /> + </div> + ); + } + + accept() { + this.setState({ working: true}); + acceptTip(this.props.merchantDomain, this.props.tipId); + } + + renderButtons() { + return ( + <form className="pure-form"> + <button + className="pure-button pure-button-primary" + type="button" + onClick={() => this.accept()}> + { this.state.working ? <span><object className="svg-icon svg-baseline" data="/img/spinner-bars.svg" /> </span> : null } + Accept tip + </button> + {" "} + <button className="pure-button" type="button" onClick={() => { window.close(); }}>Discard tip</button> + </form> + ); + } + + render(): JSX.Element { + const ts = this.state.tipStatus; + if (!ts) { + return <p>Processing ...</p>; + } + return ( + <div> + <h2>Tip Received!</h2> + <p>You received a tip of <strong>{renderAmount(ts.tip.amount)}</strong> from <strong>{this.props.merchantDomain}</strong>.</p> + {ts.tip.accepted + ? <p>You've accepted this tip!</p> + : this.renderButtons() + } + {this.renderExchangeInfo(ts)} + </div> + ); + } +} + +async function main() { + try { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + + let merchantDomain = query.merchant_domain; + let tipId = query.tip_id; + let props: TipDisplayProps = { tipId, merchantDomain }; + + ReactDOM.render(<TipDisplay {...props} />, + document.getElementById("container")!); + + } 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(); +}); diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index 792ba2f2c..d4c536fa9 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -27,8 +27,14 @@ import { AmountJson, Amounts, + DenominationRecord, + ReserveCreationInfo, } from "../types"; +import * as moment from "moment"; + +import * as i18n from "../i18n"; + import * as React from "react"; @@ -101,3 +107,142 @@ export class Collapsible extends React.Component<CollapsibleProps, CollapsibleSt ); } } + + +function AuditorDetailsView(props: {rci: ReserveCreationInfo|null}): JSX.Element { + const rci = props.rci; + console.log("rci", rci); + 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) => ( + <div> + <h3>Auditor {a.auditor_url}</h3> + <p>Public key: {a.auditor_pub}</p> + <p>Trusted: {rci.trustedAuditorPubs.indexOf(a.auditor_pub) >= 0 ? "yes" : "no"}</p> + <p>Audits {a.denomination_keys.length} of {rci.numOfferedDenoms} denominations</p> + </div> + ))} + </div> + ); +} + +function FeeDetailsView(props: {rci: ReserveCreationInfo|null}): JSX.Element { + const rci = props.rci; + if (!rci) { + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); + } + + const denoms = rci.selectedDenoms; + + const countByPub: {[s: string]: number} = {}; + const 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>{renderAmount(denom.value)}</td> + <td>{renderAmount(denom.feeWithdraw)}</td> + <td>{renderAmount(denom.feeRefresh)}</td> + <td>{renderAmount(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>{renderAmount(f.wireFee)}</td> + <td>{renderAmount(f.closingFee)}</td> + </tr> + ))} + </tbody>, + ]; + } + + const withdrawFee = renderAmount(rci.withdrawFee); + const overhead = renderAmount(rci.overhead); + + return ( + <div> + <h3>Overview</h3> + <p>{i18n.str`Withdrawal fees:`} {withdrawFee}</p> + <p>{i18n.str`Rounding loss:`} {overhead}</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> + ); +} + + +export function WithdrawDetailView(props: {rci: ReserveCreationInfo | null}): JSX.Element { + const rci = props.rci; + return ( + <div> + <Collapsible initiallyCollapsed={true} title="Fee and Spending Details"> + <FeeDetailsView rci={rci} /> + </Collapsible> + <Collapsible initiallyCollapsed={true} title="Auditor Details"> + <AuditorDetailsView rci={rci} /> + </Collapsible> + </div> + ); +} diff --git a/src/webex/style/wallet.css b/src/webex/style/wallet.css index 61dd611e9..dde17e890 100644 --- a/src/webex/style/wallet.css +++ b/src/webex/style/wallet.css @@ -251,3 +251,18 @@ a.opener { .opener-collapsed::before { content: "\25b6 " } + +.svg-icon { + display: inline-flex; + align-self: center; + position: relative; + height: 1em; + width: 1em; +} +.svg-icon svg { + height:1em; + width:1em; +} +object.svg-icon.svg-baseline { + transform: translate(0, 0.125em); +} diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index 7afc116ba..e362fc34a 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -37,6 +37,9 @@ import { ReserveCreationInfo, ReserveRecord, SenderWireInfos, + TipResponse, + TipPlanchetDetail, + TipStatus, WalletBalance, } from "../types"; @@ -358,3 +361,23 @@ export function getPurchase(contractTermsHash: string): Promise<PurchaseRecord> export function getFullRefundFees(args: { refundPermissions: RefundPermission[] }): Promise<AmountJson> { return callBackend("get-full-refund-fees", { refundPermissions: args.refundPermissions }); } + + +/** + * Get or generate planchets to give the merchant that wants to tip us. + */ +export function getTipPlanchets(merchantDomain: string, tipId: string, amount: AmountJson, deadline: number, exchangeUrl: string): Promise<TipPlanchetDetail[]> { + return callBackend("get-tip-planchets", { merchantDomain, tipId, amount, deadline, exchangeUrl }); +} + +export function getTipStatus(merchantDomain: string, tipId: string): Promise<TipStatus> { + return callBackend("get-tip-status", { merchantDomain, tipId }); +} + +export function acceptTip(merchantDomain: string, tipId: string): Promise<TipStatus> { + return callBackend("accept-tip", { merchantDomain, tipId }); +} + +export function processTipResponse(merchantDomain: string, tipId: string, tipResponse: TipResponse): Promise<void> { + return callBackend("process-tip-response", { merchantDomain, tipId, tipResponse }); +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 7393c8880..a17f516a8 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -31,12 +31,16 @@ import { Store, } from "../query"; import { + AcceptTipRequest, AmountJson, ConfirmReserveRequest, CreateReserveRequest, + GetTipPlanchetsRequest, Notifier, + ProcessTipResponseRequest, ProposalRecord, ReturnCoinsRequest, + TipStatusRequest, } from "../types"; import { Stores, @@ -44,6 +48,7 @@ import { Wallet, } from "../wallet"; + import { ChromeBadge } from "./chromeBadge"; import { MessageType } from "./messages"; import * as wxApi from "./wxApi"; @@ -316,6 +321,22 @@ function handleMessage(sender: MessageSender, } case "get-full-refund-fees": return needsWallet().getFullRefundFees(detail.refundPermissions); + case "get-tip-status": { + const req = TipStatusRequest.checked(detail); + return needsWallet().getTipStatus(req.merchantDomain, req.tipId); + } + case "accept-tip": { + const req = AcceptTipRequest.checked(detail); + return needsWallet().acceptTip(req.merchantDomain, req.tipId); + } + case "process-tip-response": { + const req = ProcessTipResponseRequest.checked(detail); + return needsWallet().processTipResponse(req.merchantDomain, req.tipId, req.tipResponse); + } + case "get-tip-planchets": { + const req = GetTipPlanchetsRequest.checked(detail); + return needsWallet().getTipPlanchets(req.merchantDomain, req.tipId, req.amount, req.deadline, req.exchangeUrl); + } default: // Exhaustiveness check. // See https://www.typescriptlang.org/docs/handbook/advanced-types.html @@ -409,6 +430,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri contract_url: headers["x-taler-contract-url"], offer_url: headers["x-taler-offer-url"], refund_url: headers["x-taler-refund-url"], + tip: headers["x-taler-tip"], }; const talerHeaderFound = Object.keys(fields).filter((x: any) => (fields as any)[x]).length !== 0; @@ -424,6 +446,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri contract_url: fields.contract_url, offer_url: fields.offer_url, refund_url: fields.refund_url, + tip: fields.tip, }; console.log("got pay detail", payDetail); @@ -728,7 +751,7 @@ function openTalerDb(): Promise<IDBDatabase> { for (const indexName in (si as any)) { if ((si as any)[indexName] instanceof Index) { const ii: Index<any, any> = (si as any)[indexName]; - s.createIndex(ii.indexName, ii.keyPath); + s.createIndex(ii.indexName, ii.keyPath, ii.options); } } } |