aboutsummaryrefslogtreecommitdiff
path: root/src/webex
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
parent38a74188d759444d7e1abac856f78ae710e2a4c5 (diff)
move webex specific things in their own directory
Diffstat (limited to 'src/webex')
-rw-r--r--src/webex/background.html11
-rw-r--r--src/webex/background.ts30
-rw-r--r--src/webex/chromeBadge.ts225
-rw-r--r--src/webex/components.ts63
-rw-r--r--src/webex/notify.ts571
-rw-r--r--src/webex/pages/add-auditor.html34
-rw-r--r--src/webex/pages/add-auditor.tsx126
-rw-r--r--src/webex/pages/auditors.html36
-rw-r--r--src/webex/pages/auditors.tsx147
-rw-r--r--src/webex/pages/confirm-contract.html69
-rw-r--r--src/webex/pages/confirm-contract.tsx242
-rw-r--r--src/webex/pages/confirm-create-reserve.html52
-rw-r--r--src/webex/pages/confirm-create-reserve.tsx641
-rw-r--r--src/webex/pages/error.html18
-rw-r--r--src/webex/pages/error.tsx63
-rw-r--r--src/webex/pages/help/empty-wallet.html30
-rw-r--r--src/webex/pages/logs.html27
-rw-r--r--src/webex/pages/logs.tsx83
-rw-r--r--src/webex/pages/payback.html36
-rw-r--r--src/webex/pages/payback.tsx100
-rw-r--r--src/webex/pages/popup.css84
-rw-r--r--src/webex/pages/popup.html18
-rw-r--r--src/webex/pages/popup.tsx548
-rw-r--r--src/webex/pages/show-db.html18
-rw-r--r--src/webex/pages/show-db.ts94
-rw-r--r--src/webex/pages/tree.html27
-rw-r--r--src/webex/pages/tree.tsx437
-rw-r--r--src/webex/renderHtml.tsx79
-rw-r--r--src/webex/style/pure.css1508
-rw-r--r--src/webex/style/wallet.css222
-rw-r--r--src/webex/wxApi.ts174
-rw-r--r--src/webex/wxBackend.ts719
32 files changed, 6532 insertions, 0 deletions
diff --git a/src/webex/background.html b/src/webex/background.html
new file mode 100644
index 000000000..0535dd5f3
--- /dev/null
+++ b/src/webex/background.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <script src="../../dist/background-bundle.js"></script>
+ <title>(wallet bg page)</title>
+</head>
+<body>
+ <img id="taler-logo" src="/img/icon.png">
+</body>
+</html>
diff --git a/src/webex/background.ts b/src/webex/background.ts
new file mode 100644
index 000000000..3c63f323e
--- /dev/null
+++ b/src/webex/background.ts
@@ -0,0 +1,30 @@
+/*
+ This file is part of TALER
+ (C) 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/>
+ */
+
+/**
+ * Entry point for the background page.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {wxMain} from "./wxBackend";
+
+window.addEventListener("load", () => {
+ wxMain();
+});
diff --git a/src/webex/chromeBadge.ts b/src/webex/chromeBadge.ts
new file mode 100644
index 000000000..13add9b3f
--- /dev/null
+++ b/src/webex/chromeBadge.ts
@@ -0,0 +1,225 @@
+/*
+ This file is part of TALER
+ (C) 2016 INRIA
+
+ 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/>
+ */
+
+import {
+ Badge,
+} from "../wallet";
+
+
+/**
+ * Polyfill for requestAnimationFrame, which
+ * doesn't work from a background page.
+ */
+function rAF(cb: (ts: number) => void) {
+ window.setTimeout(() => {
+ cb(performance.now());
+ }, 100 /* 100 ms delay between frames */);
+}
+
+
+/**
+ * Badge for Chrome that renders a Taler logo with a rotating ring if some
+ * background activity is happening.
+ */
+export class ChromeBadge implements Badge {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ /**
+ * True if animation running. The animation
+ * might still be running even if we're not busy anymore,
+ * just to transition to the "normal" state in a animated way.
+ */
+ private animationRunning: boolean = false;
+
+ /**
+ * Is the wallet still busy? Note that we do not stop the
+ * animation immediately when the wallet goes idle, but
+ * instead slowly close the gap.
+ */
+ private isBusy: boolean = false;
+
+ /**
+ * Current rotation angle, ranges from 0 to rotationAngleMax.
+ */
+ private rotationAngle: number = 0;
+
+ /**
+ * While animating, how wide is the current gap in the circle?
+ * Ranges from 0 to openMax.
+ */
+ private gapWidth: number = 0;
+
+ /**
+ * Maximum value for our rotationAngle, corresponds to 2 Pi.
+ */
+ static rotationAngleMax = 1000;
+
+ /**
+ * How fast do we rotate? Given in rotation angle (relative to rotationAngleMax) per millisecond.
+ */
+ static rotationSpeed = 0.5;
+
+ /**
+ * How fast to we open? Given in rotation angle (relative to rotationAngleMax) per millisecond.
+ */
+ static openSpeed = 0.15;
+
+ /**
+ * How fast to we close? Given as a multiplication factor per frame update.
+ */
+ static closeSpeed = 0.7;
+
+ /**
+ * How far do we open? Given relative to rotationAngleMax.
+ */
+ static openMax = 100;
+
+ constructor(window?: Window) {
+ // Allow injecting another window for testing
+ const bg = window || chrome.extension.getBackgroundPage();
+ if (!bg) {
+ throw Error("no window available");
+ }
+ this.canvas = bg.document.createElement("canvas");
+ // Note: changing the width here means changing the font
+ // size in draw() as well!
+ this.canvas.width = 32;
+ this.canvas.height = 32;
+ this.ctx = this.canvas.getContext("2d")!;
+ this.draw();
+ }
+
+ /**
+ * Draw the badge based on the current state.
+ */
+ private draw() {
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
+
+ this.ctx.beginPath();
+ this.ctx.arc(0, 0, this.canvas.width / 2 - 2, 0, 2 * Math.PI);
+ this.ctx.fillStyle = "white";
+ this.ctx.fill();
+
+ // move into the center, off by 2 for aligning the "T" with the bottom
+ // of the circle.
+ this.ctx.translate(0, 2);
+
+ // pick sans-serif font; note: 14px is based on the 32px width above!
+ this.ctx.font = "bold 24px sans-serif";
+ // draw the "T" perfectly centered (x and y) to the current position
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+ this.ctx.fillStyle = "black";
+ this.ctx.fillText("T", 0, 0);
+ // now move really into the center
+ this.ctx.translate(0, -2);
+ // start drawing the (possibly open) circle
+ this.ctx.beginPath();
+ this.ctx.lineWidth = 2.5;
+ if (this.animationRunning) {
+ /* Draw circle around the "T" with an opening of this.gapWidth */
+ const aMax = ChromeBadge.rotationAngleMax;
+ const startAngle = this.rotationAngle / aMax * Math.PI * 2;
+ const stopAngle = ((this.rotationAngle + aMax - this.gapWidth) / aMax) * Math.PI * 2;
+ this.ctx.arc(0, 0, this.canvas.width / 2 - 2, /* radius */ startAngle, stopAngle, false);
+ } else {
+ /* Draw full circle */
+ this.ctx.arc(0, 0,
+ this.canvas.width / 2 - 2, /* radius */
+ 0,
+ Math.PI * 2,
+ false);
+ }
+ this.ctx.stroke();
+ // go back to the origin
+ this.ctx.translate(-this.canvas.width / 2, -this.canvas.height / 2);
+
+ // Allow running outside the extension for testing
+ // tslint:disable-next-line:no-string-literal
+ if (window["chrome"] && window.chrome["browserAction"]) {
+ try {
+ const imageData = this.ctx.getImageData(0,
+ 0,
+ this.canvas.width,
+ this.canvas.height);
+ chrome.browserAction.setIcon({imageData});
+ } catch (e) {
+ // Might fail if browser has over-eager canvas fingerprinting countermeasures.
+ // There's nothing we can do then ...
+ }
+ }
+ }
+
+ private animate() {
+ if (this.animationRunning) {
+ return;
+ }
+ this.animationRunning = true;
+ let start: number|undefined;
+ const step = (timestamp: number) => {
+ if (!this.animationRunning) {
+ return;
+ }
+ if (!start) {
+ start = timestamp;
+ }
+ if (!this.isBusy && 0 === this.gapWidth) {
+ // stop if we're close enough to origin
+ this.rotationAngle = 0;
+ } else {
+ this.rotationAngle = (this.rotationAngle + (timestamp - start) *
+ ChromeBadge.rotationSpeed) % ChromeBadge.rotationAngleMax;
+ }
+ if (this.isBusy) {
+ if (this.gapWidth < ChromeBadge.openMax) {
+ this.gapWidth += ChromeBadge.openSpeed * (timestamp - start);
+ }
+ if (this.gapWidth > ChromeBadge.openMax) {
+ this.gapWidth = ChromeBadge.openMax;
+ }
+ } else {
+ if (this.gapWidth > 0) {
+ this.gapWidth--;
+ this.gapWidth *= ChromeBadge.closeSpeed;
+ }
+ }
+
+ if (this.isBusy || this.gapWidth > 0) {
+ start = timestamp;
+ rAF(step);
+ } else {
+ this.animationRunning = false;
+ }
+ this.draw();
+ };
+ rAF(step);
+ }
+
+ startBusy() {
+ if (this.isBusy) {
+ return;
+ }
+ this.isBusy = true;
+ this.animate();
+ }
+
+ stopBusy() {
+ this.isBusy = false;
+ }
+}
diff --git a/src/webex/components.ts b/src/webex/components.ts
new file mode 100644
index 000000000..1f5d18731
--- /dev/null
+++ b/src/webex/components.ts
@@ -0,0 +1,63 @@
+/*
+ This file is part of TALER
+ (C) 2016 Inria
+
+ 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/>
+ */
+
+
+/**
+ * General helper React components.
+ */
+
+
+/**
+ * Imports.
+ */
+import * as React from "react";
+
+/**
+ * Wrapper around state that will cause updates to the
+ * containing component.
+ */
+export interface StateHolder<T> {
+ (): T;
+ (newState: T): void;
+}
+
+/**
+ * Component that doesn't hold its state in one object,
+ * but has multiple state holders.
+ */
+export abstract class ImplicitStateComponent<PropType> extends React.Component<PropType, any> {
+ private _implicit = {needsUpdate: false, didMount: false};
+ componentDidMount() {
+ this._implicit.didMount = true;
+ if (this._implicit.needsUpdate) {
+ this.setState({} as any);
+ }
+ }
+ makeState<StateType>(initial: StateType): StateHolder<StateType> {
+ let state: StateType = initial;
+ return (s?: StateType): StateType => {
+ if (s !== undefined) {
+ state = s;
+ if (this._implicit.didMount) {
+ this.setState({} as any);
+ } else {
+ this._implicit.needsUpdate = true;
+ }
+ }
+ return state;
+ };
+ }
+}
diff --git a/src/webex/notify.ts b/src/webex/notify.ts
new file mode 100644
index 000000000..733367a59
--- /dev/null
+++ b/src/webex/notify.ts
@@ -0,0 +1,571 @@
+/*
+ This file is part of TALER
+ (C) 2015 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/>
+ */
+
+// tslint:disable:no-unused-expression
+
+/**
+ * Module that is injected into (all!) pages to allow them
+ * to interact with the GNU Taler wallet via DOM Events.
+ */
+
+
+/**
+ * Imports.
+ */
+import URI = require("urijs");
+
+declare var cloneInto: any;
+
+const PROTOCOL_VERSION = 1;
+
+let logVerbose: boolean = false;
+try {
+ logVerbose = !!localStorage.getItem("taler-log-verbose");
+} catch (e) {
+ // can't read from local storage
+}
+
+if (document.documentElement.getAttribute("data-taler-nojs")) {
+ document.dispatchEvent(new Event("taler-probe-result"));
+}
+
+
+function subst(url: string, H_contract: string) {
+ url = url.replace("${H_contract}", H_contract);
+ url = url.replace("${$}", "$");
+ return url;
+}
+
+interface Handler {
+ type: string;
+ listener: (e: CustomEvent) => void|Promise<void>;
+}
+const handlers: Handler[] = [];
+
+function hashContract(contract: string): Promise<string> {
+ const walletHashContractMsg = {
+ detail: {contract},
+ type: "hash-contract",
+ };
+ return new Promise<string>((resolve, reject) => {
+ chrome.runtime.sendMessage(walletHashContractMsg, (resp: any) => {
+ if (!resp.hash) {
+ console.log("error", resp);
+ reject(Error("hashing failed"));
+ }
+ resolve(resp.hash);
+ });
+ });
+}
+
+function queryPayment(url: string): Promise<any> {
+ const walletMsg = {
+ detail: { url },
+ type: "query-payment",
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ resolve(resp);
+ });
+ });
+}
+
+function putHistory(historyEntry: any): Promise<void> {
+ const walletMsg = {
+ detail: {
+ historyEntry,
+ },
+ type: "put-history-entry",
+ };
+ return new Promise<void>((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ resolve();
+ });
+ });
+}
+
+function saveOffer(offer: any): Promise<number> {
+ const walletMsg = {
+ detail: {
+ offer: {
+ H_contract: offer.hash,
+ contract: offer.data,
+ merchant_sig: offer.sig,
+ offer_time: new Date().getTime() / 1000,
+ },
+ type: "save-offer",
+ },
+ };
+ return new Promise<number>((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ if (resp && resp.error) {
+ reject(resp);
+ } else {
+ resolve(resp);
+ }
+ });
+ });
+}
+
+
+let sheet: CSSStyleSheet|null;
+
+function initStyle() {
+ logVerbose && console.log("taking over styles");
+ const name = "taler-presence-stylesheet";
+ const content = "/* Taler stylesheet controlled by JS */";
+ let style = document.getElementById(name) as HTMLStyleElement|null;
+ if (!style) {
+ style = document.createElement("style");
+ // Needed by WebKit
+ style.appendChild(document.createTextNode(content));
+ style.id = name;
+ document.head.appendChild(style);
+ sheet = style.sheet as CSSStyleSheet;
+ } else {
+ // We've taken over the stylesheet now,
+ // make it clear by clearing all the rules in it
+ // and making it obvious in the DOM.
+ if (style.tagName.toLowerCase() === "style") {
+ style.innerText = content;
+ }
+ if (!style.sheet) {
+ throw Error("taler-presence-stylesheet should be a style sheet (<link> or <style>)");
+ }
+ sheet = style.sheet as CSSStyleSheet;
+ while (sheet.cssRules.length > 0) {
+ sheet.deleteRule(0);
+ }
+ }
+}
+
+
+function setStyles(installed: boolean) {
+ if (!sheet || !sheet.cssRules) {
+ return;
+ }
+ while (sheet.cssRules.length > 0) {
+ sheet.deleteRule(0);
+ }
+ if (installed) {
+ sheet.insertRule(".taler-installed-hide { display: none; }", 0);
+ sheet.insertRule(".taler-probed-hide { display: none; }", 0);
+ } else {
+ sheet.insertRule(".taler-installed-show { display: none; }", 0);
+ }
+}
+
+
+function handlePaymentResponse(walletResp: any) {
+ /**
+ * 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.
+ */
+ 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();
+ }
+ talerPaymentFailed(walletResp.H_contract).then(() => {
+ if (timeoutHandle !== null) {
+ clearTimeout(timeoutHandle);
+ timeoutHandle = null;
+ }
+ err();
+ });
+ timeoutHandle = window.setTimeout(onTimeout, 200);
+ }
+
+
+ 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.contract.pay_url);
+ r.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
+ r.send(JSON.stringify(walletResp.payReq));
+ r.onload = () => {
+ if (!r) {
+ return;
+ }
+ switch (r.status) {
+ case 200:
+ const merchantResp = JSON.parse(r.responseText);
+ logVerbose && console.log("got success from pay_url");
+ talerPaymentSucceeded({H_contract: walletResp.H_contract, merchantSig: merchantResp.sig}).then(() => {
+ const nextUrl = walletResp.contract.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();
+ }
+ timeoutHandle = window.setTimeout(retry, timeout_ms);
+ }
+ sendPay();
+}
+
+
+function init() {
+ chrome.runtime.sendMessage({type: "get-tab-cookie"}, (resp) => {
+ if (chrome.runtime.lastError) {
+ logVerbose && console.log("extension not yet ready");
+ window.setTimeout(init, 200);
+ return;
+ }
+ if (document.documentElement.getAttribute("data-taler-nojs")) {
+ const onload = () => {
+ initStyle();
+ setStyles(true);
+ };
+ if (document.readyState === "complete") {
+ onload();
+ } else {
+ document.addEventListener("DOMContentLoaded", onload);
+ }
+ }
+ registerHandlers();
+ // Hack to know when the extension is unloaded
+ const port = chrome.runtime.connect();
+
+ port.onDisconnect.addListener(() => {
+ logVerbose && console.log("chrome runtime disconnected, removing handlers");
+ if (document.documentElement.getAttribute("data-taler-nojs")) {
+ setStyles(false);
+ }
+ for (const handler of handlers) {
+ document.removeEventListener(handler.type, handler.listener);
+ }
+ });
+
+ if (resp && resp.type === "pay") {
+ logVerbose && console.log("doing taler.pay with", resp.payDetail);
+ talerPay(resp.payDetail).then(handlePaymentResponse);
+ document.documentElement.style.visibility = "hidden";
+ }
+ });
+}
+
+type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void;
+
+function generateNonce(): Promise<string> {
+ const walletMsg = {
+ type: "generate-nonce",
+ };
+ return new Promise<string>((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ resolve(resp);
+ });
+ });
+}
+
+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();
+ });
+}
+
+async function processProposal(proposal: any) {
+ if (!proposal.data) {
+ console.error("field proposal.data field missing");
+ return;
+ }
+
+ if (!proposal.hash) {
+ console.error("proposal.hash field missing");
+ return;
+ }
+
+ const contractHash = await hashContract(proposal.data);
+
+ if (contractHash !== proposal.hash) {
+ console.error("merchant-supplied contract hash is wrong");
+ return;
+ }
+
+ let merchantName = "(unknown)";
+ try {
+ merchantName = proposal.data.merchant.name;
+ } catch (e) {
+ // bad contract / name not included
+ }
+
+ const historyEntry = {
+ detail: {
+ contractHash,
+ merchantName,
+ },
+ subjectId: `contract-${contractHash}`,
+ timestamp: (new Date()).getTime(),
+ type: "offer-contract",
+ };
+ await putHistory(historyEntry);
+ const offerId = await saveOffer(proposal);
+
+ const uri = new URI(chrome.extension.getURL(
+ "/src/pages/confirm-contract.html"));
+ const params = {
+ offerId: offerId.toString(),
+ };
+ const target = uri.query(params).href();
+ document.location.replace(target);
+}
+
+function talerPay(msg: any): Promise<any> {
+ return new Promise(async(resolve, reject) => {
+ // current URL without fragment
+ const url = new URI(document.location.href).fragment("").href();
+ const res = await queryPayment(url);
+ logVerbose && console.log("taler-pay: got response", res);
+ if (res && res.payReq) {
+ resolve(res);
+ return;
+ }
+ if (msg.contract_url) {
+ const nonce = await generateNonce();
+ const proposal = await downloadContract(msg.contract_url, nonce);
+ if (proposal.data.nonce !== nonce) {
+ console.error("stale contract");
+ return;
+ }
+ await processProposal(proposal);
+ return;
+ }
+
+ if (msg.offer_url) {
+ document.location.href = msg.offer_url;
+ return;
+ }
+
+ console.log("can't proceed with payment, no way to get contract specified");
+ });
+}
+
+function talerPaymentFailed(H_contract: string) {
+ return new Promise(async(resolve, reject) => {
+ const walletMsg = {
+ detail: {
+ contractHash: H_contract,
+ },
+ type: "payment-failed",
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ resolve();
+ });
+ });
+}
+
+function talerPaymentSucceeded(msg: any) {
+ return new Promise((resolve, reject) => {
+ if (!msg.H_contract) {
+ console.error("H_contract missing in taler-payment-succeeded");
+ return;
+ }
+ if (!msg.merchantSig) {
+ console.error("merchantSig missing in taler-payment-succeeded");
+ return;
+ }
+ logVerbose && console.log("got taler-payment-succeeded");
+ const walletMsg = {
+ detail: {
+ contractHash: msg.H_contract,
+ merchantSig: msg.merchantSig,
+ },
+ type: "payment-succeeded",
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ resolve();
+ });
+ });
+}
+
+
+function registerHandlers() {
+ /**
+ * Add a handler for a DOM event, which automatically
+ * handles adding sequence numbers to responses.
+ */
+ function addHandler(type: string, handler: HandlerFn) {
+ const handlerWrap = (e: CustomEvent) => {
+ if (e.type !== type) {
+ throw Error(`invariant violated`);
+ }
+ let callId: number|undefined;
+ if (e.detail && e.detail.callId !== undefined) {
+ callId = e.detail.callId;
+ }
+ const responder = (msg?: any) => {
+ const fullMsg = Object.assign({}, msg, {callId});
+ let opts = { detail: fullMsg };
+ if ("function" === typeof cloneInto) {
+ opts = cloneInto(opts, document.defaultView);
+ }
+ const evt = new CustomEvent(type + "-result", opts);
+ document.dispatchEvent(evt);
+ };
+ handler(e.detail, responder);
+ };
+ document.addEventListener(type, handlerWrap);
+ handlers.push({type, listener: handlerWrap});
+ }
+
+
+ addHandler("taler-query-id", (msg: any, sendResponse: any) => {
+ // FIXME: maybe include this info in taoer-probe?
+ sendResponse({id: chrome.runtime.id});
+ });
+
+ addHandler("taler-probe", (msg: any, sendResponse: any) => {
+ sendResponse();
+ });
+
+ addHandler("taler-create-reserve", (msg: any) => {
+ const params = {
+ amount: JSON.stringify(msg.amount),
+ bank_url: document.location.href,
+ callback_url: new URI(msg.callback_url) .absoluteTo(document.location.href),
+ suggested_exchange_url: msg.suggested_exchange_url,
+ wt_types: JSON.stringify(msg.wt_types),
+ };
+ const uri = new URI(chrome.extension.getURL("/src/pages/confirm-create-reserve.html"));
+ const redirectUrl = uri.query(params).href();
+ window.location.href = redirectUrl;
+ });
+
+ addHandler("taler-add-auditor", (msg: any) => {
+ const params = {
+ req: JSON.stringify(msg),
+ };
+ const uri = new URI(chrome.extension.getURL("/src/pages/add-auditor.html"));
+ const redirectUrl = uri.query(params).href();
+ window.location.href = redirectUrl;
+ });
+
+ addHandler("taler-confirm-reserve", (msg: any, sendResponse: any) => {
+ const walletMsg = {
+ detail: {
+ reservePub: msg.reserve_pub,
+ },
+ type: "confirm-reserve",
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ });
+ });
+
+
+ addHandler("taler-confirm-contract", async(msg: any) => {
+ if (!msg.contract_wrapper) {
+ console.error("contract wrapper missing");
+ return;
+ }
+
+ const proposal = msg.contract_wrapper;
+
+ processProposal(proposal);
+ });
+
+ addHandler("taler-pay", async(msg: any, sendResponse: any) => {
+ const resp = await talerPay(msg);
+ sendResponse(resp);
+ });
+
+ addHandler("taler-payment-failed", async(msg: any, sendResponse: any) => {
+ await talerPaymentFailed(msg.H_contract);
+ sendResponse();
+ });
+
+ addHandler("taler-payment-succeeded", async(msg: any, sendResponse: any) => {
+ await talerPaymentSucceeded(msg);
+ sendResponse();
+ });
+}
+
+logVerbose && console.log("loading Taler content script");
+init();
+
diff --git a/src/webex/pages/add-auditor.html b/src/webex/pages/add-auditor.html
new file mode 100644
index 000000000..b7a9d041d
--- /dev/null
+++ b/src/webex/pages/add-auditor.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+
+ <title>Taler Wallet: Add Auditor</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/add-auditor-bundle.js"></script>
+
+ <style>
+ .tree-item {
+ margin: 2em;
+ border-radius: 5px;
+ border: 1px solid gray;
+ padding: 1em;
+ }
+ .button-linky {
+ background: none;
+ color: black;
+ text-decoration: underline;
+ border: none;
+ }
+ </style>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/add-auditor.tsx b/src/webex/pages/add-auditor.tsx
new file mode 100644
index 000000000..c1a9f997f
--- /dev/null
+++ b/src/webex/pages/add-auditor.tsx
@@ -0,0 +1,126 @@
+/*
+ This file is part of TALER
+ (C) 2017 Inria
+
+ 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/>
+ */
+
+/**
+ * View and edit auditors.
+ *
+ * @author Florian Dold
+ */
+
+
+import { getTalerStampDate } from "../../helpers";
+import {
+ ExchangeRecord,
+ DenominationRecord,
+ AuditorRecord,
+ CurrencyRecord,
+ ReserveRecord,
+ CoinRecord,
+ PreCoinRecord,
+ Denomination
+} from "../../types";
+
+import { ImplicitStateComponent, StateHolder } from "../components";
+import {
+ getCurrencies,
+ updateCurrency,
+} from "../wxApi";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import URI = require("urijs");
+
+interface ConfirmAuditorProps {
+ url: string;
+ currency: string;
+ auditorPub: string;
+ expirationStamp: number;
+}
+
+class ConfirmAuditor extends ImplicitStateComponent<ConfirmAuditorProps> {
+ addDone: StateHolder<boolean> = this.makeState(false);
+ constructor() {
+ super();
+ }
+
+ async add() {
+ let currencies = await getCurrencies();
+ let currency: CurrencyRecord|undefined = undefined;
+
+ for (let c of currencies) {
+ if (c.name == this.props.currency) {
+ currency = c;
+ }
+ }
+
+ if (!currency) {
+ currency = { name: this.props.currency, auditors: [], fractionalDigits: 2, exchanges: [] };
+ }
+
+ let newAuditor = { auditorPub: this.props.auditorPub, baseUrl: this.props.url, expirationStamp: this.props.expirationStamp };
+
+ let auditorFound = false;
+ for (let idx in currency.auditors) {
+ let a = currency.auditors[idx];
+ if (a.baseUrl == this.props.url) {
+ auditorFound = true;
+ // Update auditor if already found by URL.
+ currency.auditors[idx] = newAuditor;
+ }
+ }
+
+ if (!auditorFound) {
+ currency.auditors.push(newAuditor);
+ }
+
+ await updateCurrency(currency);
+
+ this.addDone(true);
+ }
+
+ back() {
+ window.history.back();
+ }
+
+ render(): JSX.Element {
+ return (
+ <div id="main">
+ <p>Do you want to let <strong>{this.props.auditorPub}</strong> audit the currency "{this.props.currency}"?</p>
+ {this.addDone() ?
+ (<div>Auditor was added! You can also <a href={chrome.extension.getURL("/src/pages/auditors.html")}>view and edit</a> auditors.</div>)
+ :
+ (<div>
+ <button onClick={() => this.add()} className="pure-button pure-button-primary">Yes</button>
+ <button onClick={() => this.back()} className="pure-button">No</button>
+ </div>)
+ }
+ </div>
+ );
+ }
+}
+
+export function main() {
+ const walletPageUrl = new URI(document.location.href);
+ const query: any = JSON.parse((URI.parseQuery(walletPageUrl.query()) as any)["req"]);
+ const url = query.url;
+ const currency: string = query.currency;
+ const auditorPub: string = query.auditorPub;
+ const expirationStamp = Number.parseInt(query.expirationStamp);
+ const args = { url, currency, auditorPub, expirationStamp };
+ ReactDOM.render(<ConfirmAuditor {...args} />, document.getElementById("container")!);
+}
+
+document.addEventListener("DOMContentLoaded", main);
diff --git a/src/webex/pages/auditors.html b/src/webex/pages/auditors.html
new file mode 100644
index 000000000..cbfc3b4b5
--- /dev/null
+++ b/src/webex/pages/auditors.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Auditors</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/auditors-bundle.js"></script>
+
+ <style>
+ body {
+ font-size: 100%;
+ }
+ .tree-item {
+ margin: 2em;
+ border-radius: 5px;
+ border: 1px solid gray;
+ padding: 1em;
+ }
+ .button-linky {
+ background: none;
+ color: black;
+ text-decoration: underline;
+ border: none;
+ }
+ </style>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/auditors.tsx b/src/webex/pages/auditors.tsx
new file mode 100644
index 000000000..dac3c2be9
--- /dev/null
+++ b/src/webex/pages/auditors.tsx
@@ -0,0 +1,147 @@
+/*
+ This file is part of TALER
+ (C) 2017 Inria
+
+ 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/>
+ */
+
+/**
+ * View and edit auditors.
+ *
+ * @author Florian Dold
+ */
+
+
+import { getTalerStampDate } from "../../helpers";
+import {
+ ExchangeRecord,
+ ExchangeForCurrencyRecord,
+ DenominationRecord,
+ AuditorRecord,
+ CurrencyRecord,
+ ReserveRecord,
+ CoinRecord,
+ PreCoinRecord,
+ Denomination
+} from "../../types";
+
+import { ImplicitStateComponent, StateHolder } from "../components";
+import {
+ getCurrencies,
+ updateCurrency,
+} from "../wxApi";
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+interface CurrencyListState {
+ currencies?: CurrencyRecord[];
+}
+
+class CurrencyList extends React.Component<any, CurrencyListState> {
+ constructor() {
+ super();
+ let port = chrome.runtime.connect();
+ port.onMessage.addListener((msg: any) => {
+ if (msg.notify) {
+ console.log("got notified");
+ this.update();
+ }
+ });
+ this.update();
+ this.state = {} as any;
+ }
+
+ async update() {
+ let currencies = await getCurrencies();
+ console.log("currencies: ", currencies);
+ this.setState({ currencies });
+ }
+
+ async confirmRemoveAuditor(c: CurrencyRecord, a: AuditorRecord) {
+ if (window.confirm(`Do you really want to remove auditor ${a.baseUrl} for currency ${c.name}?`)) {
+ c.auditors = c.auditors.filter((x) => x.auditorPub != a.auditorPub);
+ await updateCurrency(c);
+ }
+ }
+
+ async confirmRemoveExchange(c: CurrencyRecord, e: ExchangeForCurrencyRecord) {
+ if (window.confirm(`Do you really want to remove exchange ${e.baseUrl} for currency ${c.name}?`)) {
+ c.exchanges = c.exchanges.filter((x) => x.baseUrl != e.baseUrl);
+ await updateCurrency(c);
+ }
+ }
+
+ renderAuditors(c: CurrencyRecord): any {
+ if (c.auditors.length == 0) {
+ return <p>No trusted auditors for this currency.</p>
+ }
+ return (
+ <div>
+ <p>Trusted Auditors:</p>
+ <ul>
+ {c.auditors.map(a => (
+ <li>{a.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveAuditor(c, a)}>Remove</button>
+ <ul>
+ <li>valid until {new Date(a.expirationStamp).toString()}</li>
+ <li>public key {a.auditorPub}</li>
+ </ul>
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+ }
+
+ renderExchanges(c: CurrencyRecord): any {
+ if (c.exchanges.length == 0) {
+ return <p>No trusted exchanges for this currency.</p>
+ }
+ return (
+ <div>
+ <p>Trusted Exchanges:</p>
+ <ul>
+ {c.exchanges.map(e => (
+ <li>{e.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveExchange(c, e)}>Remove</button>
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+ }
+
+ render(): JSX.Element {
+ let currencies = this.state.currencies;
+ if (!currencies) {
+ return <span>...</span>;
+ }
+ return (
+ <div id="main">
+ {currencies.map(c => (
+ <div>
+ <h1>Currency {c.name}</h1>
+ <p>Displayed with {c.fractionalDigits} fractional digits.</p>
+ <h2>Auditors</h2>
+ <div>{this.renderAuditors(c)}</div>
+ <h2>Exchanges</h2>
+ <div>{this.renderExchanges(c)}</div>
+ </div>
+ ))}
+ </div>
+ );
+ }
+}
+
+export function main() {
+ ReactDOM.render(<CurrencyList />, document.getElementById("container")!);
+}
+
+document.addEventListener("DOMContentLoaded", main);
diff --git a/src/webex/pages/confirm-contract.html b/src/webex/pages/confirm-contract.html
new file mode 100644
index 000000000..6713b2e2c
--- /dev/null
+++ b/src/webex/pages/confirm-contract.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Confirm Reserve Creation</title>
+
+ <link rel="stylesheet" type="text/css" href="/src/style/wallet.css">
+
+ <link rel="icon" href="/img/icon.png">
+
+ <script src="/dist/page-common-bundle.js"></script>
+ <script src="/dist/confirm-contract-bundle.js"></script>
+
+ <style>
+ button.accept {
+ background-color: #5757D2;
+ border: 1px solid black;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: white;
+ }
+ button.linky {
+ background:none!important;
+ border:none;
+ padding:0!important;
+
+ font-family:arial,sans-serif;
+ color:#069;
+ text-decoration:underline;
+ cursor:pointer;
+ }
+
+ input.url {
+ width: 25em;
+ }
+
+
+ button.accept:disabled {
+ background-color: #dedbe8;
+ border: 1px solid white;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: #2C2C2C;
+ }
+
+ .errorbox {
+ border: 1px solid;
+ display: inline-block;
+ margin: 1em;
+ padding: 1em;
+ font-weight: bold;
+ background: #FF8A8A;
+ }
+ </style>
+</head>
+
+<body>
+ <section id="main">
+ <h1>GNU Taler Wallet</h1>
+ <article id="contract" class="fade"></article>
+ </section>
+</body>
+
+</html>
diff --git a/src/webex/pages/confirm-contract.tsx b/src/webex/pages/confirm-contract.tsx
new file mode 100644
index 000000000..011df27a1
--- /dev/null
+++ b/src/webex/pages/confirm-contract.tsx
@@ -0,0 +1,242 @@
+/*
+ This file is part of TALER
+ (C) 2015 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 entering
+ * a contract.
+ */
+
+
+/**
+ * Imports.
+ */
+import * as i18n from "../../i18n";
+import { Contract, AmountJson, ExchangeRecord } from "../../types";
+import { OfferRecord } from "../../wallet";
+
+import { renderContract } from "../renderHtml";
+import { getExchanges } from "../wxApi";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import URI = require("urijs");
+
+
+interface DetailState {
+ collapsed: boolean;
+}
+
+interface DetailProps {
+ contract: Contract
+ collapsed: boolean
+ exchanges: null|ExchangeRecord[];
+}
+
+
+class Details extends React.Component<DetailProps, DetailState> {
+ constructor(props: DetailProps) {
+ super(props);
+ console.log("new Details component created");
+ this.state = {
+ collapsed: props.collapsed,
+ };
+
+ console.log("initial state:", this.state);
+ }
+
+ render() {
+ if (this.state.collapsed) {
+ return (
+ <div>
+ <button className="linky"
+ onClick={() => { this.setState({collapsed: false} as any)}}>
+ <i18n.Translate wrap="span">
+ show more details
+ </i18n.Translate>
+ </button>
+ </div>
+ );
+ } else {
+ return (
+ <div>
+ <button className="linky"
+ onClick={() => this.setState({collapsed: true} as any)}>
+ show less details
+ </button>
+ <div>
+ {i18n.str`Accepted exchanges:`}
+ <ul>
+ {this.props.contract.exchanges.map(
+ e => <li>{`${e.url}: ${e.master_pub}`}</li>)}
+ </ul>
+ {i18n.str`Exchanges in the wallet:`}
+ <ul>
+ {(this.props.exchanges || []).map(
+ (e: ExchangeRecord) =>
+ <li>{`${e.baseUrl}: ${e.masterPublicKey}`}</li>)}
+ </ul>
+ </div>
+ </div>);
+ }
+ }
+}
+
+interface ContractPromptProps {
+ offerId: number;
+}
+
+interface ContractPromptState {
+ offer: OfferRecord|null;
+ error: string|null;
+ payDisabled: boolean;
+ exchanges: null|ExchangeRecord[];
+}
+
+class ContractPrompt extends React.Component<ContractPromptProps, ContractPromptState> {
+ constructor() {
+ super();
+ this.state = {
+ offer: null,
+ error: null,
+ payDisabled: true,
+ exchanges: null
+ }
+ }
+
+ componentWillMount() {
+ this.update();
+ }
+
+ componentWillUnmount() {
+ // FIXME: abort running ops
+ }
+
+ async update() {
+ let offer = await this.getOffer();
+ this.setState({offer} as any);
+ this.checkPayment();
+ let exchanges = await getExchanges();
+ this.setState({exchanges} as any);
+ }
+
+ getOffer(): Promise<OfferRecord> {
+ return new Promise<OfferRecord>((resolve, reject) => {
+ let msg = {
+ type: 'get-offer',
+ detail: {
+ offerId: this.props.offerId
+ }
+ };
+ chrome.runtime.sendMessage(msg, (resp) => {
+ resolve(resp);
+ });
+ })
+ }
+
+ checkPayment() {
+ let msg = {
+ type: 'check-pay',
+ detail: {
+ offer: this.state.offer
+ }
+ };
+ chrome.runtime.sendMessage(msg, (resp) => {
+ if (resp.error) {
+ console.log("check-pay error", JSON.stringify(resp));
+ switch (resp.error) {
+ case "coins-insufficient":
+ let msgInsufficient = i18n.str`You have insufficient funds of the requested currency in your wallet.`;
+ let msgNoMatch = i18n.str`You do not have any funds from an exchange that is accepted by this merchant. None of the exchanges accepted by the merchant is known to your wallet.`;
+ if (this.state.exchanges && this.state.offer) {
+ let acceptedExchangePubs = this.state.offer.contract.exchanges.map((e) => e.master_pub);
+ let ex = this.state.exchanges.find((e) => acceptedExchangePubs.indexOf(e.masterPublicKey) >= 0);
+ if (ex) {
+ this.setState({error: msgInsufficient});
+ } else {
+ this.setState({error: msgNoMatch});
+ }
+ } else {
+ this.setState({error: msgInsufficient});
+ }
+ break;
+ default:
+ this.setState({error: `Error: ${resp.error}`});
+ break;
+ }
+ this.setState({payDisabled: true});
+ } else {
+ this.setState({payDisabled: false, error: null});
+ }
+ this.setState({} as any);
+ window.setTimeout(() => this.checkPayment(), 500);
+ });
+ }
+
+ doPayment() {
+ let d = {offer: this.state.offer};
+ chrome.runtime.sendMessage({type: 'confirm-pay', detail: d}, (resp) => {
+ if (resp.error) {
+ console.log("confirm-pay error", JSON.stringify(resp));
+ switch (resp.error) {
+ case "coins-insufficient":
+ this.setState({error: "You do not have enough coins of the requested currency."});
+ break;
+ default:
+ this.setState({error: `Error: ${resp.error}`});
+ break;
+ }
+ return;
+ }
+ let c = d.offer!.contract;
+ console.log("contract", c);
+ document.location.href = c.fulfillment_url;
+ });
+ }
+
+
+ render() {
+ if (!this.state.offer) {
+ return <span>...</span>;
+ }
+ let c = this.state.offer.contract;
+ return (
+ <div>
+ <div>
+ {renderContract(c)}
+ </div>
+ <button onClick={() => this.doPayment()}
+ disabled={this.state.payDisabled}
+ className="accept">
+ Confirm payment
+ </button>
+ <div>
+ {(this.state.error ? <p className="errorbox">{this.state.error}</p> : <p />)}
+ </div>
+ <Details exchanges={this.state.exchanges} contract={c} collapsed={!this.state.error}/>
+ </div>
+ );
+ }
+}
+
+
+document.addEventListener("DOMContentLoaded", () => {
+ let url = new URI(document.location.href);
+ let query: any = URI.parseQuery(url.query());
+ let offerId = JSON.parse(query.offerId);
+
+ ReactDOM.render(<ContractPrompt offerId={offerId}/>, document.getElementById(
+ "contract")!);
+});
diff --git a/src/webex/pages/confirm-create-reserve.html b/src/webex/pages/confirm-create-reserve.html
new file mode 100644
index 000000000..16ab12a30
--- /dev/null
+++ b/src/webex/pages/confirm-create-reserve.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Select Taler Provider</title>
+
+ <link rel="icon" href="/img/icon.png">
+ <link rel="stylesheet" type="text/css" href="/src/style/wallet.css">
+ <link rel="stylesheet" type="text/css" href="/src/style/pure.css">
+
+ <script src="/dist/page-common-bundle.js"></script>
+ <script src="/dist/confirm-create-reserve-bundle.js"></script>
+
+ <style>
+ body {
+ font-size: 100%;
+ overflow-y: scroll;
+ }
+ .button-success {
+ background: rgb(28, 184, 65); /* this is a green */
+ color: white;
+ border-radius: 4px;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
+ }
+ .button-secondary {
+ background: rgb(66, 184, 221); /* this is a light blue */
+ color: white;
+ border-radius: 4px;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
+ }
+ a.opener {
+ color: black;
+ }
+ .opener-open::before {
+ content: "\25bc"
+ }
+ .opener-collapsed::before {
+ content: "\25b6 "
+ }
+ </style>
+
+</head>
+
+<body>
+ <section id="main">
+ <h1>GNU Taler Wallet</h1>
+ <div class="fade" id="exchange-selection"></div>
+ </section>
+</body>
+
+</html>
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();
+});
diff --git a/src/webex/pages/error.html b/src/webex/pages/error.html
new file mode 100644
index 000000000..51a8fd73a
--- /dev/null
+++ b/src/webex/pages/error.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Error Occured</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/error-bundle.js"></script>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx
new file mode 100644
index 000000000..f278bd224
--- /dev/null
+++ b/src/webex/pages/error.tsx
@@ -0,0 +1,63 @@
+/*
+ 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 {ImplicitStateComponent, StateHolder} from "../components";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import URI = require("urijs");
+
+"use strict";
+
+interface ErrorProps {
+ message: string;
+}
+
+class ErrorView extends React.Component<ErrorProps, void> {
+ render(): JSX.Element {
+ return (
+ <div>
+ An error occurred: {this.props.message}
+ </div>
+ );
+ }
+}
+
+export async function main() {
+ try {
+ const url = new URI(document.location.href);
+ const query: any = URI.parseQuery(url.query());
+
+ const message: string = query.message || "unknown error";
+
+ ReactDOM.render(<ErrorView message={message} />, 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 = `Fatal error: "${e.message}".`;
+ console.error(`got error "${e.message}"`, e);
+ }
+}
diff --git a/src/webex/pages/help/empty-wallet.html b/src/webex/pages/help/empty-wallet.html
new file mode 100644
index 000000000..dd29d9689
--- /dev/null
+++ b/src/webex/pages/help/empty-wallet.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>GNU Taler Help - Empty Wallet</title>
+ <link rel="icon" href="/img/icon.png">
+ <meta name="description" content="">
+ <link rel="stylesheet" type="text/css" href="/src/style/wallet.css">
+ </head>
+ <body>
+ <div class="container" id="main">
+ <div class="row">
+ <div class="col-lg-12">
+ <h2 lang="en">Your wallet is empty!</h2>
+ <p lang="en">You have succeeded with installing the Taler wallet. However, before
+ you can buy articles using the Taler wallet, you must withdraw electronic coins.
+ This is typically done by visiting your bank's online banking Web site. There,
+ you instruct your bank to transfer the funds to a Taler exchange operator. In
+ return, your wallet will be allowed to withdraw electronic coins.</p>
+ <p lang="en">At this stage, we are not aware of any regular exchange operators issuing
+ coins in well-known currencies. However, to see how Taler would work, you
+ can visit our "fake" bank at
+ <a href="https://bank.demo.taler.net/">bank.demo.taler.net</a> to
+ withdraw coins in the "KUDOS" currency that we created just for
+ demonstrating the system.</p>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/src/webex/pages/logs.html b/src/webex/pages/logs.html
new file mode 100644
index 000000000..9545269e3
--- /dev/null
+++ b/src/webex/pages/logs.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Logs</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/logs-bundle.js"></script>
+
+ <style>
+ .tree-item {
+ margin: 2em;
+ border-radius: 5px;
+ border: 1px solid gray;
+ padding: 1em;
+ }
+ </style>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/logs.tsx b/src/webex/pages/logs.tsx
new file mode 100644
index 000000000..0c533bfa8
--- /dev/null
+++ b/src/webex/pages/logs.tsx
@@ -0,0 +1,83 @@
+/*
+ This file is part of TALER
+ (C) 2016 Inria
+
+ 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/>
+ */
+
+/**
+ * Show wallet logs.
+ *
+ * @author Florian Dold
+ */
+
+import {LogEntry, getLogs} from "../../logging";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+interface LogViewProps {
+ log: LogEntry;
+}
+
+class LogView extends React.Component<LogViewProps, void> {
+ render(): JSX.Element {
+ let e = this.props.log;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>level: {e.level}</li>
+ <li>msg: {e.msg}</li>
+ <li>id: {e.id || "unknown"}</li>
+ <li>file: {e.source || "(unknown)"}</li>
+ <li>line: {e.line || "(unknown)"}</li>
+ <li>col: {e.col || "(unknown)"}</li>
+ {(e.detail ? <li> detail: <pre>{e.detail}</pre></li> : [])}
+ </ul>
+ </div>
+ );
+ }
+}
+
+interface LogsState {
+ logs: LogEntry[]|undefined;
+}
+
+class Logs extends React.Component<any, LogsState> {
+ constructor() {
+ super();
+ this.update();
+ this.state = {} as any;
+ }
+
+ async update() {
+ let logs = await getLogs();
+ this.setState({logs});
+ }
+
+ render(): JSX.Element {
+ let logs = this.state.logs;
+ if (!logs) {
+ return <span>...</span>;
+ }
+ return (
+ <div className="tree-item">
+ Logs:
+ {logs.map(e => <LogView log={e} />)}
+ </div>
+ );
+ }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ ReactDOM.render(<Logs />, document.getElementById("container")!);
+});
diff --git a/src/webex/pages/payback.html b/src/webex/pages/payback.html
new file mode 100644
index 000000000..d6fe334c8
--- /dev/null
+++ b/src/webex/pages/payback.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Payback</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/payback-bundle.js"></script>
+
+ <style>
+ body {
+ font-size: 100%;
+ }
+ .tree-item {
+ margin: 2em;
+ border-radius: 5px;
+ border: 1px solid gray;
+ padding: 1em;
+ }
+ .button-linky {
+ background: none;
+ color: black;
+ text-decoration: underline;
+ border: none;
+ }
+ </style>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/payback.tsx b/src/webex/pages/payback.tsx
new file mode 100644
index 000000000..7bcc581d8
--- /dev/null
+++ b/src/webex/pages/payback.tsx
@@ -0,0 +1,100 @@
+/*
+ This file is part of TALER
+ (C) 2017 Inria
+
+ 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/>
+ */
+
+/**
+ * View and edit auditors.
+ *
+ * @author Florian Dold
+ */
+
+
+import { amountToPretty, getTalerStampDate } from "../../helpers";
+import {
+ ExchangeRecord,
+ ExchangeForCurrencyRecord,
+ DenominationRecord,
+ AuditorRecord,
+ CurrencyRecord,
+ ReserveRecord,
+ CoinRecord,
+ PreCoinRecord,
+ Denomination,
+ WalletBalance,
+} from "../../types";
+
+import { ImplicitStateComponent, StateHolder } from "../components";
+import {
+ getCurrencies,
+ updateCurrency,
+ getPaybackReserves,
+ withdrawPaybackReserve,
+} from "../wxApi";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+class Payback extends ImplicitStateComponent<any> {
+ reserves: StateHolder<ReserveRecord[]|null> = this.makeState(null);
+ constructor() {
+ super();
+ let port = chrome.runtime.connect();
+ port.onMessage.addListener((msg: any) => {
+ if (msg.notify) {
+ console.log("got notified");
+ this.update();
+ }
+ });
+ this.update();
+ }
+
+ async update() {
+ let reserves = await getPaybackReserves();
+ this.reserves(reserves);
+ }
+
+ withdrawPayback(pub: string) {
+ withdrawPaybackReserve(pub);
+ }
+
+ render(): JSX.Element {
+ let reserves = this.reserves();
+ if (!reserves) {
+ return <span>loading ...</span>;
+ }
+ if (reserves.length == 0) {
+ return <span>No reserves with payback available.</span>;
+ }
+ return (
+ <div>
+ {reserves.map(r => (
+ <div>
+ <h2>Reserve for ${amountToPretty(r.current_amount!)}</h2>
+ <ul>
+ <li>Exchange: ${r.exchange_base_url}</li>
+ </ul>
+ <button onClick={() => this.withdrawPayback(r.reserve_pub)}>Withdraw again</button>
+ </div>
+ ))}
+ </div>
+ );
+ }
+}
+
+export function main() {
+ ReactDOM.render(<Payback />, document.getElementById("container")!);
+}
+
+document.addEventListener("DOMContentLoaded", main);
diff --git a/src/webex/pages/popup.css b/src/webex/pages/popup.css
new file mode 100644
index 000000000..675412c11
--- /dev/null
+++ b/src/webex/pages/popup.css
@@ -0,0 +1,84 @@
+
+/**
+ * @author Gabor X. Toth
+ * @author Marcello Stanisci
+ * @author Florian Dold
+ */
+
+body {
+ min-height: 20em;
+ width: 30em;
+ margin: 0;
+ padding: 0;
+ max-height: 800px;
+ overflow: hidden;
+}
+
+.nav {
+ background-color: #ddd;
+ padding: 0.5em 0;
+}
+
+.nav a {
+ color: black;
+ padding: 0.5em;
+ text-decoration: none;
+}
+
+.nav a.active {
+ background-color: white;
+ font-weight: bold;
+}
+
+
+.container {
+ overflow-y: scroll;
+ max-height: 400px;
+}
+
+.abbrev {
+ text-decoration-style: dotted;
+}
+
+#content {
+ padding: 1em;
+}
+
+
+#wallet-table .amount {
+ text-align: right;
+}
+
+.hidden {
+ display: none;
+}
+
+#transactions-table th,
+#transactions-table td {
+ padding: 0.2em 0.5em;
+}
+
+#reserve-create table {
+ width: 100%;
+}
+
+#reserve-create table td.label {
+ width: 5em;
+}
+
+#reserve-create table .input input[type="text"] {
+ width: 100%;
+}
+
+.historyItem {
+ border: 1px solid black;
+ border-radius: 10px;
+ padding-left: 0.5em;
+ margin: 0.5em;
+}
+
+.historyDate {
+ font-size: 90%;
+ margin: 0.3em;
+ color: slategray;
+}
diff --git a/src/webex/pages/popup.html b/src/webex/pages/popup.html
new file mode 100644
index 000000000..98f24bccc
--- /dev/null
+++ b/src/webex/pages/popup.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="utf-8">
+
+ <link rel="stylesheet" type="text/css" href="../style/wallet.css">
+ <link rel="stylesheet" type="text/css" href="popup.css">
+
+ <script src="/dist/page-common-bundle.js"></script>
+ <script src="/dist/popup-bundle.js"></script>
+</head>
+
+<body>
+ <div id="content" style="margin:0;padding:0"></div>
+</body>
+
+</html>
diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx
new file mode 100644
index 000000000..a806cfef9
--- /dev/null
+++ b/src/webex/pages/popup.tsx
@@ -0,0 +1,548 @@
+/*
+ This file is part of TALER
+ (C) 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/>
+ */
+
+
+/**
+ * Popup shown to the user when they click
+ * the Taler browser action button.
+ *
+ * @author Florian Dold
+ */
+
+
+"use strict";
+
+import {
+ AmountJson,
+ Amounts,
+ WalletBalance,
+ WalletBalanceEntry
+} from "../../types";
+import { HistoryRecord, HistoryLevel } from "../../wallet";
+import { amountToPretty } from "../../helpers";
+import * as i18n from "../../i18n";
+
+import { abbrev } from "../renderHtml";
+
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import URI = require("urijs");
+
+function onUpdateNotification(f: () => void): () => void {
+ let port = chrome.runtime.connect({name: "notifications"});
+ let listener = (msg: any, port: any) => {
+ f();
+ };
+ port.onMessage.addListener(listener);
+ return () => {
+ port.onMessage.removeListener(listener);
+ }
+}
+
+
+class Router extends React.Component<any,any> {
+ static setRoute(s: string): void {
+ window.location.hash = s;
+ }
+
+ static getRoute(): string {
+ // Omit the '#' at the beginning
+ return window.location.hash.substring(1);
+ }
+
+ static onRoute(f: any): () => void {
+ Router.routeHandlers.push(f);
+ return () => {
+ let i = Router.routeHandlers.indexOf(f);
+ this.routeHandlers = this.routeHandlers.splice(i, 1);
+ }
+ }
+
+ static routeHandlers: any[] = [];
+
+ componentWillMount() {
+ console.log("router mounted");
+ window.onhashchange = () => {
+ this.setState({});
+ for (let f of Router.routeHandlers) {
+ f();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ console.log("router unmounted");
+ }
+
+
+ render(): JSX.Element {
+ let route = window.location.hash.substring(1);
+ console.log("rendering route", route);
+ let defaultChild: React.ReactChild|null = null;
+ let foundChild: React.ReactChild|null = null;
+ React.Children.forEach(this.props.children, (child) => {
+ let childProps: any = (child as any).props;
+ if (!childProps) {
+ return;
+ }
+ if (childProps["default"]) {
+ defaultChild = child;
+ }
+ if (childProps["route"] == route) {
+ foundChild = child;
+ }
+ })
+ let child: React.ReactChild | null = foundChild || defaultChild;
+ if (!child) {
+ throw Error("unknown route");
+ }
+ Router.setRoute((child as any).props["route"]);
+ return <div>{child}</div>;
+ }
+}
+
+
+interface TabProps {
+ target: string;
+ children?: React.ReactNode;
+}
+
+function Tab(props: TabProps) {
+ let cssClass = "";
+ if (props.target == Router.getRoute()) {
+ cssClass = "active";
+ }
+ let onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
+ Router.setRoute(props.target);
+ e.preventDefault();
+ };
+ return (
+ <a onClick={onClick} href={props.target} className={cssClass}>
+ {props.children}
+ </a>
+ );
+}
+
+
+class WalletNavBar extends React.Component<any,any> {
+ cancelSubscription: any;
+
+ componentWillMount() {
+ this.cancelSubscription = Router.onRoute(() => {
+ this.setState({});
+ });
+ }
+
+ componentWillUnmount() {
+ if (this.cancelSubscription) {
+ this.cancelSubscription();
+ }
+ }
+
+ render() {
+ console.log("rendering nav bar");
+ return (
+ <div className="nav" id="header">
+ <Tab target="/balance">
+ {i18n.str`Balance`}
+ </Tab>
+ <Tab target="/history">
+ {i18n.str`History`}
+ </Tab>
+ <Tab target="/debug">
+ {i18n.str`Debug`}
+ </Tab>
+ </div>);
+ }
+}
+
+
+function ExtensionLink(props: any) {
+ let onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
+ chrome.tabs.create({
+ "url": chrome.extension.getURL(props.target)
+ });
+ e.preventDefault();
+ };
+ return (
+ <a onClick={onClick} href={props.target}>
+ {props.children}
+ </a>)
+}
+
+
+export function bigAmount(amount: AmountJson): JSX.Element {
+ let v = amount.value + amount.fraction / Amounts.fractionalBase;
+ return (
+ <span>
+ <span style={{fontSize: "300%"}}>{v}</span>
+ {" "}
+ <span>{amount.currency}</span>
+ </span>
+ );
+}
+
+class WalletBalanceView extends React.Component<any, any> {
+ balance: WalletBalance;
+ gotError = false;
+ canceler: (() => void) | undefined = undefined;
+ unmount = false;
+
+ componentWillMount() {
+ this.canceler = onUpdateNotification(() => this.updateBalance());
+ this.updateBalance();
+ }
+
+ componentWillUnmount() {
+ console.log("component WalletBalanceView will unmount");
+ if (this.canceler) {
+ this.canceler();
+ }
+ this.unmount = true;
+ }
+
+ updateBalance() {
+ chrome.runtime.sendMessage({type: "balances"}, (resp) => {
+ if (this.unmount) {
+ return;
+ }
+ if (resp.error) {
+ this.gotError = true;
+ console.error("could not retrieve balances", resp);
+ this.setState({});
+ return;
+ }
+ this.gotError = false;
+ console.log("got wallet", resp);
+ this.balance = resp;
+ this.setState({});
+ });
+ }
+
+ renderEmpty(): JSX.Element {
+ let helpLink = (
+ <ExtensionLink target="/src/pages/help/empty-wallet.html">
+ {i18n.str`help`}
+ </ExtensionLink>
+ );
+ return (
+ <div>
+ <i18n.Translate wrap="p">
+ You have no balance to show. Need some
+ {" "}<span>{helpLink}</span>{" "}
+ getting started?
+ </i18n.Translate>
+ </div>
+ );
+ }
+
+ formatPending(entry: WalletBalanceEntry): JSX.Element {
+ let incoming: JSX.Element | undefined;
+ let payment: JSX.Element | undefined;
+
+ console.log("available: ", entry.pendingIncoming ? amountToPretty(entry.available) : null);
+ console.log("incoming: ", entry.pendingIncoming ? amountToPretty(entry.pendingIncoming) : null);
+
+ if (Amounts.isNonZero(entry.pendingIncoming)) {
+ incoming = (
+ <i18n.Translate wrap="span">
+ <span style={{color: "darkgreen"}}>
+ {"+"}
+ {amountToPretty(entry.pendingIncoming)}
+ </span>
+ {" "}
+ incoming
+ </i18n.Translate>
+ );
+ }
+
+ if (Amounts.isNonZero(entry.pendingPayment)) {
+ payment = (
+ <i18n.Translate wrap="span">
+ <span style={{color: "darkblue"}}>
+ {amountToPretty(entry.pendingPayment)}
+ </span>
+ {" "}
+ being spent
+ </i18n.Translate>
+ );
+ }
+
+ let l = [incoming, payment].filter((x) => x !== undefined);
+ if (l.length == 0) {
+ return <span />;
+ }
+
+ if (l.length == 1) {
+ return <span>({l})</span>
+ }
+ return <span>({l[0]}, {l[1]})</span>;
+
+ }
+
+ render(): JSX.Element {
+ let wallet = this.balance;
+ if (this.gotError) {
+ return i18n.str`Error: could not retrieve balance information.`;
+ }
+ if (!wallet) {
+ return <span></span>;
+ }
+ console.log(wallet);
+ let paybackAvailable = false;
+ let listing = Object.keys(wallet).map((key) => {
+ let entry: WalletBalanceEntry = wallet[key];
+ if (entry.paybackAmount.value != 0 || entry.paybackAmount.fraction != 0) {
+ paybackAvailable = true;
+ }
+ return (
+ <p>
+ {bigAmount(entry.available)}
+ {" "}
+ {this.formatPending(entry)}
+ </p>
+ );
+ });
+ let link = chrome.extension.getURL("/src/pages/auditors.html");
+ let linkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>;
+ let paybackLink = chrome.extension.getURL("/src/pages/payback.html");
+ let paybackLinkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>;
+ return (
+ <div>
+ {listing.length > 0 ? listing : this.renderEmpty()}
+ {paybackAvailable && paybackLinkElem}
+ {linkElem}
+ </div>
+ );
+ }
+}
+
+
+function formatHistoryItem(historyItem: HistoryRecord) {
+ const d = historyItem.detail;
+ const t = historyItem.timestamp;
+ console.log("hist item", historyItem);
+ switch (historyItem.type) {
+ case "create-reserve":
+ return (
+ <i18n.Translate wrap="p">
+ Bank requested reserve (<span>{abbrev(d.reservePub)}</span>) for <span>{amountToPretty(d.requestedAmount)}</span>.
+ </i18n.Translate>
+ );
+ case "confirm-reserve": {
+ // FIXME: eventually remove compat fix
+ let exchange = d.exchangeBaseUrl ? (new URI(d.exchangeBaseUrl)).host() : "??";
+ let pub = abbrev(d.reservePub);
+ return (
+ <i18n.Translate wrap="p">
+ Started to withdraw
+ {" "}{amountToPretty(d.requestedAmount)}{" "}
+ from <span>{exchange}</span> (<span>{pub}</span>).
+ </i18n.Translate>
+ );
+ }
+ case "offer-contract": {
+ let link = chrome.extension.getURL("view-contract.html");
+ let linkElem = <a href={link}>{abbrev(d.contractHash)}</a>;
+ let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>;
+ return (
+ <i18n.Translate wrap="p">
+ Merchant <em>{abbrev(d.merchantName, 15)}</em> offered contract <a href={link}>{abbrev(d.contractHash)}</a>;
+ </i18n.Translate>
+ );
+ }
+ case "depleted-reserve": {
+ let exchange = d.exchangeBaseUrl ? (new URI(d.exchangeBaseUrl)).host() : "??";
+ let amount = amountToPretty(d.requestedAmount);
+ let pub = abbrev(d.reservePub);
+ return (
+ <i18n.Translate wrap="p">
+ Withdrew <span>{amount}</span> from <span>{exchange}</span> (<span>{pub}</span>).
+ </i18n.Translate>
+ );
+ }
+ case "pay": {
+ let url = d.fulfillmentUrl;
+ let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>;
+ let fulfillmentLinkElem = <a href={url} onClick={openTab(url)}>view product</a>;
+ return (
+ <i18n.Translate wrap="p">
+ Paid <span>{amountToPretty(d.amount)}</span> to merchant <span>{merchantElem}</span>. (<span>{fulfillmentLinkElem}</span>)
+ </i18n.Translate>
+ );
+ }
+ default:
+ return (<p>{i18n.str`Unknown event (${historyItem.type})`}</p>);
+ }
+}
+
+
+class WalletHistory extends React.Component<any, any> {
+ myHistory: any[];
+ gotError = false;
+ unmounted = false;
+
+ componentWillMount() {
+ this.update();
+ onUpdateNotification(() => this.update());
+ }
+
+ componentWillUnmount() {
+ console.log("history component unmounted");
+ this.unmounted = true;
+ }
+
+ update() {
+ chrome.runtime.sendMessage({type: "get-history"}, (resp) => {
+ if (this.unmounted) {
+ return;
+ }
+ console.log("got history response");
+ if (resp.error) {
+ this.gotError = true;
+ console.error("could not retrieve history", resp);
+ this.setState({});
+ return;
+ }
+ this.gotError = false;
+ console.log("got history", resp.history);
+ this.myHistory = resp.history;
+ this.setState({});
+ });
+ }
+
+ render(): JSX.Element {
+ console.log("rendering history");
+ let history: HistoryRecord[] = this.myHistory;
+ if (this.gotError) {
+ return i18n.str`Error: could not retrieve event history`;
+ }
+
+ if (!history) {
+ // We're not ready yet
+ return <span />;
+ }
+
+ let subjectMemo: {[s: string]: boolean} = {};
+ let listing: any[] = [];
+ for (let record of history.reverse()) {
+ if (record.subjectId && subjectMemo[record.subjectId]) {
+ continue;
+ }
+ if (record.level != undefined && record.level < HistoryLevel.User) {
+ continue;
+ }
+ subjectMemo[record.subjectId as string] = true;
+
+ let item = (
+ <div className="historyItem">
+ <div className="historyDate">
+ {(new Date(record.timestamp)).toString()}
+ </div>
+ {formatHistoryItem(record)}
+ </div>
+ );
+
+ listing.push(item);
+ }
+
+ if (listing.length > 0) {
+ return <div className="container">{listing}</div>;
+ }
+ return <p>{i18n.str`Your wallet has no events recorded.`}</p>
+ }
+
+}
+
+
+function reload() {
+ try {
+ chrome.runtime.reload();
+ window.close();
+ } catch (e) {
+ // Functionality missing in firefox, ignore!
+ }
+}
+
+function confirmReset() {
+ if (confirm("Do you want to IRREVOCABLY DESTROY everything inside your" +
+ " wallet and LOSE ALL YOUR COINS?")) {
+ chrome.runtime.sendMessage({type: "reset"});
+ window.close();
+ }
+}
+
+
+function WalletDebug(props: any) {
+ return (<div>
+ <p>Debug tools:</p>
+ <button onClick={openExtensionPage("/src/pages/popup.html")}>
+ wallet tab
+ </button>
+ <button onClick={openExtensionPage("/src/pages/show-db.html")}>
+ show db
+ </button>
+ <button onClick={openExtensionPage("/src/pages/tree.html")}>
+ show tree
+ </button>
+ <button onClick={openExtensionPage("/src/pages/logs.html")}>
+ show logs
+ </button>
+ <br />
+ <button onClick={confirmReset}>
+ reset
+ </button>
+ <button onClick={reload}>
+ reload chrome extension
+ </button>
+ </div>);
+}
+
+
+function openExtensionPage(page: string) {
+ return function() {
+ chrome.tabs.create({
+ "url": chrome.extension.getURL(page)
+ });
+ }
+}
+
+
+function openTab(page: string) {
+ return function() {
+ chrome.tabs.create({
+ "url": page
+ });
+ }
+}
+
+
+let el = (
+ <div>
+ <WalletNavBar />
+ <div style={{margin: "1em"}}>
+ <Router>
+ <WalletBalanceView route="/balance" default/>
+ <WalletHistory route="/history"/>
+ <WalletDebug route="/debug"/>
+ </Router>
+ </div>
+ </div>
+);
+
+document.addEventListener("DOMContentLoaded", () => {
+ ReactDOM.render(el, document.getElementById("content")!);
+})
diff --git a/src/webex/pages/show-db.html b/src/webex/pages/show-db.html
new file mode 100644
index 000000000..215c726d9
--- /dev/null
+++ b/src/webex/pages/show-db.html
@@ -0,0 +1,18 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Reserve Created</title>
+ <link rel="stylesheet" type="text/css" href="../style/wallet.css">
+ <link rel="icon" href="/img/icon.png">
+ <script src="/dist/page-common.js"></script>
+ <script src="/dist/show-db-bundle.js"></script>
+ </head>
+ <body>
+ <h1>DB Dump</h1>
+ <input type="file" id="fileInput" style="display:none">
+ <button id="import">Import Dump</button>
+ <button id="download">Download Dump</button>
+ <pre id="dump"></pre>
+ </body>
+</html>
diff --git a/src/webex/pages/show-db.ts b/src/webex/pages/show-db.ts
new file mode 100644
index 000000000..d95951385
--- /dev/null
+++ b/src/webex/pages/show-db.ts
@@ -0,0 +1,94 @@
+/*
+ This file is part of TALER
+ (C) 2015 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/>
+ */
+
+
+/**
+ * Wallet database dump for debugging.
+ *
+ * @author Florian Dold
+ */
+
+function replacer(match: string, pIndent: string, pKey: string, pVal: string,
+ pEnd: string) {
+ const key = "<span class=json-key>";
+ const val = "<span class=json-value>";
+ const str = "<span class=json-string>";
+ let r = pIndent || "";
+ if (pKey) {
+ r = r + key + '"' + pKey.replace(/[": ]/g, "") + '":</span> ';
+ }
+ if (pVal) {
+ r = r + (pVal[0] === '"' ? str : val) + pVal + "</span>";
+ }
+ return r + (pEnd || "");
+}
+
+
+function prettyPrint(obj: any) {
+ const jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg;
+ return JSON.stringify(obj, null as any, 3)
+ .replace(/&/g, "&amp;").replace(/\\"/g, "&quot;")
+ .replace(/</g, "&lt;").replace(/>/g, "&gt;")
+ .replace(jsonLine, replacer);
+}
+
+
+document.addEventListener("DOMContentLoaded", () => {
+ chrome.runtime.sendMessage({type: "dump-db"}, (resp) => {
+ const el = document.getElementById("dump");
+ if (!el) {
+ throw Error();
+ }
+ el.innerHTML = prettyPrint(resp);
+
+ document.getElementById("download")!.addEventListener("click", (evt) => {
+ console.log("creating download");
+ const element = document.createElement("a");
+ element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(JSON.stringify(resp)));
+ element.setAttribute("download", "wallet-dump.txt");
+ element.style.display = "none";
+ document.body.appendChild(element);
+ element.click();
+ });
+
+ });
+
+
+ const fileInput = document.getElementById("fileInput")! as HTMLInputElement;
+ fileInput.onchange = (evt) => {
+ if (!fileInput.files || fileInput.files.length !== 1) {
+ alert("please select exactly one file to import");
+ return;
+ }
+ const file = fileInput.files[0];
+ const fr = new FileReader();
+ fr.onload = (e: any) => {
+ console.log("got file");
+ const dump = JSON.parse(e.target.result);
+ console.log("parsed contents", dump);
+ chrome.runtime.sendMessage({ type: "import-db", detail: { dump } }, (resp) => {
+ alert("loaded");
+ });
+ };
+ console.log("reading file", file);
+ fr.readAsText(file);
+ };
+
+ document.getElementById("import")!.addEventListener("click", (evt) => {
+ fileInput.click();
+ evt.preventDefault();
+ });
+});
diff --git a/src/webex/pages/tree.html b/src/webex/pages/tree.html
new file mode 100644
index 000000000..0c0a368b3
--- /dev/null
+++ b/src/webex/pages/tree.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <title>Taler Wallet: Tree View</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/tree-bundle.js"></script>
+
+ <style>
+ .tree-item {
+ margin: 2em;
+ border-radius: 5px;
+ border: 1px solid gray;
+ padding: 1em;
+ }
+ </style>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/webex/pages/tree.tsx b/src/webex/pages/tree.tsx
new file mode 100644
index 000000000..ddf8f2dbc
--- /dev/null
+++ b/src/webex/pages/tree.tsx
@@ -0,0 +1,437 @@
+/*
+ This file is part of TALER
+ (C) 2016 Inria
+
+ 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/>
+ */
+
+/**
+ * Show contents of the wallet as a tree.
+ *
+ * @author Florian Dold
+ */
+
+
+import { amountToPretty, getTalerStampDate } from "../../helpers";
+import {
+ CoinRecord,
+ CoinStatus,
+ Denomination,
+ DenominationRecord,
+ ExchangeRecord,
+ PreCoinRecord,
+ ReserveRecord,
+} from "../../types";
+
+import { ImplicitStateComponent, StateHolder } from "../components";
+import {
+ getReserves, getExchanges, getCoins, getPreCoins,
+ refresh, getDenoms, payback,
+} from "../wxApi";
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+interface ReserveViewProps {
+ reserve: ReserveRecord;
+}
+
+class ReserveView extends React.Component<ReserveViewProps, void> {
+ render(): JSX.Element {
+ let r: ReserveRecord = this.props.reserve;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Key: {r.reserve_pub}</li>
+ <li>Created: {(new Date(r.created * 1000).toString())}</li>
+ <li>Current: {r.current_amount ? amountToPretty(r.current_amount!) : "null"}</li>
+ <li>Requested: {amountToPretty(r.requested_amount)}</li>
+ <li>Confirmed: {r.confirmed}</li>
+ </ul>
+ </div>
+ );
+ }
+}
+
+interface ReserveListProps {
+ exchangeBaseUrl: string;
+}
+
+interface ToggleProps {
+ expanded: StateHolder<boolean>;
+}
+
+class Toggle extends ImplicitStateComponent<ToggleProps> {
+ renderButton() {
+ let show = () => {
+ this.props.expanded(true);
+ this.setState({});
+ };
+ let hide = () => {
+ this.props.expanded(false);
+ this.setState({});
+ };
+ if (this.props.expanded()) {
+ return <button onClick={hide}>hide</button>;
+ }
+ return <button onClick={show}>show</button>;
+
+ }
+ render() {
+ return (
+ <div style={{display: "inline"}}>
+ {this.renderButton()}
+ {this.props.expanded() ? this.props.children : []}
+ </div>);
+ }
+}
+
+
+interface CoinViewProps {
+ coin: CoinRecord;
+}
+
+interface RefreshDialogProps {
+ coin: CoinRecord;
+}
+
+class RefreshDialog extends ImplicitStateComponent<RefreshDialogProps> {
+ refreshRequested = this.makeState<boolean>(false);
+ render(): JSX.Element {
+ if (!this.refreshRequested()) {
+ return (
+ <div style={{display: "inline"}}>
+ <button onClick={() => this.refreshRequested(true)}>refresh</button>
+ </div>
+ );
+ }
+ return (
+ <div>
+ Refresh amount: <input type="text" size={10} />
+ <button onClick={() => refresh(this.props.coin.coinPub)}>ok</button>
+ <button onClick={() => this.refreshRequested(false)}>cancel</button>
+ </div>
+ );
+ }
+}
+
+class CoinView extends React.Component<CoinViewProps, void> {
+ render() {
+ let c = this.props.coin;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Key: {c.coinPub}</li>
+ <li>Current amount: {amountToPretty(c.currentAmount)}</li>
+ <li>Denomination: <ExpanderText text={c.denomPub} /></li>
+ <li>Suspended: {(c.suspended || false).toString()}</li>
+ <li>Status: {CoinStatus[c.status]}</li>
+ <li><RefreshDialog coin={c} /></li>
+ <li><button onClick={() => payback(c.coinPub)}>Payback</button></li>
+ </ul>
+ </div>
+ );
+ }
+}
+
+
+
+interface PreCoinViewProps {
+ precoin: PreCoinRecord;
+}
+
+class PreCoinView extends React.Component<PreCoinViewProps, void> {
+ render() {
+ let c = this.props.precoin;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Key: {c.coinPub}</li>
+ </ul>
+ </div>
+ );
+ }
+}
+
+interface CoinListProps {
+ exchangeBaseUrl: string;
+}
+
+class CoinList extends ImplicitStateComponent<CoinListProps> {
+ coins = this.makeState<CoinRecord[] | null>(null);
+ expanded = this.makeState<boolean>(false);
+
+ constructor(props: CoinListProps) {
+ super(props);
+ this.update(props);
+ }
+
+ async update(props: CoinListProps) {
+ let coins = await getCoins(props.exchangeBaseUrl);
+ this.coins(coins);
+ }
+
+ componentWillReceiveProps(newProps: CoinListProps) {
+ this.update(newProps);
+ }
+
+ render(): JSX.Element {
+ if (!this.coins()) {
+ return <div>...</div>;
+ }
+ return (
+ <div className="tree-item">
+ Coins ({this.coins() !.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.coins() !.map((c) => <CoinView coin={c} />)}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+
+interface PreCoinListProps {
+ exchangeBaseUrl: string;
+}
+
+class PreCoinList extends ImplicitStateComponent<PreCoinListProps> {
+ precoins = this.makeState<PreCoinRecord[] | null>(null);
+ expanded = this.makeState<boolean>(false);
+
+ constructor(props: PreCoinListProps) {
+ super(props);
+ this.update();
+ }
+
+ async update() {
+ let precoins = await getPreCoins(this.props.exchangeBaseUrl);
+ this.precoins(precoins);
+ }
+
+ render(): JSX.Element {
+ if (!this.precoins()) {
+ return <div>...</div>;
+ }
+ return (
+ <div className="tree-item">
+ Planchets ({this.precoins() !.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.precoins() !.map((c) => <PreCoinView precoin={c} />)}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+interface DenominationListProps {
+ exchange: ExchangeRecord;
+}
+
+interface ExpanderTextProps {
+ text: string;
+}
+
+class ExpanderText extends ImplicitStateComponent<ExpanderTextProps> {
+ expanded = this.makeState<boolean>(false);
+ textArea: any = undefined;
+
+ componentDidUpdate() {
+ if (this.expanded() && this.textArea) {
+ this.textArea.focus();
+ this.textArea.scrollTop = 0;
+ }
+ }
+
+ render(): JSX.Element {
+ if (!this.expanded()) {
+ return (
+ <span onClick={() => { this.expanded(true); }}>
+ {(this.props.text.length <= 10)
+ ? this.props.text
+ : (
+ <span>
+ {this.props.text.substring(0,10)}
+ <span style={{textDecoration: "underline"}}>...</span>
+ </span>
+ )
+ }
+ </span>
+ );
+ }
+ return (
+ <textarea
+ readOnly
+ style={{display: "block"}}
+ onBlur={() => this.expanded(false)}
+ ref={(e) => this.textArea = e}>
+ {this.props.text}
+ </textarea>
+ );
+ }
+}
+
+class DenominationList extends ImplicitStateComponent<DenominationListProps> {
+ expanded = this.makeState<boolean>(false);
+ denoms = this.makeState<undefined|DenominationRecord[]>(undefined);
+
+ constructor(props: DenominationListProps) {
+ super(props);
+ this.update();
+ }
+
+ async update() {
+ let d = await getDenoms(this.props.exchange.baseUrl);
+ this.denoms(d);
+ }
+
+ renderDenom(d: DenominationRecord) {
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Offered: {d.isOffered ? "yes" : "no"}</li>
+ <li>Value: {amountToPretty(d.value)}</li>
+ <li>Withdraw fee: {amountToPretty(d.feeWithdraw)}</li>
+ <li>Refresh fee: {amountToPretty(d.feeRefresh)}</li>
+ <li>Deposit fee: {amountToPretty(d.feeDeposit)}</li>
+ <li>Refund fee: {amountToPretty(d.feeRefund)}</li>
+ <li>Start: {getTalerStampDate(d.stampStart)!.toString()}</li>
+ <li>Withdraw expiration: {getTalerStampDate(d.stampExpireWithdraw)!.toString()}</li>
+ <li>Legal expiration: {getTalerStampDate(d.stampExpireLegal)!.toString()}</li>
+ <li>Deposit expiration: {getTalerStampDate(d.stampExpireDeposit)!.toString()}</li>
+ <li>Denom pub: <ExpanderText text={d.denomPub} /></li>
+ </ul>
+ </div>
+ );
+ }
+
+ render(): JSX.Element {
+ let denoms = this.denoms()
+ if (!denoms) {
+ return (
+ <div className="tree-item">
+ Denominations (...)
+ {" "}
+ <Toggle expanded={this.expanded}>
+ ...
+ </Toggle>
+ </div>
+ );
+ }
+ return (
+ <div className="tree-item">
+ Denominations ({denoms.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {denoms.map((d) => this.renderDenom(d))}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+class ReserveList extends ImplicitStateComponent<ReserveListProps> {
+ reserves = this.makeState<ReserveRecord[] | null>(null);
+ expanded = this.makeState<boolean>(false);
+
+ constructor(props: ReserveListProps) {
+ super(props);
+ this.update();
+ }
+
+ async update() {
+ let reserves = await getReserves(this.props.exchangeBaseUrl);
+ this.reserves(reserves);
+ }
+
+ render(): JSX.Element {
+ if (!this.reserves()) {
+ return <div>...</div>;
+ }
+ return (
+ <div className="tree-item">
+ Reserves ({this.reserves() !.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.reserves() !.map((r) => <ReserveView reserve={r} />)}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+interface ExchangeProps {
+ exchange: ExchangeRecord;
+}
+
+class ExchangeView extends React.Component<ExchangeProps, void> {
+ render(): JSX.Element {
+ let e = this.props.exchange;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Exchange Base Url: {this.props.exchange.baseUrl}</li>
+ <li>Master public key: <ExpanderText text={this.props.exchange.masterPublicKey} /></li>
+ </ul>
+ <DenominationList exchange={e} />
+ <ReserveList exchangeBaseUrl={this.props.exchange.baseUrl} />
+ <CoinList exchangeBaseUrl={this.props.exchange.baseUrl} />
+ <PreCoinList exchangeBaseUrl={this.props.exchange.baseUrl} />
+ </div>
+ );
+ }
+}
+
+interface ExchangesListState {
+ exchanges?: ExchangeRecord[];
+}
+
+class ExchangesList extends React.Component<any, ExchangesListState> {
+ constructor() {
+ super();
+ let port = chrome.runtime.connect();
+ port.onMessage.addListener((msg: any) => {
+ if (msg.notify) {
+ console.log("got notified");
+ this.update();
+ }
+ });
+ this.update();
+ this.state = {} as any;
+ }
+
+ async update() {
+ let exchanges = await getExchanges();
+ console.log("exchanges: ", exchanges);
+ this.setState({ exchanges });
+ }
+
+ render(): JSX.Element {
+ let exchanges = this.state.exchanges;
+ if (!exchanges) {
+ return <span>...</span>;
+ }
+ return (
+ <div className="tree-item">
+ Exchanges ({exchanges.length.toString()}):
+ {exchanges.map(e => <ExchangeView exchange={e} />)}
+ </div>
+ );
+ }
+}
+
+export function main() {
+ ReactDOM.render(<ExchangesList />, document.getElementById("container")!);
+}
+
+document.addEventListener("DOMContentLoaded", main);
diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx
new file mode 100644
index 000000000..440cd5789
--- /dev/null
+++ b/src/webex/renderHtml.tsx
@@ -0,0 +1,79 @@
+/*
+ This file is part of TALER
+ (C) 2016 INRIA
+
+ 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/>
+ */
+
+/**
+ * Helpers functions to render Taler-related data structures to HTML.
+ *
+ * @author Florian Dold
+ */
+
+
+/**
+ * Imports.
+ */
+import {
+ AmountJson,
+ Amounts,
+ Contract,
+} from "../types";
+import * as i18n from "../i18n";
+import { amountToPretty } from "../helpers";
+
+import * as React from "react";
+
+
+export function renderContract(contract: Contract): JSX.Element {
+ let merchantName;
+ if (contract.merchant && contract.merchant.name) {
+ merchantName = <strong>{contract.merchant.name}</strong>;
+ } else {
+ merchantName = <strong>(pub: {contract.merchant_pub})</strong>;
+ }
+ let amount = <strong>{amountToPretty(contract.amount)}</strong>;
+
+ return (
+ <div>
+ <i18n.Translate wrap="p">
+ The merchant <span>{merchantName}</span>
+ wants to enter a contract over <span>{amount}</span>{" "}
+ with you.
+ </i18n.Translate>
+ <p>{i18n.str`You are about to purchase:`}</p>
+ <ul>
+ {contract.products.map(
+ (p: any, i: number) => (<li key={i}>{`${p.description}: ${amountToPretty(p.price)}`}</li>))
+ }
+ </ul>
+ </div>
+ );
+}
+
+
+/**
+ * Abbreviate a string to a given length, and show the full
+ * string on hover as a tooltip.
+ */
+export function abbrev(s: string, n: number = 5) {
+ let sAbbrev = s;
+ if (s.length > n) {
+ sAbbrev = s.slice(0, n) + "..";
+ }
+ return (
+ <span className="abbrev" title={s}>
+ {sAbbrev}
+ </span>
+ );
+}
diff --git a/src/webex/style/pure.css b/src/webex/style/pure.css
new file mode 100644
index 000000000..739113970
--- /dev/null
+++ b/src/webex/style/pure.css
@@ -0,0 +1,1508 @@
+/*!
+Pure v0.6.2
+Copyright 2013 Yahoo!
+Licensed under the BSD License.
+https://github.com/yahoo/pure/blob/master/LICENSE.md
+*/
+/*!
+normalize.css v^3.0 | MIT License | git.io/normalize
+Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
+
+/**
+ * 1. Set default font family to sans-serif.
+ * 2. Prevent iOS and IE text size adjust after device orientation change,
+ * without disabling user zoom.
+ */
+
+html {
+ font-family: sans-serif; /* 1 */
+ -ms-text-size-adjust: 100%; /* 2 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/**
+ * Remove default margin.
+ */
+
+body {
+ margin: 0;
+}
+
+/* HTML5 display definitions
+ ========================================================================== */
+
+/**
+ * Correct `block` display not defined for any HTML5 element in IE 8/9.
+ * Correct `block` display not defined for `details` or `summary` in IE 10/11
+ * and Firefox.
+ * Correct `block` display not defined for `main` in IE 11.
+ */
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+menu,
+nav,
+section,
+summary {
+ display: block;
+}
+
+/**
+ * 1. Correct `inline-block` display not defined in IE 8/9.
+ * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
+ */
+
+audio,
+canvas,
+progress,
+video {
+ display: inline-block; /* 1 */
+ vertical-align: baseline; /* 2 */
+}
+
+/**
+ * Prevent modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Address `[hidden]` styling not present in IE 8/9/10.
+ * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.
+ */
+
+[hidden],
+template {
+ display: none;
+}
+
+/* Links
+ ========================================================================== */
+
+/**
+ * Remove the gray background color from active links in IE 10.
+ */
+
+a {
+ background-color: transparent;
+}
+
+/**
+ * Improve readability of focused elements when they are also in an
+ * active/hover state.
+ */
+
+a:active,
+a:hover {
+ outline: 0;
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
+ */
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/**
+ * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
+ */
+
+b,
+strong {
+ font-weight: bold;
+}
+
+/**
+ * Address styling not present in Safari and Chrome.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Address variable `h1` font-size and margin within `section` and `article`
+ * contexts in Firefox 4+, Safari, and Chrome.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+/**
+ * Address inconsistent and variable font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove border when inside `a` element in IE 8/9/10.
+ */
+
+img {
+ border: 0;
+}
+
+/**
+ * Correct overflow not hidden in IE 9/10/11.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * Address margin not present in IE 8/9 and Safari.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * Address differences between Firefox and other browsers.
+ */
+
+hr {
+ box-sizing: content-box;
+ height: 0;
+}
+
+/**
+ * Contain overflow in all browsers.
+ */
+
+pre {
+ overflow: auto;
+}
+
+/**
+ * Address odd `em`-unit font size rendering in all browsers.
+ */
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * Known limitation: by default, Chrome and Safari on OS X allow very limited
+ * styling of `select`, unless a `border` property is set.
+ */
+
+/**
+ * 1. Correct color not being inherited.
+ * Known issue: affects color of disabled elements.
+ * 2. Correct font properties not being inherited.
+ * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ color: inherit; /* 1 */
+ font: inherit; /* 2 */
+ margin: 0; /* 3 */
+}
+
+/**
+ * Address `overflow` set to `hidden` in IE 8/9/10/11.
+ */
+
+button {
+ overflow: visible;
+}
+
+/**
+ * Address inconsistent `text-transform` inheritance for `button` and `select`.
+ * All other form control elements do not inherit `text-transform` values.
+ * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
+ * Correct `select` style inheritance in Firefox.
+ */
+
+button,
+select {
+ text-transform: none;
+}
+
+/**
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Correct inability to style clickable `input` types in iOS.
+ * 3. Improve usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+
+button,
+html input[type="button"], /* 1 */
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button; /* 2 */
+ cursor: pointer; /* 3 */
+}
+
+/**
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled],
+html input[disabled] {
+ cursor: default;
+}
+
+/**
+ * Remove inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+/**
+ * Address Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+input {
+ line-height: normal;
+}
+
+/**
+ * It's recommended that you don't attempt to style these elements.
+ * Firefox's implementation doesn't respect box-sizing, padding, or width.
+ *
+ * 1. Address box sizing set to `content-box` in IE 8/9/10.
+ * 2. Remove excess padding in IE 8/9/10.
+ */
+
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Fix the cursor style for Chrome's increment/decrement buttons. For certain
+ * `font-size` values of the `input`, it causes the cursor style of the
+ * decrement button to change from `default` to `text`.
+ */
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
+ * 2. Address `box-sizing` set to `border-box` in Safari and Chrome.
+ */
+
+input[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ box-sizing: content-box; /* 2 */
+}
+
+/**
+ * Remove inner padding and search cancel button in Safari and Chrome on OS X.
+ * Safari (but not Chrome) clips the cancel button when the search input has
+ * padding (and `textfield` appearance).
+ */
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct `color` not being inherited in IE 8/9/10/11.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+ border: 0; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Remove default vertical scrollbar in IE 8/9/10/11.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * Don't inherit the `font-weight` (applied by a rule above).
+ * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
+ */
+
+optgroup {
+ font-weight: bold;
+}
+
+/* Tables
+ ========================================================================== */
+
+/**
+ * Remove most spacing between table cells.
+ */
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+td,
+th {
+ padding: 0;
+}
+
+/*csslint important:false*/
+
+/* ==========================================================================
+ Pure Base Extras
+ ========================================================================== */
+
+/**
+ * Extra rules that Pure adds on top of Normalize.css
+ */
+
+/**
+ * Always hide an element when it has the `hidden` HTML attribute.
+ */
+
+.hidden,
+[hidden] {
+ display: none !important;
+}
+
+/**
+ * Add this class to an image to make it fit within it's fluid parent wrapper while maintaining
+ * aspect ratio.
+ */
+.pure-img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+}
+
+/*csslint regex-selectors:false, known-properties:false, duplicate-properties:false*/
+
+.pure-g {
+ letter-spacing: -0.31em; /* Webkit: collapse white-space between units */
+ *letter-spacing: normal; /* reset IE < 8 */
+ *word-spacing: -0.43em; /* IE < 8: collapse white-space between units */
+ text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */
+
+ /*
+ Sets the font stack to fonts known to work properly with the above letter
+ and word spacings. See: https://github.com/yahoo/pure/issues/41/
+
+ The following font stack makes Pure Grids work on all known environments.
+
+ * FreeSans: Ships with many Linux distros, including Ubuntu
+
+ * Arimo: Ships with Chrome OS. Arimo has to be defined before Helvetica and
+ Arial to get picked up by the browser, even though neither is available
+ in Chrome OS.
+
+ * Droid Sans: Ships with all versions of Android.
+
+ * Helvetica, Arial, sans-serif: Common font stack on OS X and Windows.
+ */
+ font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif;
+
+ /* Use flexbox when possible to avoid `letter-spacing` side-effects. */
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-flow: row wrap;
+ -ms-flex-flow: row wrap;
+ flex-flow: row wrap;
+
+ /* Prevents distributing space between rows */
+ -webkit-align-content: flex-start;
+ -ms-flex-line-pack: start;
+ align-content: flex-start;
+}
+
+/* IE10 display: -ms-flexbox (and display: flex in IE 11) does not work inside a table; fall back to block and rely on font hack */
+@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
+ table .pure-g {
+ display: block;
+ }
+}
+
+/* Opera as of 12 on Windows needs word-spacing.
+ The ".opera-only" selector is used to prevent actual prefocus styling
+ and is not required in markup.
+*/
+.opera-only :-o-prefocus,
+.pure-g {
+ word-spacing: -0.43em;
+}
+
+.pure-u {
+ display: inline-block;
+ *display: inline; /* IE < 8: fake inline-block */
+ zoom: 1;
+ letter-spacing: normal;
+ word-spacing: normal;
+ vertical-align: top;
+ text-rendering: auto;
+}
+
+/*
+Resets the font family back to the OS/browser's default sans-serif font,
+this the same font stack that Normalize.css sets for the `body`.
+*/
+.pure-g [class *= "pure-u"] {
+ font-family: sans-serif;
+}
+
+.pure-u-1,
+.pure-u-1-1,
+.pure-u-1-2,
+.pure-u-1-3,
+.pure-u-2-3,
+.pure-u-1-4,
+.pure-u-3-4,
+.pure-u-1-5,
+.pure-u-2-5,
+.pure-u-3-5,
+.pure-u-4-5,
+.pure-u-5-5,
+.pure-u-1-6,
+.pure-u-5-6,
+.pure-u-1-8,
+.pure-u-3-8,
+.pure-u-5-8,
+.pure-u-7-8,
+.pure-u-1-12,
+.pure-u-5-12,
+.pure-u-7-12,
+.pure-u-11-12,
+.pure-u-1-24,
+.pure-u-2-24,
+.pure-u-3-24,
+.pure-u-4-24,
+.pure-u-5-24,
+.pure-u-6-24,
+.pure-u-7-24,
+.pure-u-8-24,
+.pure-u-9-24,
+.pure-u-10-24,
+.pure-u-11-24,
+.pure-u-12-24,
+.pure-u-13-24,
+.pure-u-14-24,
+.pure-u-15-24,
+.pure-u-16-24,
+.pure-u-17-24,
+.pure-u-18-24,
+.pure-u-19-24,
+.pure-u-20-24,
+.pure-u-21-24,
+.pure-u-22-24,
+.pure-u-23-24,
+.pure-u-24-24 {
+ display: inline-block;
+ *display: inline;
+ zoom: 1;
+ letter-spacing: normal;
+ word-spacing: normal;
+ vertical-align: top;
+ text-rendering: auto;
+}
+
+.pure-u-1-24 {
+ width: 4.1667%;
+ *width: 4.1357%;
+}
+
+.pure-u-1-12,
+.pure-u-2-24 {
+ width: 8.3333%;
+ *width: 8.3023%;
+}
+
+.pure-u-1-8,
+.pure-u-3-24 {
+ width: 12.5000%;
+ *width: 12.4690%;
+}
+
+.pure-u-1-6,
+.pure-u-4-24 {
+ width: 16.6667%;
+ *width: 16.6357%;
+}
+
+.pure-u-1-5 {
+ width: 20%;
+ *width: 19.9690%;
+}
+
+.pure-u-5-24 {
+ width: 20.8333%;
+ *width: 20.8023%;
+}
+
+.pure-u-1-4,
+.pure-u-6-24 {
+ width: 25%;
+ *width: 24.9690%;
+}
+
+.pure-u-7-24 {
+ width: 29.1667%;
+ *width: 29.1357%;
+}
+
+.pure-u-1-3,
+.pure-u-8-24 {
+ width: 33.3333%;
+ *width: 33.3023%;
+}
+
+.pure-u-3-8,
+.pure-u-9-24 {
+ width: 37.5000%;
+ *width: 37.4690%;
+}
+
+.pure-u-2-5 {
+ width: 40%;
+ *width: 39.9690%;
+}
+
+.pure-u-5-12,
+.pure-u-10-24 {
+ width: 41.6667%;
+ *width: 41.6357%;
+}
+
+.pure-u-11-24 {
+ width: 45.8333%;
+ *width: 45.8023%;
+}
+
+.pure-u-1-2,
+.pure-u-12-24 {
+ width: 50%;
+ *width: 49.9690%;
+}
+
+.pure-u-13-24 {
+ width: 54.1667%;
+ *width: 54.1357%;
+}
+
+.pure-u-7-12,
+.pure-u-14-24 {
+ width: 58.3333%;
+ *width: 58.3023%;
+}
+
+.pure-u-3-5 {
+ width: 60%;
+ *width: 59.9690%;
+}
+
+.pure-u-5-8,
+.pure-u-15-24 {
+ width: 62.5000%;
+ *width: 62.4690%;
+}
+
+.pure-u-2-3,
+.pure-u-16-24 {
+ width: 66.6667%;
+ *width: 66.6357%;
+}
+
+.pure-u-17-24 {
+ width: 70.8333%;
+ *width: 70.8023%;
+}
+
+.pure-u-3-4,
+.pure-u-18-24 {
+ width: 75%;
+ *width: 74.9690%;
+}
+
+.pure-u-19-24 {
+ width: 79.1667%;
+ *width: 79.1357%;
+}
+
+.pure-u-4-5 {
+ width: 80%;
+ *width: 79.9690%;
+}
+
+.pure-u-5-6,
+.pure-u-20-24 {
+ width: 83.3333%;
+ *width: 83.3023%;
+}
+
+.pure-u-7-8,
+.pure-u-21-24 {
+ width: 87.5000%;
+ *width: 87.4690%;
+}
+
+.pure-u-11-12,
+.pure-u-22-24 {
+ width: 91.6667%;
+ *width: 91.6357%;
+}
+
+.pure-u-23-24 {
+ width: 95.8333%;
+ *width: 95.8023%;
+}
+
+.pure-u-1,
+.pure-u-1-1,
+.pure-u-5-5,
+.pure-u-24-24 {
+ width: 100%;
+}
+.pure-button {
+ /* Structure */
+ display: inline-block;
+ zoom: 1;
+ line-height: normal;
+ white-space: nowrap;
+ vertical-align: middle;
+ text-align: center;
+ cursor: pointer;
+ -webkit-user-drag: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ box-sizing: border-box;
+}
+
+/* Firefox: Get rid of the inner focus border */
+.pure-button::-moz-focus-inner {
+ padding: 0;
+ border: 0;
+}
+
+/* Inherit .pure-g styles */
+.pure-button-group {
+ letter-spacing: -0.31em; /* Webkit: collapse white-space between units */
+ *letter-spacing: normal; /* reset IE < 8 */
+ *word-spacing: -0.43em; /* IE < 8: collapse white-space between units */
+ text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */
+}
+
+.opera-only :-o-prefocus,
+.pure-button-group {
+ word-spacing: -0.43em;
+}
+
+.pure-button-group .pure-button {
+ letter-spacing: normal;
+ word-spacing: normal;
+ vertical-align: top;
+ text-rendering: auto;
+}
+
+/*csslint outline-none:false*/
+
+.pure-button {
+ font-family: inherit;
+ font-size: 100%;
+ padding: 0.5em 1em;
+ color: #444; /* rgba not supported (IE 8) */
+ color: rgba(0, 0, 0, 0.80); /* rgba supported */
+ border: 1px solid #999; /*IE 6/7/8*/
+ border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/
+ background-color: #E6E6E6;
+ text-decoration: none;
+ border-radius: 2px;
+}
+
+.pure-button-hover,
+.pure-button:hover,
+.pure-button:focus {
+ /* csslint ignore:start */
+ filter: alpha(opacity=90);
+ /* csslint ignore:end */
+ background-image: -webkit-linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10));
+ background-image: linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10));
+}
+.pure-button:focus {
+ outline: 0;
+}
+.pure-button-active,
+.pure-button:active {
+ box-shadow: 0 0 0 1px rgba(0,0,0, 0.15) inset, 0 0 6px rgba(0,0,0, 0.20) inset;
+ border-color: #000\9;
+}
+
+.pure-button[disabled],
+.pure-button-disabled,
+.pure-button-disabled:hover,
+.pure-button-disabled:focus,
+.pure-button-disabled:active {
+ border: none;
+ background-image: none;
+ /* csslint ignore:start */
+ filter: alpha(opacity=40);
+ /* csslint ignore:end */
+ opacity: 0.40;
+ cursor: not-allowed;
+ box-shadow: none;
+ pointer-events: none;
+}
+
+.pure-button-hidden {
+ display: none;
+}
+
+.pure-button-primary,
+.pure-button-selected,
+a.pure-button-primary,
+a.pure-button-selected {
+ background-color: rgb(0, 120, 231);
+ color: #fff;
+}
+
+/* Button Groups */
+.pure-button-group .pure-button {
+ margin: 0;
+ border-radius: 0;
+ border-right: 1px solid #111; /* fallback color for rgba() for IE7/8 */
+ border-right: 1px solid rgba(0, 0, 0, 0.2);
+
+}
+
+.pure-button-group .pure-button:first-child {
+ border-top-left-radius: 2px;
+ border-bottom-left-radius: 2px;
+}
+.pure-button-group .pure-button:last-child {
+ border-top-right-radius: 2px;
+ border-bottom-right-radius: 2px;
+ border-right: none;
+}
+
+/*csslint box-model:false*/
+/*
+Box-model set to false because we're setting a height on select elements, which
+also have border and padding. This is done because some browsers don't render
+the padding. We explicitly set the box-model for select elements to border-box,
+so we can ignore the csslint warning.
+*/
+
+.pure-form input[type="text"],
+.pure-form input[type="password"],
+.pure-form input[type="email"],
+.pure-form input[type="url"],
+.pure-form input[type="date"],
+.pure-form input[type="month"],
+.pure-form input[type="time"],
+.pure-form input[type="datetime"],
+.pure-form input[type="datetime-local"],
+.pure-form input[type="week"],
+.pure-form input[type="number"],
+.pure-form input[type="search"],
+.pure-form input[type="tel"],
+.pure-form input[type="color"],
+.pure-form select,
+.pure-form textarea {
+ padding: 0.5em 0.6em;
+ display: inline-block;
+ border: 1px solid #ccc;
+ box-shadow: inset 0 1px 3px #ddd;
+ border-radius: 4px;
+ vertical-align: middle;
+ box-sizing: border-box;
+}
+
+/*
+Need to separate out the :not() selector from the rest of the CSS 2.1 selectors
+since IE8 won't execute CSS that contains a CSS3 selector.
+*/
+.pure-form input:not([type]) {
+ padding: 0.5em 0.6em;
+ display: inline-block;
+ border: 1px solid #ccc;
+ box-shadow: inset 0 1px 3px #ddd;
+ border-radius: 4px;
+ box-sizing: border-box;
+}
+
+
+/* Chrome (as of v.32/34 on OS X) needs additional room for color to display. */
+/* May be able to remove this tweak as color inputs become more standardized across browsers. */
+.pure-form input[type="color"] {
+ padding: 0.2em 0.5em;
+}
+
+
+.pure-form input[type="text"]:focus,
+.pure-form input[type="password"]:focus,
+.pure-form input[type="email"]:focus,
+.pure-form input[type="url"]:focus,
+.pure-form input[type="date"]:focus,
+.pure-form input[type="month"]:focus,
+.pure-form input[type="time"]:focus,
+.pure-form input[type="datetime"]:focus,
+.pure-form input[type="datetime-local"]:focus,
+.pure-form input[type="week"]:focus,
+.pure-form input[type="number"]:focus,
+.pure-form input[type="search"]:focus,
+.pure-form input[type="tel"]:focus,
+.pure-form input[type="color"]:focus,
+.pure-form select:focus,
+.pure-form textarea:focus {
+ outline: 0;
+ border-color: #129FEA;
+}
+
+/*
+Need to separate out the :not() selector from the rest of the CSS 2.1 selectors
+since IE8 won't execute CSS that contains a CSS3 selector.
+*/
+.pure-form input:not([type]):focus {
+ outline: 0;
+ border-color: #129FEA;
+}
+
+.pure-form input[type="file"]:focus,
+.pure-form input[type="radio"]:focus,
+.pure-form input[type="checkbox"]:focus {
+ outline: thin solid #129FEA;
+ outline: 1px auto #129FEA;
+}
+.pure-form .pure-checkbox,
+.pure-form .pure-radio {
+ margin: 0.5em 0;
+ display: block;
+}
+
+.pure-form input[type="text"][disabled],
+.pure-form input[type="password"][disabled],
+.pure-form input[type="email"][disabled],
+.pure-form input[type="url"][disabled],
+.pure-form input[type="date"][disabled],
+.pure-form input[type="month"][disabled],
+.pure-form input[type="time"][disabled],
+.pure-form input[type="datetime"][disabled],
+.pure-form input[type="datetime-local"][disabled],
+.pure-form input[type="week"][disabled],
+.pure-form input[type="number"][disabled],
+.pure-form input[type="search"][disabled],
+.pure-form input[type="tel"][disabled],
+.pure-form input[type="color"][disabled],
+.pure-form select[disabled],
+.pure-form textarea[disabled] {
+ cursor: not-allowed;
+ background-color: #eaeded;
+ color: #cad2d3;
+}
+
+/*
+Need to separate out the :not() selector from the rest of the CSS 2.1 selectors
+since IE8 won't execute CSS that contains a CSS3 selector.
+*/
+.pure-form input:not([type])[disabled] {
+ cursor: not-allowed;
+ background-color: #eaeded;
+ color: #cad2d3;
+}
+.pure-form input[readonly],
+.pure-form select[readonly],
+.pure-form textarea[readonly] {
+ background-color: #eee; /* menu hover bg color */
+ color: #777; /* menu text color */
+ border-color: #ccc;
+}
+
+.pure-form input:focus:invalid,
+.pure-form textarea:focus:invalid,
+.pure-form select:focus:invalid {
+ color: #b94a48;
+ border-color: #e9322d;
+}
+.pure-form input[type="file"]:focus:invalid:focus,
+.pure-form input[type="radio"]:focus:invalid:focus,
+.pure-form input[type="checkbox"]:focus:invalid:focus {
+ outline-color: #e9322d;
+}
+.pure-form select {
+ /* Normalizes the height; padding is not sufficient. */
+ height: 2.25em;
+ border: 1px solid #ccc;
+ background-color: white;
+}
+.pure-form select[multiple] {
+ height: auto;
+}
+.pure-form label {
+ margin: 0.5em 0 0.2em;
+}
+.pure-form fieldset {
+ margin: 0;
+ padding: 0.35em 0 0.75em;
+ border: 0;
+}
+.pure-form legend {
+ display: block;
+ width: 100%;
+ padding: 0.3em 0;
+ margin-bottom: 0.3em;
+ color: #333;
+ border-bottom: 1px solid #e5e5e5;
+}
+
+.pure-form-stacked input[type="text"],
+.pure-form-stacked input[type="password"],
+.pure-form-stacked input[type="email"],
+.pure-form-stacked input[type="url"],
+.pure-form-stacked input[type="date"],
+.pure-form-stacked input[type="month"],
+.pure-form-stacked input[type="time"],
+.pure-form-stacked input[type="datetime"],
+.pure-form-stacked input[type="datetime-local"],
+.pure-form-stacked input[type="week"],
+.pure-form-stacked input[type="number"],
+.pure-form-stacked input[type="search"],
+.pure-form-stacked input[type="tel"],
+.pure-form-stacked input[type="color"],
+.pure-form-stacked input[type="file"],
+.pure-form-stacked select,
+.pure-form-stacked label,
+.pure-form-stacked textarea {
+ display: block;
+ margin: 0.25em 0;
+}
+
+/*
+Need to separate out the :not() selector from the rest of the CSS 2.1 selectors
+since IE8 won't execute CSS that contains a CSS3 selector.
+*/
+.pure-form-stacked input:not([type]) {
+ display: block;
+ margin: 0.25em 0;
+}
+.pure-form-aligned input,
+.pure-form-aligned textarea,
+.pure-form-aligned select,
+/* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */
+.pure-form-aligned .pure-help-inline,
+.pure-form-message-inline {
+ display: inline-block;
+ *display: inline;
+ *zoom: 1;
+ vertical-align: middle;
+}
+.pure-form-aligned textarea {
+ vertical-align: top;
+}
+
+/* Aligned Forms */
+.pure-form-aligned .pure-control-group {
+ margin-bottom: 0.5em;
+}
+.pure-form-aligned .pure-control-group label {
+ text-align: right;
+ display: inline-block;
+ vertical-align: middle;
+ width: 10em;
+ margin: 0 1em 0 0;
+}
+.pure-form-aligned .pure-controls {
+ margin: 1.5em 0 0 11em;
+}
+
+/* Rounded Inputs */
+.pure-form input.pure-input-rounded,
+.pure-form .pure-input-rounded {
+ border-radius: 2em;
+ padding: 0.5em 1em;
+}
+
+/* Grouped Inputs */
+.pure-form .pure-group fieldset {
+ margin-bottom: 10px;
+}
+.pure-form .pure-group input,
+.pure-form .pure-group textarea {
+ display: block;
+ padding: 10px;
+ margin: 0 0 -1px;
+ border-radius: 0;
+ position: relative;
+ top: -1px;
+}
+.pure-form .pure-group input:focus,
+.pure-form .pure-group textarea:focus {
+ z-index: 3;
+}
+.pure-form .pure-group input:first-child,
+.pure-form .pure-group textarea:first-child {
+ top: 1px;
+ border-radius: 4px 4px 0 0;
+ margin: 0;
+}
+.pure-form .pure-group input:first-child:last-child,
+.pure-form .pure-group textarea:first-child:last-child {
+ top: 1px;
+ border-radius: 4px;
+ margin: 0;
+}
+.pure-form .pure-group input:last-child,
+.pure-form .pure-group textarea:last-child {
+ top: -2px;
+ border-radius: 0 0 4px 4px;
+ margin: 0;
+}
+.pure-form .pure-group button {
+ margin: 0.35em 0;
+}
+
+.pure-form .pure-input-1 {
+ width: 100%;
+}
+.pure-form .pure-input-3-4 {
+ width: 75%;
+}
+.pure-form .pure-input-2-3 {
+ width: 66%;
+}
+.pure-form .pure-input-1-2 {
+ width: 50%;
+}
+.pure-form .pure-input-1-3 {
+ width: 33%;
+}
+.pure-form .pure-input-1-4 {
+ width: 25%;
+}
+
+/* Inline help for forms */
+/* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */
+.pure-form .pure-help-inline,
+.pure-form-message-inline {
+ display: inline-block;
+ padding-left: 0.3em;
+ color: #666;
+ vertical-align: middle;
+ font-size: 0.875em;
+}
+
+/* Block help for forms */
+.pure-form-message {
+ display: block;
+ color: #666;
+ font-size: 0.875em;
+}
+
+@media only screen and (max-width : 480px) {
+ .pure-form button[type="submit"] {
+ margin: 0.7em 0 0;
+ }
+
+ .pure-form input:not([type]),
+ .pure-form input[type="text"],
+ .pure-form input[type="password"],
+ .pure-form input[type="email"],
+ .pure-form input[type="url"],
+ .pure-form input[type="date"],
+ .pure-form input[type="month"],
+ .pure-form input[type="time"],
+ .pure-form input[type="datetime"],
+ .pure-form input[type="datetime-local"],
+ .pure-form input[type="week"],
+ .pure-form input[type="number"],
+ .pure-form input[type="search"],
+ .pure-form input[type="tel"],
+ .pure-form input[type="color"],
+ .pure-form label {
+ margin-bottom: 0.3em;
+ display: block;
+ }
+
+ .pure-group input:not([type]),
+ .pure-group input[type="text"],
+ .pure-group input[type="password"],
+ .pure-group input[type="email"],
+ .pure-group input[type="url"],
+ .pure-group input[type="date"],
+ .pure-group input[type="month"],
+ .pure-group input[type="time"],
+ .pure-group input[type="datetime"],
+ .pure-group input[type="datetime-local"],
+ .pure-group input[type="week"],
+ .pure-group input[type="number"],
+ .pure-group input[type="search"],
+ .pure-group input[type="tel"],
+ .pure-group input[type="color"] {
+ margin-bottom: 0;
+ }
+
+ .pure-form-aligned .pure-control-group label {
+ margin-bottom: 0.3em;
+ text-align: left;
+ display: block;
+ width: 100%;
+ }
+
+ .pure-form-aligned .pure-controls {
+ margin: 1.5em 0 0 0;
+ }
+
+ /* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */
+ .pure-form .pure-help-inline,
+ .pure-form-message-inline,
+ .pure-form-message {
+ display: block;
+ font-size: 0.75em;
+ /* Increased bottom padding to make it group with its related input element. */
+ padding: 0.2em 0 0.8em;
+ }
+}
+
+/*csslint adjoining-classes: false, box-model:false*/
+.pure-menu {
+ box-sizing: border-box;
+}
+
+.pure-menu-fixed {
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 3;
+}
+
+.pure-menu-list,
+.pure-menu-item {
+ position: relative;
+}
+
+.pure-menu-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.pure-menu-item {
+ padding: 0;
+ margin: 0;
+ height: 100%;
+}
+
+.pure-menu-link,
+.pure-menu-heading {
+ display: block;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+/* HORIZONTAL MENU */
+.pure-menu-horizontal {
+ width: 100%;
+ white-space: nowrap;
+}
+
+.pure-menu-horizontal .pure-menu-list {
+ display: inline-block;
+}
+
+/* Initial menus should be inline-block so that they are horizontal */
+.pure-menu-horizontal .pure-menu-item,
+.pure-menu-horizontal .pure-menu-heading,
+.pure-menu-horizontal .pure-menu-separator {
+ display: inline-block;
+ *display: inline;
+ zoom: 1;
+ vertical-align: middle;
+}
+
+/* Submenus should still be display: block; */
+.pure-menu-item .pure-menu-item {
+ display: block;
+}
+
+.pure-menu-children {
+ display: none;
+ position: absolute;
+ left: 100%;
+ top: 0;
+ margin: 0;
+ padding: 0;
+ z-index: 3;
+}
+
+.pure-menu-horizontal .pure-menu-children {
+ left: 0;
+ top: auto;
+ width: inherit;
+}
+
+.pure-menu-allow-hover:hover > .pure-menu-children,
+.pure-menu-active > .pure-menu-children {
+ display: block;
+ position: absolute;
+}
+
+/* Vertical Menus - show the dropdown arrow */
+.pure-menu-has-children > .pure-menu-link:after {
+ padding-left: 0.5em;
+ content: "\25B8";
+ font-size: small;
+}
+
+/* Horizontal Menus - show the dropdown arrow */
+.pure-menu-horizontal .pure-menu-has-children > .pure-menu-link:after {
+ content: "\25BE";
+}
+
+/* scrollable menus */
+.pure-menu-scrollable {
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+
+.pure-menu-scrollable .pure-menu-list {
+ display: block;
+}
+
+.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list {
+ display: inline-block;
+}
+
+.pure-menu-horizontal.pure-menu-scrollable {
+ white-space: nowrap;
+ overflow-y: hidden;
+ overflow-x: auto;
+ -ms-overflow-style: none;
+ -webkit-overflow-scrolling: touch;
+ /* a little extra padding for this style to allow for scrollbars */
+ padding: .5em 0;
+}
+
+.pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar {
+ display: none;
+}
+
+/* misc default styling */
+
+.pure-menu-separator,
+.pure-menu-horizontal .pure-menu-children .pure-menu-separator {
+ background-color: #ccc;
+ height: 1px;
+ margin: .3em 0;
+}
+
+.pure-menu-horizontal .pure-menu-separator {
+ width: 1px;
+ height: 1.3em;
+ margin: 0 .3em ;
+}
+
+/* Need to reset the separator since submenu is vertical */
+.pure-menu-horizontal .pure-menu-children .pure-menu-separator {
+ display: block;
+ width: auto;
+}
+
+.pure-menu-heading {
+ text-transform: uppercase;
+ color: #565d64;
+}
+
+.pure-menu-link {
+ color: #777;
+}
+
+.pure-menu-children {
+ background-color: #fff;
+}
+
+.pure-menu-link,
+.pure-menu-disabled,
+.pure-menu-heading {
+ padding: .5em 1em;
+}
+
+.pure-menu-disabled {
+ opacity: .5;
+}
+
+.pure-menu-disabled .pure-menu-link:hover {
+ background-color: transparent;
+}
+
+.pure-menu-active > .pure-menu-link,
+.pure-menu-link:hover,
+.pure-menu-link:focus {
+ background-color: #eee;
+}
+
+.pure-menu-selected .pure-menu-link,
+.pure-menu-selected .pure-menu-link:visited {
+ color: #000;
+}
+
+.pure-table {
+ /* Remove spacing between table cells (from Normalize.css) */
+ border-collapse: collapse;
+ border-spacing: 0;
+ empty-cells: show;
+ border: 1px solid #cbcbcb;
+}
+
+.pure-table caption {
+ color: #000;
+ font: italic 85%/1 arial, sans-serif;
+ padding: 1em 0;
+ text-align: center;
+}
+
+.pure-table td,
+.pure-table th {
+ border-left: 1px solid #cbcbcb;/* inner column border */
+ border-width: 0 0 0 1px;
+ font-size: inherit;
+ margin: 0;
+ overflow: visible; /*to make ths where the title is really long work*/
+ padding: 0.5em 1em; /* cell padding */
+}
+
+/* Consider removing this next declaration block, as it causes problems when
+there's a rowspan on the first cell. Case added to the tests. issue#432 */
+.pure-table td:first-child,
+.pure-table th:first-child {
+ border-left-width: 0;
+}
+
+.pure-table thead {
+ background-color: #e0e0e0;
+ color: #000;
+ text-align: left;
+ vertical-align: bottom;
+}
+
+/*
+striping:
+ even - #fff (white)
+ odd - #f2f2f2 (light gray)
+*/
+.pure-table td {
+ background-color: transparent;
+}
+.pure-table-odd td {
+ background-color: #f2f2f2;
+}
+
+/* nth-child selector for modern browsers */
+.pure-table-striped tr:nth-child(2n-1) td {
+ background-color: #f2f2f2;
+}
+
+/* BORDERED TABLES */
+.pure-table-bordered td {
+ border-bottom: 1px solid #cbcbcb;
+}
+.pure-table-bordered tbody > tr:last-child > td {
+ border-bottom-width: 0;
+}
+
+
+/* HORIZONTAL BORDERED TABLES */
+
+.pure-table-horizontal td,
+.pure-table-horizontal th {
+ border-width: 0 0 1px 0;
+ border-bottom: 1px solid #cbcbcb;
+}
+.pure-table-horizontal tbody > tr:last-child > td {
+ border-bottom-width: 0;
+}
diff --git a/src/webex/style/wallet.css b/src/webex/style/wallet.css
new file mode 100644
index 000000000..752fc6d75
--- /dev/null
+++ b/src/webex/style/wallet.css
@@ -0,0 +1,222 @@
+#main {
+ border: solid 1px black;
+ border-radius: 10px;
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: 2em;
+ max-width: 50%;
+ padding: 2em;
+}
+
+header {
+ width: 100%;
+ height: 100px;
+ margin: 0;
+ padding: 0;
+ border-bottom: 1px solid black;
+}
+
+header h1 {
+ font-size: 200%;
+ margin: 0;
+ padding: 0 0 0 120px;
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+header #logo {
+ float: left;
+ width: 100px;
+ height: 100px;
+ padding: 0;
+ margin: 0;
+ text-align: center;
+ border-right: 1px solid black;
+ background-image: url(/img/logo.png);
+ background-size: 100px;
+}
+
+aside {
+ width: 100px;
+ float: left;
+}
+
+section#main {
+ margin: auto;
+ padding: 20px;
+ border-left: 1px solid black;
+ height: 100%;
+ max-width: 50%;
+}
+
+section#main h1:first-child {
+ margin-top: 0;
+}
+
+h1 {
+ font-size: 160%;
+}
+
+h2 {
+ font-size: 140%;
+}
+
+h3 {
+ font-size: 120%;
+}
+
+h4, h5, h6 {
+ font-size: 100%;
+}
+
+.form-row {
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+label {
+ padding-right: 1em;
+}
+
+label::after {
+ content: ":";
+}
+
+input.url {
+ width: 25em;
+}
+
+.formish {
+}
+
+.json-key {
+ color: brown;
+}
+.json-value {
+ color: navy;
+ }
+.json-string {
+ color: olive;
+}
+
+button {
+ font-size: 120%;
+ padding: 0.5em;
+}
+
+button.confirm-pay {
+ float: right;
+}
+
+/* We use fading to hide slower DOM updates */
+.fade {
+ -webkit-animation: fade 0.7s;
+ animation: fade 0.7s;
+ opacity: 1;
+}
+
+@-webkit-keyframes fade {
+ from {opacity: 0}
+ to {opacity: 1}
+}
+@keyframes fade {
+ from {opacity: 0}
+ to {opacity: 1}
+ }
+
+button.linky {
+ background:none!important;
+ border:none;
+ padding:0!important;
+
+ font-family:arial,sans-serif;
+ color:#069;
+ text-decoration:underline;
+ cursor:pointer;
+}
+
+table, th, td {
+ border: 1px solid black;
+}
+
+
+button.accept {
+ background-color: #5757D2;
+ border: 1px solid black;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: white;
+}
+button.linky {
+ background:none!important;
+ border:none;
+ padding:0!important;
+
+ font-family:arial,sans-serif;
+ color:#069;
+ text-decoration:underline;
+ cursor:pointer;
+}
+
+
+button.accept:disabled {
+ background-color: #dedbe8;
+ border: 1px solid white;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: #2C2C2C;
+}
+
+input.url {
+ width: 25em;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+td {
+ border-left: 1px solid black;
+ border-right: 1px solid black;
+ text-align: center;
+ padding: 0.3em;
+}
+
+span.spacer {
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+}
+
+.button-success,
+.button-destructive,
+.button-warning,
+.button-secondary {
+ color: white;
+ border-radius: 4px;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
+}
+
+.button-success {
+ background: rgb(28, 184, 65);
+}
+
+.button-destructive {
+ background: rgb(202, 60, 60);
+}
+
+.button-warning {
+ background: rgb(223, 117, 20);
+}
+
+.button-secondary {
+ background: rgb(66, 184, 221);
+}
+
+a.actionLink {
+ color: black;
+}
diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts
new file mode 100644
index 000000000..e5a502406
--- /dev/null
+++ b/src/webex/wxApi.ts
@@ -0,0 +1,174 @@
+/*
+ This file is part of TALER
+ (C) 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/>
+ */
+
+/**
+ * Interface to the wallet through WebExtension messaging.
+ */
+
+
+/**
+ * Imports.
+ */
+import {
+ AmountJson,
+ CoinRecord,
+ CurrencyRecord,
+ DenominationRecord,
+ ExchangeRecord,
+ PreCoinRecord,
+ ReserveCreationInfo,
+ ReserveRecord,
+} from "../types";
+
+
+/**
+ * Query the wallet for the coins that would be used to withdraw
+ * from a given reserve.
+ */
+export function getReserveCreationInfo(baseUrl: string,
+ amount: AmountJson): Promise<ReserveCreationInfo> {
+ const m = { type: "reserve-creation-info", detail: { baseUrl, amount } };
+ return new Promise<ReserveCreationInfo>((resolve, reject) => {
+ chrome.runtime.sendMessage(m, (resp) => {
+ if (resp.error) {
+ console.error("error response", resp);
+ const e = Error("call to reserve-creation-info failed");
+ (e as any).errorResponse = resp;
+ reject(e);
+ return;
+ }
+ resolve(resp);
+ });
+ });
+}
+
+
+async function callBackend(type: string, detail?: any): Promise<any> {
+ return new Promise<any>((resolve, reject) => {
+ chrome.runtime.sendMessage({ type, detail }, (resp) => {
+ if (resp && resp.error) {
+ reject(resp);
+ } else {
+ resolve(resp);
+ }
+ });
+ });
+}
+
+
+/**
+ * Get all exchanges the wallet knows about.
+ */
+export async function getExchanges(): Promise<ExchangeRecord[]> {
+ return await callBackend("get-exchanges");
+}
+
+
+/**
+ * Get all currencies the exchange knows about.
+ */
+export async function getCurrencies(): Promise<CurrencyRecord[]> {
+ return await callBackend("get-currencies");
+}
+
+
+/**
+ * Get information about a specific currency.
+ */
+export async function getCurrency(name: string): Promise<CurrencyRecord|null> {
+ return await callBackend("currency-info", {name});
+}
+
+
+/**
+ * Get information about a specific exchange.
+ */
+export async function getExchangeInfo(baseUrl: string): Promise<ExchangeRecord> {
+ return await callBackend("exchange-info", {baseUrl});
+}
+
+
+/**
+ * Replace an existing currency record with the one given. The currency to
+ * replace is specified inside the currency record.
+ */
+export async function updateCurrency(currencyRecord: CurrencyRecord): Promise<void> {
+ return await callBackend("update-currency", { currencyRecord });
+}
+
+
+/**
+ * Get all reserves the wallet has at an exchange.
+ */
+export async function getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> {
+ return await callBackend("get-reserves", { exchangeBaseUrl });
+}
+
+
+/**
+ * Get all reserves for which a payback is available.
+ */
+export async function getPaybackReserves(): Promise<ReserveRecord[]> {
+ return await callBackend("get-payback-reserves");
+}
+
+
+/**
+ * Withdraw the payback that is available for a reserve.
+ */
+export async function withdrawPaybackReserve(reservePub: string): Promise<ReserveRecord[]> {
+ return await callBackend("withdraw-payback-reserve", { reservePub });
+}
+
+
+/**
+ * Get all coins withdrawn from the given exchange.
+ */
+export async function getCoins(exchangeBaseUrl: string): Promise<CoinRecord[]> {
+ return await callBackend("get-coins", { exchangeBaseUrl });
+}
+
+
+/**
+ * Get all precoins withdrawn from the given exchange.
+ */
+export async function getPreCoins(exchangeBaseUrl: string): Promise<PreCoinRecord[]> {
+ return await callBackend("get-precoins", { exchangeBaseUrl });
+}
+
+
+/**
+ * Get all denoms offered by the given exchange.
+ */
+export async function getDenoms(exchangeBaseUrl: string): Promise<DenominationRecord[]> {
+ return await callBackend("get-denoms", { exchangeBaseUrl });
+}
+
+
+/**
+ * Start refreshing a coin.
+ */
+export async function refresh(coinPub: string): Promise<void> {
+ return await callBackend("refresh-coin", { coinPub });
+}
+
+
+/**
+ * Request payback for a coin. Only works for non-refreshed coins.
+ */
+export async function payback(coinPub: string): Promise<void> {
+ return await callBackend("payback-coin", { coinPub });
+}
diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts
new file mode 100644
index 000000000..35e1ff938
--- /dev/null
+++ b/src/webex/wxBackend.ts
@@ -0,0 +1,719 @@
+/*
+ This file is part of TALER
+ (C) 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/>
+ */
+
+/**
+ * Messaging for the WebExtensions wallet. Should contain
+ * parts that are specific for WebExtensions, but as little business
+ * logic as possible.
+ */
+
+
+/**
+ * Imports.
+ */
+import { Checkable } from "../checkable";
+import { BrowserHttpLib } from "../http";
+import * as logging from "../logging";
+import {
+ Index,
+ Store,
+} from "../query";
+import {
+ AmountJson,
+ Contract,
+ Notifier,
+} from "../types";
+import {
+ Badge,
+ ConfirmReserveRequest,
+ CreateReserveRequest,
+ OfferRecord,
+ Stores,
+ Wallet,
+} from "../wallet";
+
+import { ChromeBadge } from "./chromeBadge";
+import URI = require("urijs");
+import Port = chrome.runtime.Port;
+import MessageSender = chrome.runtime.MessageSender;
+
+
+const DB_NAME = "taler";
+
+/**
+ * Current database version, should be incremented
+ * each time we do incompatible schema changes on the database.
+ * In the future we might consider adding migration functions for
+ * each version increment.
+ */
+const DB_VERSION = 17;
+
+type Handler = (detail: any, sender: MessageSender) => Promise<any>;
+
+function makeHandlers(db: IDBDatabase,
+ wallet: Wallet): { [msg: string]: Handler } {
+ return {
+ ["balances"]: (detail, sender) => {
+ return wallet.getBalances();
+ },
+ ["dump-db"]: (detail, sender) => {
+ return exportDb(db);
+ },
+ ["import-db"]: (detail, sender) => {
+ return importDb(db, detail.dump);
+ },
+ ["get-tab-cookie"]: (detail, sender) => {
+ if (!sender || !sender.tab || !sender.tab.id) {
+ return Promise.resolve();
+ }
+ const id: number = sender.tab.id;
+ const info: any = paymentRequestCookies[id] as any;
+ delete paymentRequestCookies[id];
+ return Promise.resolve(info);
+ },
+ ["ping"]: (detail, sender) => {
+ return Promise.resolve();
+ },
+ ["reset"]: (detail, sender) => {
+ if (db) {
+ const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite");
+ // tslint:disable-next-line:prefer-for-of
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
+ tx.objectStore(db.objectStoreNames[i]).clear();
+ }
+ }
+ deleteDb();
+
+ chrome.browserAction.setBadgeText({ text: "" });
+ console.log("reset done");
+ // Response is synchronous
+ return Promise.resolve({});
+ },
+ ["create-reserve"]: (detail, sender) => {
+ const d = {
+ amount: detail.amount,
+ exchange: detail.exchange,
+ };
+ const req = CreateReserveRequest.checked(d);
+ return wallet.createReserve(req);
+ },
+ ["confirm-reserve"]: (detail, sender) => {
+ // TODO: make it a checkable
+ const d = {
+ reservePub: detail.reservePub,
+ };
+ const req = ConfirmReserveRequest.checked(d);
+ return wallet.confirmReserve(req);
+ },
+ ["generate-nonce"]: (detail, sender) => {
+ return wallet.generateNonce();
+ },
+ ["confirm-pay"]: (detail, sender) => {
+ let offer: OfferRecord;
+ try {
+ offer = OfferRecord.checked(detail.offer);
+ } catch (e) {
+ if (e instanceof Checkable.SchemaError) {
+ console.error("schema error:", e.message);
+ return Promise.resolve({
+ detail,
+ error: "invalid contract",
+ hint: e.message,
+ });
+ } else {
+ throw e;
+ }
+ }
+
+ return wallet.confirmPay(offer);
+ },
+ ["check-pay"]: (detail, sender) => {
+ let offer: OfferRecord;
+ try {
+ offer = OfferRecord.checked(detail.offer);
+ } catch (e) {
+ if (e instanceof Checkable.SchemaError) {
+ console.error("schema error:", e.message);
+ return Promise.resolve({
+ detail,
+ error: "invalid contract",
+ hint: e.message,
+ });
+ } else {
+ throw e;
+ }
+ }
+ return wallet.checkPay(offer);
+ },
+ ["query-payment"]: (detail: any, sender: MessageSender) => {
+ if (sender.tab && sender.tab.id) {
+ rateLimitCache[sender.tab.id]++;
+ if (rateLimitCache[sender.tab.id] > 10) {
+ console.warn("rate limit for query-payment exceeded");
+ const msg = {
+ error: "rate limit exceeded for query-payment",
+ hint: "Check for redirect loops",
+ rateLimitExceeded: true,
+ };
+ return Promise.resolve(msg);
+ }
+ }
+ return wallet.queryPayment(detail.url);
+ },
+ ["exchange-info"]: (detail) => {
+ if (!detail.baseUrl) {
+ return Promise.resolve({ error: "bad url" });
+ }
+ return wallet.updateExchangeFromUrl(detail.baseUrl);
+ },
+ ["currency-info"]: (detail) => {
+ if (!detail.name) {
+ return Promise.resolve({ error: "name missing" });
+ }
+ return wallet.getCurrencyRecord(detail.name);
+ },
+ ["hash-contract"]: (detail) => {
+ if (!detail.contract) {
+ return Promise.resolve({ error: "contract missing" });
+ }
+ return wallet.hashContract(detail.contract).then((hash) => {
+ return { hash };
+ });
+ },
+ ["put-history-entry"]: (detail: any) => {
+ if (!detail.historyEntry) {
+ return Promise.resolve({ error: "historyEntry missing" });
+ }
+ return wallet.putHistory(detail.historyEntry);
+ },
+ ["save-offer"]: (detail: any) => {
+ const offer = detail.offer;
+ if (!offer) {
+ return Promise.resolve({ error: "offer missing" });
+ }
+ console.log("handling safe-offer", detail);
+ // FIXME: fully migrate to new terminology
+ const checkedOffer = OfferRecord.checked(offer);
+ return wallet.saveOffer(checkedOffer);
+ },
+ ["reserve-creation-info"]: (detail, sender) => {
+ if (!detail.baseUrl || typeof detail.baseUrl !== "string") {
+ return Promise.resolve({ error: "bad url" });
+ }
+ const amount = AmountJson.checked(detail.amount);
+ return wallet.getReserveCreationInfo(detail.baseUrl, amount);
+ },
+ ["get-history"]: (detail, sender) => {
+ // TODO: limit history length
+ return wallet.getHistory();
+ },
+ ["get-offer"]: (detail, sender) => {
+ return wallet.getOffer(detail.offerId);
+ },
+ ["get-exchanges"]: (detail, sender) => {
+ return wallet.getExchanges();
+ },
+ ["get-currencies"]: (detail, sender) => {
+ return wallet.getCurrencies();
+ },
+ ["update-currency"]: (detail, sender) => {
+ return wallet.updateCurrency(detail.currencyRecord);
+ },
+ ["get-reserves"]: (detail, sender) => {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangeBaseUrl missing"));
+ }
+ return wallet.getReserves(detail.exchangeBaseUrl);
+ },
+ ["get-payback-reserves"]: (detail, sender) => {
+ return wallet.getPaybackReserves();
+ },
+ ["withdraw-payback-reserve"]: (detail, sender) => {
+ if (typeof detail.reservePub !== "string") {
+ return Promise.reject(Error("reservePub missing"));
+ }
+ return wallet.withdrawPaybackReserve(detail.reservePub);
+ },
+ ["get-coins"]: (detail, sender) => {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return wallet.getCoins(detail.exchangeBaseUrl);
+ },
+ ["get-precoins"]: (detail, sender) => {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return wallet.getPreCoins(detail.exchangeBaseUrl);
+ },
+ ["get-denoms"]: (detail, sender) => {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return wallet.getDenoms(detail.exchangeBaseUrl);
+ },
+ ["refresh-coin"]: (detail, sender) => {
+ if (typeof detail.coinPub !== "string") {
+ return Promise.reject(Error("coinPub missing"));
+ }
+ return wallet.refresh(detail.coinPub);
+ },
+ ["payback-coin"]: (detail, sender) => {
+ if (typeof detail.coinPub !== "string") {
+ return Promise.reject(Error("coinPub missing"));
+ }
+ return wallet.payback(detail.coinPub);
+ },
+ ["payment-failed"]: (detail, sender) => {
+ // For now we just update exchanges (maybe the exchange did something
+ // wrong and the keys were messed up).
+ // FIXME: in the future we should look at what actually went wrong.
+ console.error("payment reported as failed");
+ wallet.updateExchanges();
+ return Promise.resolve();
+ },
+ ["payment-succeeded"]: (detail, sender) => {
+ const contractHash = detail.contractHash;
+ const merchantSig = detail.merchantSig;
+ if (!contractHash) {
+ return Promise.reject(Error("contractHash missing"));
+ }
+ if (!merchantSig) {
+ return Promise.reject(Error("merchantSig missing"));
+ }
+ return wallet.paymentSucceeded(contractHash, merchantSig);
+ },
+ };
+}
+
+
+async function dispatch(handlers: any, req: any, sender: any, sendResponse: any): Promise<void> {
+ if (!(req.type in handlers)) {
+ console.error(`Request type ${JSON.stringify(req)} unknown, req ${req.type}`);
+ try {
+ sendResponse({ error: "request unknown" });
+ } catch (e) {
+ // might fail if tab disconnected
+ }
+ }
+
+ try {
+ const p = handlers[req.type](req.detail, sender);
+ const r = await p;
+ try {
+ sendResponse(r);
+ } catch (e) {
+ // might fail if tab disconnected
+ }
+ } catch (e) {
+ console.log(`exception during wallet handler for '${req.type}'`);
+ console.log("request", req);
+ console.error(e);
+ let stack;
+ try {
+ stack = e.stack.toString();
+ } catch (e) {
+ // might fail
+ }
+ try {
+ sendResponse({
+ error: "exception",
+ hint: e.message,
+ stack,
+ });
+ } catch (e) {
+ console.log(e);
+ // might fail if tab disconnected
+ }
+ }
+}
+
+
+class ChromeNotifier implements Notifier {
+ private ports: Port[] = [];
+
+ constructor() {
+ chrome.runtime.onConnect.addListener((port) => {
+ console.log("got connect!");
+ this.ports.push(port);
+ port.onDisconnect.addListener(() => {
+ const i = this.ports.indexOf(port);
+ if (i >= 0) {
+ this.ports.splice(i, 1);
+ } else {
+ console.error("port already removed");
+ }
+ });
+ });
+ }
+
+ notify() {
+ for (const p of this.ports) {
+ p.postMessage({ notify: true });
+ }
+ }
+}
+
+
+/**
+ * Mapping from tab ID to payment information (if any).
+ */
+const paymentRequestCookies: { [n: number]: any } = {};
+
+
+/**
+ * Handle a HTTP response that has the "402 Payment Required" status.
+ * In this callback we don't have access to the body, and must communicate via
+ * shared state with the content script that will later be run later
+ * in this tab.
+ */
+function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: string, tabId: number): any {
+ const headers: { [s: string]: string } = {};
+ for (const kv of headerList) {
+ if (kv.value) {
+ headers[kv.name.toLowerCase()] = kv.value;
+ }
+ }
+
+ const fields = {
+ contract_query: headers["x-taler-contract-query"],
+ contract_url: headers["x-taler-contract-url"],
+ offer_url: headers["x-taler-offer-url"],
+ };
+
+ const talerHeaderFound = Object.keys(fields).filter((x: any) => (fields as any)[x]).length !== 0;
+
+ if (!talerHeaderFound) {
+ // looks like it's not a taler request, it might be
+ // for a different payment system (or the shop is buggy)
+ console.log("ignoring non-taler 402 response");
+ return;
+ }
+
+ const payDetail = {
+ contract_url: fields.contract_url,
+ offer_url: fields.offer_url,
+ };
+
+ console.log("got pay detail", payDetail);
+
+ // This cookie will be read by the injected content script
+ // in the tab that displays the page.
+ paymentRequestCookies[tabId] = {
+ payDetail,
+ type: "pay",
+ };
+}
+
+
+function handleBankRequest(wallet: Wallet, headerList: chrome.webRequest.HttpHeader[],
+ url: string, tabId: number): any {
+ const headers: { [s: string]: string } = {};
+ for (const kv of headerList) {
+ if (kv.value) {
+ headers[kv.name.toLowerCase()] = kv.value;
+ }
+ }
+
+ const reservePub = headers["x-taler-reserve-pub"];
+ if (reservePub !== undefined) {
+ console.log(`confirming reserve ${reservePub} via 201`);
+ wallet.confirmReserve({reservePub});
+ return;
+ }
+
+ const amount = headers["x-taler-amount"];
+ if (amount) {
+ const callbackUrl = headers["x-taler-callback-url"];
+ if (!callbackUrl) {
+ console.log("202 not understood (X-Taler-Callback-Url missing)");
+ return;
+ }
+ let amountParsed;
+ try {
+ amountParsed = JSON.parse(amount);
+ } catch (e) {
+ const uri = new URI(chrome.extension.getURL("/src/pages/error.html"));
+ const p = {
+ message: `Can't parse amount ("${amount}"): ${e.message}`,
+ };
+ const redirectUrl = uri.query(p).href();
+ // FIXME: use direct redirect when https://bugzilla.mozilla.org/show_bug.cgi?id=707624 is fixed
+ chrome.tabs.update(tabId, {url: redirectUrl});
+ return;
+ }
+ const wtTypes = headers["x-taler-wt-types"];
+ if (!wtTypes) {
+ console.log("202 not understood (X-Taler-Wt-Types missing)");
+ return;
+ }
+ const params = {
+ amount,
+ bank_url: url,
+ callback_url: new URI(callbackUrl) .absoluteTo(url),
+ suggested_exchange_url: headers["x-taler-suggested-exchange"],
+ wt_types: wtTypes,
+ };
+ const uri = new URI(chrome.extension.getURL("/src/pages/confirm-create-reserve.html"));
+ const redirectUrl = uri.query(params).href();
+ console.log("redirecting to", redirectUrl);
+ // FIXME: use direct redirect when https://bugzilla.mozilla.org/show_bug.cgi?id=707624 is fixed
+ chrome.tabs.update(tabId, {url: redirectUrl});
+ return;
+ }
+ // no known headers found, not a taler request ...
+}
+
+
+// Rate limit cache for executePayment operations, to break redirect loops
+let rateLimitCache: { [n: number]: number } = {};
+
+function clearRateLimitCache() {
+ rateLimitCache = {};
+}
+
+/**
+ * Main function to run for the WebExtension backend.
+ *
+ * Sets up all event handlers and other machinery.
+ */
+export async function wxMain() {
+ window.onerror = (m, source, lineno, colno, error) => {
+ logging.record("error", m + error, undefined, source || "(unknown)", lineno || 0, colno || 0);
+ };
+
+ chrome.browserAction.setBadgeText({ text: "" });
+ const badge = new ChromeBadge();
+
+ chrome.tabs.query({}, (tabs) => {
+ for (const tab of tabs) {
+ if (!tab.url || !tab.id) {
+ return;
+ }
+ const uri = new URI(tab.url);
+ if (uri.protocol() === "http" || uri.protocol() === "https") {
+ console.log("injecting into existing tab", tab.id);
+ chrome.tabs.executeScript(tab.id, { file: "/dist/contentScript-bundle.js" });
+ const code = `
+ if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) {
+ document.dispatchEvent(new Event("taler-probe-result"));
+ }
+ `;
+ chrome.tabs.executeScript(tab.id, { code, runAt: "document_idle" });
+ }
+ }
+ });
+
+ const tabTimers: {[n: number]: number[]} = {};
+
+ chrome.tabs.onRemoved.addListener((tabId, changeInfo) => {
+ const tt = tabTimers[tabId] || [];
+ for (const t of tt) {
+ chrome.extension.getBackgroundPage().clearTimeout(t);
+ }
+ });
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status !== "complete") {
+ return;
+ }
+ const timers: number[] = [];
+
+ const addRun = (dt: number) => {
+ const id = chrome.extension.getBackgroundPage().setTimeout(run, dt);
+ timers.push(id);
+ };
+
+ const run = () => {
+ timers.shift();
+ chrome.tabs.get(tabId, (tab) => {
+ if (chrome.runtime.lastError) {
+ return;
+ }
+ if (!tab.url || !tab.id) {
+ return;
+ }
+ const uri = new URI(tab.url);
+ if (!(uri.protocol() === "http" || uri.protocol() === "https")) {
+ return;
+ }
+ const code = `
+ if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) {
+ document.dispatchEvent(new Event("taler-probe-result"));
+ }
+ `;
+ chrome.tabs.executeScript(tab.id!, { code, runAt: "document_start" });
+ });
+ };
+
+ addRun(0);
+ addRun(50);
+ addRun(300);
+ addRun(1000);
+ addRun(2000);
+ addRun(4000);
+ addRun(8000);
+ addRun(16000);
+ tabTimers[tabId] = timers;
+ });
+
+ chrome.extension.getBackgroundPage().setInterval(clearRateLimitCache, 5000);
+
+ let db: IDBDatabase;
+ try {
+ db = await openTalerDb();
+ } catch (e) {
+ console.error("could not open database", e);
+ return;
+ }
+ const http = new BrowserHttpLib();
+ const notifier = new ChromeNotifier();
+ console.log("setting wallet");
+ const wallet = new Wallet(db, http, badge!, notifier);
+ // Useful for debugging in the background page.
+ (window as any).talerWallet = wallet;
+
+ // Handlers for messages coming directly from the content
+ // script on the page
+ const handlers = makeHandlers(db, wallet!);
+ chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
+ dispatch(handlers, req, sender, sendResponse);
+ return true;
+ });
+
+ // Handlers for catching HTTP requests
+ chrome.webRequest.onHeadersReceived.addListener((details) => {
+ if (details.statusCode === 402) {
+ console.log(`got 402 from ${details.url}`);
+ return handleHttpPayment(details.responseHeaders || [],
+ details.url,
+ details.tabId);
+ } else if (details.statusCode === 202) {
+ return handleBankRequest(wallet!, details.responseHeaders || [],
+ details.url,
+ details.tabId);
+ }
+ }, { urls: ["<all_urls>"] }, ["responseHeaders", "blocking"]);
+}
+
+
+/**
+ * Return a promise that resolves
+ * to the taler wallet db.
+ */
+function openTalerDb(): Promise<IDBDatabase> {
+ return new Promise<IDBDatabase>((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+ req.onerror = (e) => {
+ reject(e);
+ };
+ req.onsuccess = (e) => {
+ resolve(req.result);
+ };
+ req.onupgradeneeded = (e) => {
+ const db = req.result;
+ console.log("DB: upgrade needed: oldVersion = " + e.oldVersion);
+ switch (e.oldVersion) {
+ case 0: // DB does not exist yet
+
+ for (const n in Stores) {
+ if ((Stores as any)[n] instanceof Store) {
+ const si: Store<any> = (Stores as any)[n];
+ const s = db.createObjectStore(si.name, si.storeParams);
+ 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);
+ }
+ }
+ }
+ }
+ break;
+ default:
+ if (e.oldVersion !== DB_VERSION) {
+ window.alert("Incompatible wallet dababase version, please reset" +
+ " db.");
+ chrome.browserAction.setBadgeText({text: "err"});
+ chrome.browserAction.setBadgeBackgroundColor({color: "#F00"});
+ throw Error("incompatible DB");
+ }
+ break;
+ }
+ };
+ });
+}
+
+
+function exportDb(db: IDBDatabase): Promise<any> {
+ const dump = {
+ name: db.name,
+ stores: {} as {[s: string]: any},
+ version: db.version,
+ };
+
+ return new Promise((resolve, reject) => {
+
+ const tx = db.transaction(Array.from(db.objectStoreNames));
+ tx.addEventListener("complete", () => {
+ resolve(dump);
+ });
+ // tslint:disable-next-line:prefer-for-of
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
+ const name = db.objectStoreNames[i];
+ const storeDump = {} as {[s: string]: any};
+ dump.stores[name] = storeDump;
+ tx.objectStore(name)
+ .openCursor()
+ .addEventListener("success", (e: Event) => {
+ const cursor = (e.target as any).result;
+ if (cursor) {
+ storeDump[cursor.key] = cursor.value;
+ cursor.continue();
+ }
+ });
+ }
+ });
+}
+
+
+function importDb(db: IDBDatabase, dump: any): Promise<void> {
+ console.log("importing db", dump);
+ return new Promise<void>((resolve, reject) => {
+ const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite");
+ if (dump.stores) {
+ for (const storeName in dump.stores) {
+ const objects = [];
+ const dumpStore = dump.stores[storeName];
+ for (const key in dumpStore) {
+ objects.push(dumpStore[key]);
+ }
+ console.log(`importing ${objects.length} records into ${storeName}`);
+ const store = tx.objectStore(storeName);
+ const clearReq = store.clear();
+ for (const obj of objects) {
+ store.put(obj);
+ }
+ }
+ }
+ tx.addEventListener("complete", () => {
+ resolve();
+ });
+ });
+}
+
+
+function deleteDb() {
+ indexedDB.deleteDatabase(DB_NAME);
+}