=r.length||i>=t.length);++i)r[i+e]=t[i];return i}function z(t,r){return t instanceof r||null!=t&&null!=t.constructor&&null!=t.constructor.name&&t.constructor.name===r.name}function D(t){return t!=t}var F=function(){for(var t=new Array(256),r=0;r<16;++r)for(var e=16*r,n=0;n<16;++n)t[e+n]="0123456789abcdef"[r]+"0123456789abcdef"[n];return t}()}).call(this,t("buffer").Buffer)},{"base64-js":2,buffer:5,ieee754:3}],2:[function(t,r,e){"use strict";e.byteLength=function(t){var r=h(t),e=r[0],n=r[1];return 3*(e+n)/4-n},e.toByteArray=function(t){var r,e,n=h(t),f=n[0],u=n[1],s=new o(function(t,r,e){return 3*(r+e)/4-e}(0,f,u)),a=0,p=u>0?f-4:f;for(e=0;e>16&255,s[a++]=r>>8&255,s[a++]=255&r;2===u&&(r=i[t.charCodeAt(e)]<<2|i[t.charCodeAt(e+1)]>>4,s[a++]=255&r);1===u&&(r=i[t.charCodeAt(e)]<<10|i[t.charCodeAt(e+1)]<<4|i[t.charCodeAt(e+2)]>>2,s[a++]=r>>8&255,s[a++]=255&r);return s},e.fromByteArray=function(t){for(var r,e=t.length,i=e%3,o=[],f=0,u=e-i;fu?u:f+16383));1===i?(r=t[e-1],o.push(n[r>>2]+n[r<<4&63]+"==")):2===i&&(r=(t[e-2]<<8)+t[e-1],o.push(n[r>>10]+n[r>>4&63]+n[r<<2&63]+"="));return o.join("")};for(var n=[],i=[],o="undefined"!=typeof Uint8Array?Uint8Array:Array,f="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u=0,s=f.length;u0)throw new Error("Invalid string. Length must be a multiple of 4");var e=t.indexOf("=");return-1===e&&(e=r),[e,e===r?0:4-e%4]}function a(t,r,e){for(var i,o,f=[],u=r;u>18&63]+n[o>>12&63]+n[o>>6&63]+n[63&o]);return f.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},{}],3:[function(t,r,e){e.read=function(t,r,e,n,i){var o,f,u=8*i-n-1,s=(1<>1,a=-7,p=e?i-1:0,c=e?-1:1,l=t[r+p];for(p+=c,o=l&(1<<-a)-1,l>>=-a,a+=u;a>0;o=256*o+t[r+p],p+=c,a-=8);for(f=o&(1<<-a)-1,o>>=-a,a+=n;a>0;f=256*f+t[r+p],p+=c,a-=8);if(0===o)o=1-h;else{if(o===s)return f?NaN:1/0*(l?-1:1);f+=Math.pow(2,n),o-=h}return(l?-1:1)*f*Math.pow(2,o-n)},e.write=function(t,r,e,n,i,o){var f,u,s,h=8*o-i-1,a=(1<>1,c=23===i?Math.pow(2,-24)-Math.pow(2,-77):0,l=n?0:o-1,y=n?1:-1,g=r<0||0===r&&1/r<0?1:0;for(r=Math.abs(r),isNaN(r)||r===1/0?(u=isNaN(r)?1:0,f=a):(f=Math.floor(Math.log(r)/Math.LN2),r*(s=Math.pow(2,-f))<1&&(f--,s*=2),(r+=f+p>=1?c/s:c*Math.pow(2,1-p))*s>=2&&(f++,s/=2),f+p>=a?(u=0,f=a):f+p>=1?(u=(r*s-1)*Math.pow(2,i),f+=p):(u=r*Math.pow(2,p-1)*Math.pow(2,i),f=0));i>=8;t[e+l]=255&u,l+=y,u/=256,i-=8);for(f=f<0;t[e+l]=255&f,l+=y,f/=256,h-=8);t[e+l-y]|=128*g}},{}],4:[function(t,r,e){arguments[4][2][0].apply(e,arguments)},{dup:2}],5:[function(t,r,e){(function(r){"use strict";var n=t("base64-js"),i=t("ieee754");e.Buffer=r,e.SlowBuffer=function(t){+t!=t&&(t=0);return r.alloc(+t)},e.INSPECT_MAX_BYTES=50;var o=2147483647;function f(t){if(t>o)throw new RangeError('The value "'+t+'" is invalid for option "size"');var e=new Uint8Array(t);return e.__proto__=r.prototype,e}function r(t,r,e){if("number"==typeof t){if("string"==typeof r)throw new TypeError('The "string" argument must be of type string. Received type number');return h(t)}return u(t,r,e)}function u(t,e,n){if("string"==typeof t)return function(t,e){"string"==typeof e&&""!==e||(e="utf8");if(!r.isEncoding(e))throw new TypeError("Unknown encoding: "+e);var n=0|c(t,e),i=f(n),o=i.write(t,e);o!==n&&(i=i.slice(0,o));return i}(t,e);if(ArrayBuffer.isView(t))return a(t);if(null==t)throw TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof t);if(z(t,ArrayBuffer)||t&&z(t.buffer,ArrayBuffer))return function(t,e,n){if(e<0||t.byteLength=o)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+o.toString(16)+" bytes");return 0|t}function c(t,e){if(r.isBuffer(t))return t.length;if(ArrayBuffer.isView(t)||z(t,ArrayBuffer))return t.byteLength;if("string"!=typeof t)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof t);var n=t.length,i=arguments.length>2&&!0===arguments[2];if(!i&&0===n)return 0;for(var o=!1;;)switch(e){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":return P(t).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return j(t).length;default:if(o)return i?-1:P(t).length;e=(""+e).toLowerCase(),o=!0}}function l(t,r,e){var n=t[r];t[r]=t[e],t[e]=n}function y(t,e,n,i,o){if(0===t.length)return-1;if("string"==typeof n?(i=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),D(n=+n)&&(n=o?0:t.length-1),n<0&&(n=t.length+n),n>=t.length){if(o)return-1;n=t.length-1}else if(n<0){if(!o)return-1;n=0}if("string"==typeof e&&(e=r.from(e,i)),r.isBuffer(e))return 0===e.length?-1:g(t,e,n,i,o);if("number"==typeof e)return e&=255,"function"==typeof Uint8Array.prototype.indexOf?o?Uint8Array.prototype.indexOf.call(t,e,n):Uint8Array.prototype.lastIndexOf.call(t,e,n):g(t,[e],n,i,o);throw new TypeError("val must be string, number or Buffer")}function g(t,r,e,n,i){var o,f=1,u=t.length,s=r.length;if(void 0!==n&&("ucs2"===(n=String(n).toLowerCase())||"ucs-2"===n||"utf16le"===n||"utf-16le"===n)){if(t.length<2||r.length<2)return-1;f=2,u/=2,s/=2,e/=2}function h(t,r){return 1===f?t[r]:t.readUInt16BE(r*f)}if(i){var a=-1;for(o=e;ou&&(e=u-s),o=e;o>=0;o--){for(var p=!0,c=0;ci&&(n=i):n=i;var o=r.length;n>o/2&&(n=o/2);for(var f=0;f>8,i=e%256,o.push(i),o.push(n);return o}(r,t.length-e),t,e,n)}function B(t,r,e){return 0===r&&e===t.length?n.fromByteArray(t):n.fromByteArray(t.slice(r,e))}function A(t,r,e){e=Math.min(t.length,e);for(var n=[],i=r;i239?4:h>223?3:h>191?2:1;if(i+p<=e)switch(p){case 1:h<128&&(a=h);break;case 2:128==(192&(o=t[i+1]))&&(s=(31&h)<<6|63&o)>127&&(a=s);break;case 3:o=t[i+1],f=t[i+2],128==(192&o)&&128==(192&f)&&(s=(15&h)<<12|(63&o)<<6|63&f)>2047&&(s<55296||s>57343)&&(a=s);break;case 4:o=t[i+1],f=t[i+2],u=t[i+3],128==(192&o)&&128==(192&f)&&128==(192&u)&&(s=(15&h)<<18|(63&o)<<12|(63&f)<<6|63&u)>65535&&s<1114112&&(a=s)}null===a?(a=65533,p=1):a>65535&&(a-=65536,n.push(a>>>10&1023|55296),a=56320|1023&a),n.push(a),i+=p}return function(t){var r=t.length;if(r<=U)return String.fromCharCode.apply(String,t);var e="",n=0;for(;nthis.length)return"";if((void 0===e||e>this.length)&&(e=this.length),e<=0)return"";if((e>>>=0)<=(r>>>=0))return"";for(t||(t="utf8");;)switch(t){case"hex":return S(this,r,e);case"utf8":case"utf-8":return A(this,r,e);case"ascii":return T(this,r,e);case"latin1":case"binary":return I(this,r,e);case"base64":return B(this,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return L(this,r,e);default:if(n)throw new TypeError("Unknown encoding: "+t);t=(t+"").toLowerCase(),n=!0}}.apply(this,arguments)},r.prototype.toLocaleString=r.prototype.toString,r.prototype.equals=function(t){if(!r.isBuffer(t))throw new TypeError("Argument must be a Buffer");return this===t||0===r.compare(this,t)},r.prototype.inspect=function(){var t="",r=e.INSPECT_MAX_BYTES;return t=this.toString("hex",0,r).replace(/(.{2})/g,"$1 ").trim(),this.length>r&&(t+=" ... "),""},r.prototype.compare=function(t,e,n,i,o){if(z(t,Uint8Array)&&(t=r.from(t,t.offset,t.byteLength)),!r.isBuffer(t))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof t);if(void 0===e&&(e=0),void 0===n&&(n=t?t.length:0),void 0===i&&(i=0),void 0===o&&(o=this.length),e<0||n>t.length||i<0||o>this.length)throw new RangeError("out of range index");if(i>=o&&e>=n)return 0;if(i>=o)return-1;if(e>=n)return 1;if(this===t)return 0;for(var f=(o>>>=0)-(i>>>=0),u=(n>>>=0)-(e>>>=0),s=Math.min(f,u),h=this.slice(i,o),a=t.slice(e,n),p=0;p>>=0,isFinite(e)?(e>>>=0,void 0===n&&(n="utf8")):(n=e,e=void 0)}var i=this.length-r;if((void 0===e||e>i)&&(e=i),t.length>0&&(e<0||r<0)||r>this.length)throw new RangeError("Attempt to write outside buffer bounds");n||(n="utf8");for(var o=!1;;)switch(n){case"hex":return w(this,t,r,e);case"utf8":case"utf-8":return d(this,t,r,e);case"ascii":return v(this,t,r,e);case"latin1":case"binary":return b(this,t,r,e);case"base64":return m(this,t,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return E(this,t,r,e);default:if(o)throw new TypeError("Unknown encoding: "+n);n=(""+n).toLowerCase(),o=!0}},r.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var U=4096;function T(t,r,e){var n="";e=Math.min(t.length,e);for(var i=r;in)&&(e=n);for(var i="",o=r;oe)throw new RangeError("Trying to access beyond buffer length")}function C(t,e,n,i,o,f){if(!r.isBuffer(t))throw new TypeError('"buffer" argument must be a Buffer instance');if(e>o||et.length)throw new RangeError("Index out of range")}function O(t,r,e,n,i,o){if(e+n>t.length)throw new RangeError("Index out of range");if(e<0)throw new RangeError("Index out of range")}function _(t,r,e,n,o){return r=+r,e>>>=0,o||O(t,0,e,4),i.write(t,r,e,n,23,4),e+4}function x(t,r,e,n,o){return r=+r,e>>>=0,o||O(t,0,e,8),i.write(t,r,e,n,52,8),e+8}r.prototype.slice=function(t,e){var n=this.length;(t=~~t)<0?(t+=n)<0&&(t=0):t>n&&(t=n),(e=void 0===e?n:~~e)<0?(e+=n)<0&&(e=0):e>n&&(e=n),e>>=0,r>>>=0,e||R(t,r,this.length);for(var n=this[t],i=1,o=0;++o>>=0,r>>>=0,e||R(t,r,this.length);for(var n=this[t+--r],i=1;r>0&&(i*=256);)n+=this[t+--r]*i;return n},r.prototype.readUInt8=function(t,r){return t>>>=0,r||R(t,1,this.length),this[t]},r.prototype.readUInt16LE=function(t,r){return t>>>=0,r||R(t,2,this.length),this[t]|this[t+1]<<8},r.prototype.readUInt16BE=function(t,r){return t>>>=0,r||R(t,2,this.length),this[t]<<8|this[t+1]},r.prototype.readUInt32LE=function(t,r){return t>>>=0,r||R(t,4,this.length),(this[t]|this[t+1]<<8|this[t+2]<<16)+16777216*this[t+3]},r.prototype.readUInt32BE=function(t,r){return t>>>=0,r||R(t,4,this.length),16777216*this[t]+(this[t+1]<<16|this[t+2]<<8|this[t+3])},r.prototype.readIntLE=function(t,r,e){t>>>=0,r>>>=0,e||R(t,r,this.length);for(var n=this[t],i=1,o=0;++o=(i*=128)&&(n-=Math.pow(2,8*r)),n},r.prototype.readIntBE=function(t,r,e){t>>>=0,r>>>=0,e||R(t,r,this.length);for(var n=r,i=1,o=this[t+--n];n>0&&(i*=256);)o+=this[t+--n]*i;return o>=(i*=128)&&(o-=Math.pow(2,8*r)),o},r.prototype.readInt8=function(t,r){return t>>>=0,r||R(t,1,this.length),128&this[t]?-1*(255-this[t]+1):this[t]},r.prototype.readInt16LE=function(t,r){t>>>=0,r||R(t,2,this.length);var e=this[t]|this[t+1]<<8;return 32768&e?4294901760|e:e},r.prototype.readInt16BE=function(t,r){t>>>=0,r||R(t,2,this.length);var e=this[t+1]|this[t]<<8;return 32768&e?4294901760|e:e},r.prototype.readInt32LE=function(t,r){return t>>>=0,r||R(t,4,this.length),this[t]|this[t+1]<<8|this[t+2]<<16|this[t+3]<<24},r.prototype.readInt32BE=function(t,r){return t>>>=0,r||R(t,4,this.length),this[t]<<24|this[t+1]<<16|this[t+2]<<8|this[t+3]},r.prototype.readFloatLE=function(t,r){return t>>>=0,r||R(t,4,this.length),i.read(this,t,!0,23,4)},r.prototype.readFloatBE=function(t,r){return t>>>=0,r||R(t,4,this.length),i.read(this,t,!1,23,4)},r.prototype.readDoubleLE=function(t,r){return t>>>=0,r||R(t,8,this.length),i.read(this,t,!0,52,8)},r.prototype.readDoubleBE=function(t,r){return t>>>=0,r||R(t,8,this.length),i.read(this,t,!1,52,8)},r.prototype.writeUIntLE=function(t,r,e,n){(t=+t,r>>>=0,e>>>=0,n)||C(this,t,r,e,Math.pow(2,8*e)-1,0);var i=1,o=0;for(this[r]=255&t;++o>>=0,e>>>=0,n)||C(this,t,r,e,Math.pow(2,8*e)-1,0);var i=e-1,o=1;for(this[r+i]=255&t;--i>=0&&(o*=256);)this[r+i]=t/o&255;return r+e},r.prototype.writeUInt8=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,1,255,0),this[r]=255&t,r+1},r.prototype.writeUInt16LE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,2,65535,0),this[r]=255&t,this[r+1]=t>>>8,r+2},r.prototype.writeUInt16BE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,2,65535,0),this[r]=t>>>8,this[r+1]=255&t,r+2},r.prototype.writeUInt32LE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,4,4294967295,0),this[r+3]=t>>>24,this[r+2]=t>>>16,this[r+1]=t>>>8,this[r]=255&t,r+4},r.prototype.writeUInt32BE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,4,4294967295,0),this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t,r+4},r.prototype.writeIntLE=function(t,r,e,n){if(t=+t,r>>>=0,!n){var i=Math.pow(2,8*e-1);C(this,t,r,e,i-1,-i)}var o=0,f=1,u=0;for(this[r]=255&t;++o>0)-u&255;return r+e},r.prototype.writeIntBE=function(t,r,e,n){if(t=+t,r>>>=0,!n){var i=Math.pow(2,8*e-1);C(this,t,r,e,i-1,-i)}var o=e-1,f=1,u=0;for(this[r+o]=255&t;--o>=0&&(f*=256);)t<0&&0===u&&0!==this[r+o+1]&&(u=1),this[r+o]=(t/f>>0)-u&255;return r+e},r.prototype.writeInt8=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,1,127,-128),t<0&&(t=255+t+1),this[r]=255&t,r+1},r.prototype.writeInt16LE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,2,32767,-32768),this[r]=255&t,this[r+1]=t>>>8,r+2},r.prototype.writeInt16BE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,2,32767,-32768),this[r]=t>>>8,this[r+1]=255&t,r+2},r.prototype.writeInt32LE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,4,2147483647,-2147483648),this[r]=255&t,this[r+1]=t>>>8,this[r+2]=t>>>16,this[r+3]=t>>>24,r+4},r.prototype.writeInt32BE=function(t,r,e){return t=+t,r>>>=0,e||C(this,t,r,4,2147483647,-2147483648),t<0&&(t=4294967295+t+1),this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t,r+4},r.prototype.writeFloatLE=function(t,r,e){return _(this,t,r,!0,e)},r.prototype.writeFloatBE=function(t,r,e){return _(this,t,r,!1,e)},r.prototype.writeDoubleLE=function(t,r,e){return x(this,t,r,!0,e)},r.prototype.writeDoubleBE=function(t,r,e){return x(this,t,r,!1,e)},r.prototype.copy=function(t,e,n,i){if(!r.isBuffer(t))throw new TypeError("argument should be a Buffer");if(n||(n=0),i||0===i||(i=this.length),e>=t.length&&(e=t.length),e||(e=0),i>0&&i=this.length)throw new RangeError("Index out of range");if(i<0)throw new RangeError("sourceEnd out of bounds");i>this.length&&(i=this.length),t.length-e=0;--f)t[f+e]=this[f+n];else Uint8Array.prototype.set.call(t,this.subarray(n,i),e);return o},r.prototype.fill=function(t,e,n,i){if("string"==typeof t){if("string"==typeof e?(i=e,e=0,n=this.length):"string"==typeof n&&(i=n,n=this.length),void 0!==i&&"string"!=typeof i)throw new TypeError("encoding must be a string");if("string"==typeof i&&!r.isEncoding(i))throw new TypeError("Unknown encoding: "+i);if(1===t.length){var o=t.charCodeAt(0);("utf8"===i&&o<128||"latin1"===i)&&(t=o)}}else"number"==typeof t&&(t&=255);if(e<0||this.length>>=0,n=void 0===n?this.length:n>>>0,t||(t=0),"number"==typeof t)for(f=e;f55295&&e<57344){if(!i){if(e>56319){(r-=3)>-1&&o.push(239,191,189);continue}if(f+1===n){(r-=3)>-1&&o.push(239,191,189);continue}i=e;continue}if(e<56320){(r-=3)>-1&&o.push(239,191,189),i=e;continue}e=65536+(i-55296<<10|e-56320)}else i&&(r-=3)>-1&&o.push(239,191,189);if(i=null,e<128){if((r-=1)<0)break;o.push(e)}else if(e<2048){if((r-=2)<0)break;o.push(e>>6|192,63&e|128)}else if(e<65536){if((r-=3)<0)break;o.push(e>>12|224,e>>6&63|128,63&e|128)}else{if(!(e<1114112))throw new Error("Invalid code point");if((r-=4)<0)break;o.push(e>>18|240,e>>12&63|128,e>>6&63|128,63&e|128)}}return o}function j(t){return n.toByteArray(function(t){if((t=(t=t.split("=")[0]).trim().replace(M,"")).length<2)return"";for(;t.length%4!=0;)t+="=";return t}(t))}function N(t,r,e,n){for(var i=0;i=r.length||i>=t.length);++i)r[i+e]=t[i];return i}function z(t,r){return t instanceof r||null!=t&&null!=t.constructor&&null!=t.constructor.name&&t.constructor.name===r.name}function D(t){return t!=t}}).call(this,t("buffer").Buffer)},{"base64-js":4,buffer:5,ieee754:6}],6:[function(t,r,e){arguments[4][3][0].apply(e,arguments)},{dup:3}]},{},[1])(1)});
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Twoblocks",
3 | "name": "Twoblocks",
4 | "icons": [
5 | {
6 | "src": "https://twoblocks.leopradel.com/icon-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/public/undraw_authentication_fsn5.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/copy-lib.sh:
--------------------------------------------------------------------------------
1 | cp ./node_modules/@otplib/preset-browser/buffer.js public/lib/otplib-browser-buffer.js
2 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useMemo, useCallback } from 'react';
2 | import * as Sentry from '@sentry/react';
3 | import { ThemeProvider } from '@material-ui/styles';
4 | import { createTheme } from '@material-ui/core/styles';
5 | import { CssBaseline } from '@material-ui/core';
6 | import * as Fathom from 'fathom-client';
7 | import { showConnect } from '@stacks/connect';
8 | import { Login } from './components/Login';
9 | import { Home } from './components/Home';
10 | import { Loader } from './components/Loader';
11 | import { ThemeContext, themeStorageKey } from './context/ThemeContext';
12 | import { FileContextProvider } from './context/FileContext';
13 | import { userSession } from './utils/blockstack';
14 | import { Goals } from './utils/fathom';
15 | import { config } from './config';
16 |
17 | // Track when page is loaded
18 | const FathomTrack = () => {
19 | useEffect(() => {
20 | if (config.fathomSiteId) {
21 | Fathom.load(config.fathomSiteId);
22 | Fathom.trackPageview();
23 | }
24 | }, []);
25 |
26 | return ;
27 | };
28 |
29 | const App = () => {
30 | const localTheme = localStorage.getItem('theme');
31 | const [theme, setTheme] = useState<'light' | 'dark'>(
32 | localTheme === 'dark' ? 'dark' : 'light'
33 | );
34 | const [loggedIn, setLoggedIn] = useState(!!userSession.isUserSignedIn());
35 | const [loggingIn, setLoggingIn] = useState(!!userSession.isSignInPending());
36 |
37 | const muiTheme = useMemo(
38 | () =>
39 | createTheme({
40 | palette: {
41 | type: theme,
42 | },
43 | }),
44 | [theme]
45 | );
46 |
47 | const handleChangeTheme = (data: 'light' | 'dark') => {
48 | setTheme(data);
49 | localStorage.setItem(themeStorageKey, data);
50 | };
51 |
52 | const handleLogin = useCallback(() => {
53 | Fathom.trackGoal(Goals.LOGIN, 0);
54 | showConnect({
55 | redirectTo: '/',
56 | appDetails: {
57 | name: 'Twoblocks',
58 | icon: 'https://twoblocks.leopradel.com/icon-192x192.png',
59 | },
60 | onFinish: () => {
61 | setLoggingIn(false);
62 | setLoggedIn(true);
63 | },
64 | });
65 | }, [setLoggingIn]);
66 |
67 | useEffect(() => {
68 | if (userSession.isSignInPending()) {
69 | userSession
70 | .handlePendingSignIn()
71 | .then(() => {
72 | setLoggingIn(false);
73 | setLoggedIn(true);
74 | })
75 | .catch((error: any) => {
76 | setLoggingIn(false);
77 | alert(error.message);
78 | });
79 | }
80 | }, []);
81 |
82 | return (
83 |
84 |
85 |
86 |
87 | {!loggingIn && !loggedIn && }
88 | {!loggingIn && loggedIn && (
89 |
90 |
91 |
92 | )}
93 | {loggingIn && }
94 |
95 |
96 | );
97 | };
98 |
99 | const FallbackComponent = () => {
100 | return An error has occurred
;
101 | };
102 |
103 | const AppWithError = () => {
104 | return (
105 |
106 |
107 |
108 | );
109 | };
110 |
111 | export default AppWithError;
112 |
--------------------------------------------------------------------------------
/src/components/AccountItem.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from 'react';
2 | import {
3 | Typography,
4 | IconButton,
5 | Menu,
6 | MenuItem,
7 | Theme,
8 | } from '@material-ui/core';
9 | import { makeStyles } from '@material-ui/styles';
10 | import { MoreVert } from '@material-ui/icons';
11 | import { authenticator } from '@otplib/preset-browser';
12 | import { Account } from '../types';
13 | import { EditAccount } from './EditAccount';
14 | import { DeleteAccount } from './DeleteAccount';
15 | import { ThemeContext } from '../context/ThemeContext';
16 | import { icons } from '../utils/icons';
17 |
18 | const useStyles = makeStyles((theme: Theme) => ({
19 | iconContainer: {
20 | display: 'flex',
21 | alignItems: 'center',
22 | marginRight: theme.spacing(2),
23 | },
24 | leftContainer: {
25 | flex: 1,
26 | marginTop: theme.spacing(),
27 | },
28 | name: {
29 | marginBottom: theme.spacing(),
30 | },
31 | container: {
32 | marginTop: theme.spacing(2),
33 | marginBottom: theme.spacing(2),
34 | paddingTop: theme.spacing(),
35 | paddingBottom: theme.spacing(),
36 | paddingLeft: theme.spacing(2),
37 | paddingRight: theme.spacing(2),
38 | display: 'flex',
39 | flexDirection: 'row',
40 | backgroundColor: theme.palette.background.paper,
41 | },
42 | timer: {
43 | display: 'flex',
44 | alignItems: 'center',
45 | flexDirection: 'column',
46 | },
47 | }));
48 |
49 | interface Props {
50 | index: number;
51 | account: Account;
52 | remainingSeconds: number;
53 | }
54 |
55 | export const AccountItem = (props: Props) => {
56 | const classes = useStyles();
57 | const [anchorEl, setAnchorEl] = useState(null);
58 | const open = Boolean(anchorEl);
59 | const [editModalOpen, setEditModalOpen] = useState(false);
60 | const [deleteModalOpen, setDeleteModalOpen] = useState(false);
61 | const theme = useContext(ThemeContext);
62 |
63 | const handleRequestDelete = () => {
64 | setAnchorEl(null);
65 | setDeleteModalOpen(true);
66 | };
67 |
68 | const handleRequestEdit = () => {
69 | setAnchorEl(null);
70 | setEditModalOpen(true);
71 | };
72 |
73 | let code;
74 | try {
75 | code = authenticator.generate(props.account.secret); // eslint-disable-line
76 | // Insert a space in the middle of the code for better readability
77 | code = [code.slice(0, 3), ' ', code.slice(3)].join('');
78 | } catch (error) {
79 | code = error.message;
80 | }
81 |
82 | return (
83 |
84 |
85 | {props.account.icon && (
86 |
87 |

92 |
93 | )}
94 |
95 |
96 | {props.account.name}
97 |
98 |
102 | {code}
103 |
104 |
105 |
106 | setAnchorEl(event.currentTarget)}
110 | >
111 |
112 |
113 |
122 | {props.remainingSeconds}
123 |
124 |
125 |
126 | setEditModalOpen(false)}
129 | accountIndex={props.index}
130 | account={props.account}
131 | />
132 |
133 | setDeleteModalOpen(false)}
136 | accountIndex={props.index}
137 | account={props.account}
138 | />
139 |
140 | );
141 | };
142 |
--------------------------------------------------------------------------------
/src/components/AccountList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { authenticator } from '@otplib/preset-browser';
3 | import { File } from '../utils/accounts';
4 | import { AccountItem } from './AccountItem';
5 |
6 | interface Props {
7 | file: File;
8 | }
9 |
10 | export const AccountList = (props: Props) => {
11 | const [seconds, setSeconds] = useState(0);
12 |
13 | useEffect(() => {
14 | const intervalId = setInterval(() => {
15 | setSeconds(authenticator.timeRemaining()); // eslint-disable-line
16 | }, 1000);
17 |
18 | return function cleanup() {
19 | clearInterval(intervalId);
20 | };
21 | }, []);
22 |
23 | return (
24 |
25 | {props.file.accounts.map((account, index) => (
26 |
32 | ))}
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/src/components/AddAccount.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import {
3 | Dialog,
4 | Slide,
5 | AppBar,
6 | Toolbar,
7 | IconButton,
8 | Typography,
9 | Grid,
10 | Button,
11 | TextField,
12 | Theme,
13 | FormControl,
14 | InputLabel,
15 | Select,
16 | MenuItem,
17 | } from '@material-ui/core';
18 | import { ArrowBack } from '@material-ui/icons';
19 | import { makeStyles } from '@material-ui/styles';
20 | import { Loader } from './Loader';
21 | import { icons } from '../utils/icons';
22 | import { FileContext } from '../context/FileContext';
23 |
24 | const useStyles = makeStyles((theme: Theme) => ({
25 | flex: {
26 | flex: 1,
27 | },
28 | loadingContainer: {
29 | marginTop: 56 + theme.spacing(2),
30 | },
31 | container: {
32 | marginTop: 56,
33 | },
34 | formContainer: {
35 | marginLeft: theme.spacing(2),
36 | marginRight: theme.spacing(2),
37 | display: 'flex',
38 | flexDirection: 'column',
39 | },
40 | formControlIcon: {
41 | marginTop: theme.spacing(2),
42 | marginBottom: theme.spacing(1),
43 | },
44 | selectMenuIcon: {
45 | display: 'flex',
46 | alignItems: 'center',
47 | },
48 | iconImage: {
49 | marginRight: theme.spacing(2),
50 | },
51 | }));
52 |
53 | interface Props {
54 | open: boolean;
55 | onClose: () => void;
56 | }
57 |
58 | function Transition(props: any) {
59 | return ;
60 | }
61 |
62 | export const AddAccount = ({ open, onClose }: Props) => {
63 | const classes = useStyles();
64 |
65 | const { addAccount } = useContext(FileContext);
66 |
67 | const [values, setValues] = useState({
68 | name: '',
69 | secret: '',
70 | icon: '',
71 | });
72 | const [errors, setErrors] = useState({
73 | name: false,
74 | secret: false,
75 | });
76 | const [loading, setLoading] = useState(false);
77 |
78 | const reset = () => {
79 | setValues({
80 | name: '',
81 | secret: '',
82 | icon: '',
83 | });
84 | setErrors({ name: false, secret: false });
85 | setLoading(false);
86 | };
87 |
88 | const handleChange = (name: string) => (event: any) => {
89 | setValues({ ...values, [name]: event.target.value });
90 | };
91 |
92 | const handleSubmit = async (e: React.FormEvent) => {
93 | e.preventDefault();
94 |
95 | setErrors({ name: false, secret: false });
96 |
97 | if (!values.name || values.name === '') {
98 | setErrors({ ...errors, name: true });
99 | return;
100 | }
101 | if (!values.secret || values.secret === '') {
102 | setErrors({ ...errors, secret: true });
103 | return;
104 | }
105 | try {
106 | setLoading(true);
107 | await addAccount(values);
108 |
109 | reset();
110 | onClose();
111 | } catch (error) {
112 | setLoading(false);
113 | alert(error.message);
114 | }
115 | };
116 |
117 | return (
118 |
192 | );
193 | };
194 |
--------------------------------------------------------------------------------
/src/components/AddAccountScan.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import {
3 | Dialog,
4 | Slide,
5 | AppBar,
6 | Toolbar,
7 | IconButton,
8 | Typography,
9 | Grid,
10 | Theme,
11 | } from '@material-ui/core';
12 | import { ArrowBack } from '@material-ui/icons';
13 | import { makeStyles } from '@material-ui/styles';
14 | import QrReader from 'react-qr-reader';
15 | import * as queryString from 'query-string';
16 | import { Loader } from './Loader';
17 | import { FileContext } from '../context/FileContext';
18 |
19 | const useStyles = makeStyles((theme: Theme) => ({
20 | flex: {
21 | flex: 1,
22 | },
23 | loadingContainer: {
24 | marginTop: 56 + theme.spacing(2),
25 | },
26 | errorContainer: {
27 | marginTop: 56 + theme.spacing(2),
28 | paddingLeft: theme.spacing(2),
29 | paddingRight: theme.spacing(2),
30 | },
31 | container: {
32 | marginTop: 56,
33 | },
34 | }));
35 |
36 | interface Props {
37 | open: boolean;
38 | onClose: () => void;
39 | }
40 |
41 | function Transition(props: any) {
42 | return ;
43 | }
44 |
45 | export const AddAccountScan = ({ open, onClose }: Props) => {
46 | const classes = useStyles();
47 |
48 | const { addAccount } = useContext(FileContext);
49 |
50 | const [loading, setLoading] = useState(false);
51 | const [error, setError] = useState(null);
52 |
53 | const handleClose = () => {
54 | onClose();
55 | setLoading(false);
56 | setError(null);
57 | };
58 |
59 | const handleError = (error: any) => {
60 | console.error(error);
61 | setError(error.message);
62 | setLoading(false);
63 | };
64 |
65 | const handleScan = async (scanned: string | null) => {
66 | if (scanned) {
67 | setLoading(true);
68 | const parsed = queryString.parseUrl(scanned);
69 | // Verify the url is valid
70 | if (!parsed.url.startsWith('otpauth://') && !parsed.query.secret) {
71 | alert('Invalid code');
72 | console.error(parsed);
73 | setLoading(false);
74 | return;
75 | }
76 |
77 | try {
78 | const parsedUrl = parsed.url.split('/');
79 | const accountName = parsedUrl[parsedUrl.length - 1];
80 |
81 | await addAccount({
82 | name: accountName,
83 | secret: parsed.query.secret as string,
84 | });
85 |
86 | onClose();
87 | } catch (error) {
88 | handleError(error);
89 | }
90 | }
91 | };
92 |
93 | return (
94 |
134 | );
135 | };
136 |
--------------------------------------------------------------------------------
/src/components/DeleteAccount.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import {
3 | Dialog,
4 | DialogTitle,
5 | DialogContent,
6 | DialogContentText,
7 | DialogActions,
8 | Button,
9 | } from '@material-ui/core';
10 | import { Account } from '../types';
11 | import { Loader } from './Loader';
12 | import { FileContext } from '../context/FileContext';
13 |
14 | interface Props {
15 | open: boolean;
16 | onClose: () => void;
17 | account: Account;
18 | accountIndex: number;
19 | }
20 |
21 | export const DeleteAccount = ({
22 | open,
23 | onClose,
24 | account,
25 | accountIndex,
26 | }: Props) => {
27 | const { removeAccount } = useContext(FileContext);
28 |
29 | const [loading, setLoading] = useState(false);
30 |
31 | const reset = () => {
32 | setLoading(false);
33 | };
34 |
35 | const handleDelete = async () => {
36 | try {
37 | setLoading(true);
38 | await removeAccount(accountIndex);
39 | onClose();
40 | reset();
41 | } catch (error) {
42 | setLoading(false);
43 | alert(error.message);
44 | }
45 | };
46 |
47 | return (
48 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/components/EditAccount.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import {
3 | Dialog,
4 | Slide,
5 | AppBar,
6 | Toolbar,
7 | IconButton,
8 | Typography,
9 | Grid,
10 | Button,
11 | TextField,
12 | Theme,
13 | FormControl,
14 | InputLabel,
15 | Select,
16 | MenuItem,
17 | } from '@material-ui/core';
18 | import { ArrowBack } from '@material-ui/icons';
19 | import { makeStyles } from '@material-ui/styles';
20 | import { Account } from '../types';
21 | import { Loader } from './Loader';
22 | import { icons } from '../utils/icons';
23 | import { FileContext } from '../context/FileContext';
24 |
25 | const useStyles = makeStyles((theme: Theme) => ({
26 | flex: {
27 | flex: 1,
28 | },
29 | loadingContainer: {
30 | marginTop: 56 + theme.spacing(2),
31 | },
32 | container: {
33 | marginTop: 56,
34 | },
35 | formContainer: {
36 | marginLeft: theme.spacing(2),
37 | marginRight: theme.spacing(2),
38 | display: 'flex',
39 | flexDirection: 'column',
40 | },
41 | formControlIcon: {
42 | marginTop: theme.spacing(2),
43 | marginBottom: theme.spacing(1),
44 | },
45 | selectMenuIcon: {
46 | display: 'flex',
47 | alignItems: 'center',
48 | },
49 | iconImage: {
50 | marginRight: theme.spacing(2),
51 | },
52 | }));
53 |
54 | interface Props {
55 | open: boolean;
56 | onClose: () => void;
57 | account: Account;
58 | accountIndex: number;
59 | }
60 |
61 | function Transition(props: any) {
62 | return ;
63 | }
64 |
65 | export const EditAccount = ({
66 | open,
67 | onClose,
68 | accountIndex,
69 | account,
70 | }: Props) => {
71 | const classes = useStyles();
72 |
73 | const { editAccount } = useContext(FileContext);
74 |
75 | const [values, setValues] = useState({
76 | name: account.name,
77 | icon: account.icon,
78 | });
79 | const [errors, setErrors] = useState({
80 | name: false,
81 | });
82 | const [loading, setLoading] = useState(false);
83 |
84 | const reset = () => {
85 | setErrors({ name: false });
86 | setLoading(false);
87 | };
88 |
89 | const handleChange = (name: string) => (event: any) => {
90 | setValues({ ...values, [name]: event.target.value });
91 | };
92 |
93 | const handleSubmit = async (e: React.FormEvent) => {
94 | e.preventDefault();
95 |
96 | setErrors({ name: false });
97 |
98 | if (!values.name || values.name === '') {
99 | setErrors({ ...errors, name: true });
100 | return;
101 | }
102 |
103 | try {
104 | setLoading(true);
105 | await editAccount(accountIndex, { ...account, ...values });
106 |
107 | reset();
108 | onClose();
109 | } catch (error) {
110 | setLoading(false);
111 | alert(error.message);
112 | }
113 | };
114 |
115 | return (
116 |
181 | );
182 | };
183 |
--------------------------------------------------------------------------------
/src/components/Home.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from 'react';
2 | import {
3 | AppBar,
4 | Toolbar,
5 | Typography,
6 | CircularProgress,
7 | Grid,
8 | IconButton,
9 | Menu,
10 | MenuItem,
11 | Theme,
12 | Link,
13 | } from '@material-ui/core';
14 | import { SpeedDial, SpeedDialAction, SpeedDialIcon } from '@material-ui/lab';
15 | import { makeStyles } from '@material-ui/styles';
16 | import { MoreVert, Keyboard, PhotoCamera } from '@material-ui/icons';
17 | import { AccountList } from './AccountList';
18 | import { AddAccount } from './AddAccount';
19 | import { AddAccountScan } from './AddAccountScan';
20 | import { ThemeContext } from '../context/ThemeContext';
21 | import { FileContext } from '../context/FileContext';
22 | import { userSession } from '../utils/blockstack';
23 |
24 | const useStyles = makeStyles((theme: Theme) => ({
25 | flex: {
26 | flex: 1,
27 | },
28 | fab: {
29 | position: 'fixed',
30 | bottom: theme.spacing(2),
31 | right: theme.spacing(2),
32 | },
33 | container: {
34 | marginTop: 56,
35 | },
36 | loadingContainer: {
37 | display: 'flex',
38 | justifyContent: 'center',
39 | marginTop: theme.spacing(2),
40 | },
41 | emptyContainer: {
42 | display: 'flex',
43 | justifyContent: 'center',
44 | marginTop: theme.spacing(2),
45 | flexDirection: 'column',
46 | },
47 | emptyImage: {
48 | height: 130,
49 | maxWidth: '100%',
50 | marginTop: theme.spacing(2),
51 | marginBottom: theme.spacing(2),
52 | },
53 | links: {
54 | marginTop: theme.spacing(1),
55 | marginBottom: theme.spacing(1),
56 | },
57 | linksDivider: {
58 | marginLeft: theme.spacing(1),
59 | marginRight: theme.spacing(1),
60 | },
61 | }));
62 |
63 | interface Props {
64 | setTheme: (theme: 'light' | 'dark') => void;
65 | }
66 |
67 | export const Home = ({ setTheme }: Props) => {
68 | const classes = useStyles();
69 |
70 | const theme = useContext(ThemeContext);
71 | const { file } = useContext(FileContext);
72 |
73 | const [speedDialOpen, setSpeedDialOpen] = useState(false);
74 | const [addAccountScanOpen, setAddAccountScanOpen] = useState(false);
75 | const [addAccountModalOpen, setAddAccountModalOpen] = useState(false);
76 | const [anchorEl, setAnchorEl] = useState(null);
77 | const menuOpen = Boolean(anchorEl);
78 |
79 | const handleSpeedDialClick = () => {
80 | setSpeedDialOpen(!speedDialOpen);
81 | };
82 |
83 | const handleSpeedDialClose = () => {
84 | setSpeedDialOpen(false);
85 | };
86 |
87 | const handleSelectCamera = () => {
88 | handleSpeedDialClose();
89 | setAddAccountScanOpen(true);
90 | };
91 |
92 | const handleSelectManual = () => {
93 | handleSpeedDialClose();
94 | setAddAccountModalOpen(true);
95 | };
96 |
97 | const handleSelectLightTheme = () => {
98 | setTheme('light');
99 | setAnchorEl(null);
100 | };
101 |
102 | const handleSelectDarkTheme = () => {
103 | setTheme('dark');
104 | setAnchorEl(null);
105 | };
106 |
107 | const handleLogout = () => {
108 | userSession.signUserOut(window.location.origin);
109 | };
110 |
111 | return (
112 |
113 |
114 |
115 |
116 | Twoblocks
117 |
118 |
119 | setAnchorEl(event.currentTarget)}
123 | color="inherit"
124 | >
125 |
126 |
127 |
141 |
142 |
143 |
144 |
145 | {!file && (
146 |
147 |
148 |
149 | )}
150 |
151 | {file && file.accounts.length === 0 && (
152 |
153 |
158 |
159 | Empty account list
160 |
161 |
162 | Use the + button to add a new account
163 |
164 |
165 | )}
166 |
167 | {file && (
168 |
169 |
170 |
171 | )}
172 |
173 |
179 | Twitter
180 | |
181 | Github
182 | |
183 |
186 | {process.env.NODE_ENV === 'development'
187 | ? 'Development'
188 | : `${
189 | process.env.REACT_APP_COMMIT_REF &&
190 | process.env.REACT_APP_COMMIT_REF.substring(0, 6)
191 | }...`}
192 |
193 |
194 |
195 |
196 |
197 | setAddAccountModalOpen(false)}
200 | />
201 |
202 | setAddAccountScanOpen(false)}
205 | />
206 |
207 | {file && (
208 | }
212 | open={speedDialOpen}
213 | onClick={handleSpeedDialClick}
214 | onClose={handleSpeedDialClose}
215 | >
216 | }
218 | tooltipTitle="Scan a barcode"
219 | tooltipOpen
220 | onClick={handleSelectCamera}
221 | />
222 | }
224 | tooltipTitle="Enter manually"
225 | tooltipOpen
226 | onClick={handleSelectManual}
227 | />
228 |
229 | )}
230 |
231 | );
232 | };
233 |
--------------------------------------------------------------------------------
/src/components/Loader.tsx:
--------------------------------------------------------------------------------
1 | import { Grid, CircularProgress, Theme } from '@material-ui/core';
2 | import { makeStyles } from '@material-ui/styles';
3 |
4 | const useStyles = makeStyles((theme: Theme) => ({
5 | container: {
6 | display: 'flex',
7 | justifyContent: 'center',
8 | marginTop: theme.spacing(2),
9 | marginBottom: theme.spacing(2),
10 | },
11 | }));
12 |
13 | interface Props {
14 | className?: string;
15 | }
16 |
17 | export const Loader = ({ className }: Props) => {
18 | const classes = useStyles();
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/Login.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Theme, Typography, Link, Button } from '@material-ui/core';
3 | import { makeStyles } from '@material-ui/styles';
4 |
5 | const useStyles = makeStyles((theme: Theme) => ({
6 | hero: {
7 | maxWidth: '760px',
8 | margin: '0 auto',
9 | marginTop: theme.spacing(8),
10 | marginBottom: theme.spacing(4),
11 | },
12 | container: {
13 | marginLeft: theme.spacing(4),
14 | marginRight: theme.spacing(4),
15 | [theme.breakpoints.up('sm')]: {
16 | display: 'flex',
17 | flexDirection: 'row',
18 | },
19 | },
20 | brand: {
21 | display: 'flex',
22 | flexDirection: 'column',
23 | justifyContent: 'center',
24 | [theme.breakpoints.up('sm')]: {
25 | flex: 1.2,
26 | paddingRight: theme.spacing(8),
27 | paddingBottom: theme.spacing(10),
28 | },
29 | },
30 | brandContent: {
31 | marginBottom: theme.spacing(2),
32 | },
33 | screen: {
34 | flex: 1,
35 | marginTop: theme.spacing(4),
36 | [theme.breakpoints.up('sm')]: {
37 | marginTop: 0,
38 | },
39 | },
40 | screenImage: {
41 | width: '100%',
42 | maxWidth: '100%',
43 | },
44 | }));
45 |
46 | interface LoginProps {
47 | onLogin: () => void;
48 | }
49 |
50 | export const Login = ({ onLogin }: LoginProps) => {
51 | const classes = useStyles();
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
59 | Twoblocks
60 |
61 |
62 | Free and{' '}
63 |
68 | open source
69 | {' '}
70 | 2fa manager built with{' '}
71 |
76 | Stacks
77 |
78 | . Protect yourself online!
79 |
80 |
83 |
84 |
85 |

90 |
91 |
92 |
93 |
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | fathomSiteId: process.env.REACT_APP_FATHOM_SITE_ID,
3 | sentryDsn: process.env.REACT_APP_SENTRY_DSN,
4 | };
5 |
--------------------------------------------------------------------------------
/src/context/FileContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useReducer, useEffect } from 'react';
2 | import * as Fathom from 'fathom-client';
3 | import { File, getFile, putFile } from '../utils/accounts';
4 | import { Account } from '../types';
5 | import { Goals } from '../utils/fathom';
6 |
7 | export const FileContext = createContext<{
8 | file?: File;
9 | loading?: boolean;
10 | setFile(file: File): void;
11 | addAccount(account: Account): Promise;
12 | editAccount(index: number, account: Account): Promise;
13 | removeAccount(index: number): Promise;
14 | }>(undefined as any);
15 |
16 | interface Props {
17 | children: React.ReactNode;
18 | }
19 |
20 | type Action =
21 | | { type: 'success'; file?: File }
22 | | { type: 'error'; error: string };
23 |
24 | interface State {
25 | file?: File;
26 | loading?: boolean;
27 | error?: string;
28 | }
29 |
30 | const initialState: State = {
31 | file: undefined,
32 | loading: true,
33 | error: undefined,
34 | };
35 |
36 | const reducer = (state: State, action: Action): State => {
37 | switch (action.type) {
38 | case 'success':
39 | return {
40 | ...state,
41 | loading: false,
42 | file: action.file,
43 | };
44 | case 'error':
45 | return { ...state, loading: false, error: action.error };
46 | default:
47 | throw new Error();
48 | }
49 | };
50 |
51 | export const FileContextProvider = ({ children }: Props) => {
52 | const [state, dispatch] = useReducer(reducer, initialState);
53 |
54 | const fetchFile = async () => {
55 | try {
56 | const file = await getFile();
57 | dispatch({ type: 'success', file });
58 | } catch (error) {
59 | dispatch({ type: 'error', error: error.message });
60 | alert(error.message);
61 | }
62 | };
63 |
64 | const setFile = (file: File) => {
65 | dispatch({ type: 'success', file });
66 | };
67 |
68 | /**
69 | * Add an account to the file and save it
70 | */
71 | const addAccount = async (account: Account) => {
72 | if (!state.file) return;
73 | Fathom.trackGoal(Goals.ADD_NEW_ACCOUNT, 0);
74 | const file = state.file;
75 | file.accounts.push(account);
76 | await putFile(file);
77 | dispatch({ type: 'success', file });
78 | };
79 |
80 | /**
81 | * Edit an account and save it
82 | */
83 | const editAccount = async (index: number, account: Account) => {
84 | if (!state.file) return;
85 | const file = state.file;
86 | file.accounts[index] = account;
87 | await putFile(file);
88 | dispatch({ type: 'success', file });
89 | };
90 |
91 | /**
92 | * Remove an account at a given index and save it
93 | */
94 | const removeAccount = async (index: number) => {
95 | if (!state.file) return;
96 | const file = state.file;
97 | file.accounts.splice(index, 1);
98 | await putFile(file);
99 | dispatch({ type: 'success', file });
100 | };
101 |
102 | useEffect(() => {
103 | fetchFile();
104 | }, []);
105 |
106 | return (
107 |
117 | {children}
118 |
119 | );
120 | };
121 |
--------------------------------------------------------------------------------
/src/context/ThemeContext.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const themeStorageKey = 'theme';
4 |
5 | export const ThemeContext = React.createContext<'light' | 'dark'>('light');
6 |
--------------------------------------------------------------------------------
/src/custom.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@otplib/preset-browser';
2 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { AppProps } from 'next/app';
2 | import Head from 'next/head';
3 |
4 | function MyApp({ Component, pageProps }: AppProps) {
5 | return (
6 | <>
7 |
8 |
12 |
13 | Twoblocks
14 |
18 |
19 |
20 | >
21 | );
22 | }
23 |
24 | export default MyApp;
25 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import type { NextPage } from 'next';
3 | import dynamic from 'next/dynamic';
4 | import Script from 'next/script';
5 | import * as Sentry from '@sentry/react';
6 | import 'typeface-roboto';
7 | import { config } from '../config';
8 |
9 | Sentry.init({
10 | dsn: config.sentryDsn,
11 | });
12 |
13 | const DynamicComponent = dynamic(() => import('../App'), { ssr: false });
14 |
15 | const Home: NextPage = () => {
16 | const [mounted, setMounted] = useState(false);
17 |
18 | useEffect(() => {
19 | setMounted(true);
20 | }, []);
21 |
22 | if (!mounted) {
23 | return null;
24 | }
25 |
26 | return (
27 | <>
28 |
29 |
30 | >
31 | );
32 | };
33 |
34 | export default Home;
35 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface Account {
2 | name: string;
3 | secret: string;
4 | icon?: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/accounts.ts:
--------------------------------------------------------------------------------
1 | import { Account } from '../types';
2 | import { storage } from './blockstack';
3 |
4 | const fileName = '2fa.json';
5 |
6 | export interface File {
7 | accounts: Account[];
8 | }
9 |
10 | /**
11 | * Return the file
12 | * Initialize the file if some values are missing
13 | */
14 | export const getFile = async (): Promise => {
15 | let file;
16 | try {
17 | file = (await storage.getFile(fileName)) as any;
18 | } catch (error) {
19 | // If 404 it means user is using the app for the first time
20 | if (error.code !== 'does_not_exist') {
21 | throw error;
22 | }
23 | }
24 | if (file) {
25 | file = JSON.parse(file);
26 | }
27 | if (!file) {
28 | file = {};
29 | }
30 | if (!file.accounts) {
31 | file.accounts = [];
32 | }
33 | return file;
34 | };
35 |
36 | /**
37 | * Save the file on the storage
38 | */
39 | export const putFile = async (file: File): Promise => {
40 | await storage.putFile(fileName, JSON.stringify(file));
41 | };
42 |
--------------------------------------------------------------------------------
/src/utils/blockstack.ts:
--------------------------------------------------------------------------------
1 | import { UserSession, AppConfig } from '@stacks/auth';
2 | import { Storage } from '@stacks/storage';
3 |
4 | export const appConfig = new AppConfig(['store_write']);
5 |
6 | export const userSession = new UserSession({ appConfig });
7 |
8 | export const storage = new Storage({ userSession });
9 |
--------------------------------------------------------------------------------
/src/utils/fathom.ts:
--------------------------------------------------------------------------------
1 | export enum Goals {
2 | LOGIN = 'VO8BRMV5',
3 | ADD_NEW_ACCOUNT = 'HTFXW3AD',
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/icons.ts:
--------------------------------------------------------------------------------
1 | // The icons svg are coming from https://svgporn.com
2 |
3 | export const icons: { [key: string]: { name: string; url: string } } = {
4 | airbnb: { name: 'Airbnb', url: 'https://cdn.svgporn.com/logos/airbnb.svg' },
5 | angellist: {
6 | name: 'AngelList',
7 | url: 'https://cdn.svgporn.com/logos/angellist.svg',
8 | },
9 | apple: { name: 'Apple', url: 'https://cdn.svgporn.com/logos/apple.svg' },
10 | bitcoin: {
11 | name: 'Bitcoin',
12 | url: 'https://cdn.svgporn.com/logos/bitcoin.svg',
13 | },
14 | campfire: {
15 | name: 'Campfire',
16 | url: 'https://cdn.svgporn.com/logos/campfire.svg',
17 | },
18 | circleci: {
19 | name: 'CircleCI',
20 | url: 'https://cdn.svgporn.com/logos/circleci.svg',
21 | },
22 | 'digital-ocean': {
23 | name: 'Digital Ocean',
24 | url: 'https://cdn.svgporn.com/logos/digital-ocean.svg',
25 | },
26 | discord: {
27 | name: 'Discord',
28 | url: 'https://cdn.svgporn.com/logos/discord.svg',
29 | },
30 | dropbox: {
31 | name: 'Dropbox',
32 | url: 'https://cdn.svgporn.com/logos/dropbox.svg',
33 | },
34 | facebook: {
35 | name: 'Facebook',
36 | url: 'https://cdn.svgporn.com/logos/facebook.svg',
37 | },
38 | firefox: {
39 | name: 'Firefox',
40 | url: 'https://cdn.svgporn.com/logos/firefox.svg',
41 | },
42 | gmail: {
43 | name: 'Gmail',
44 | url: 'https://cdn.svgporn.com/logos/google-gmail.svg',
45 | },
46 | github: {
47 | name: 'Github',
48 | url: 'https://cdn.svgporn.com/logos/github-icon.svg',
49 | },
50 | gitlab: {
51 | name: 'Gitlab',
52 | url: 'https://cdn.svgporn.com/logos/gitlab.svg',
53 | },
54 | google: {
55 | name: 'Google',
56 | url: 'https://cdn.svgporn.com/logos/google-icon.svg',
57 | },
58 | heroku: {
59 | name: 'Heroku',
60 | url: 'https://cdn.svgporn.com/logos/heroku.svg',
61 | },
62 | ifttt: {
63 | name: 'IFTTT',
64 | url: 'https://cdn.svgporn.com/logos/ifttt.svg',
65 | },
66 | kickstarter: {
67 | name: 'Kickstarter',
68 | url: 'https://cdn.svgporn.com/logos/kickstarter.svg',
69 | },
70 | npm: {
71 | name: 'npm',
72 | url: 'https://cdn.svgporn.com/logos/npm.svg',
73 | },
74 | slack: {
75 | name: 'Slack',
76 | url: 'https://cdn.svgporn.com/logos/slack-icon.svg',
77 | },
78 | tumblr: {
79 | name: 'Tumblr',
80 | url: 'https://cdn.svgporn.com/logos/tumblr.svg',
81 | },
82 | twitter: {
83 | name: 'Twitter',
84 | url: 'https://cdn.svgporn.com/logos/twitter.svg',
85 | },
86 | wordpress: {
87 | name: 'Wordpress',
88 | url: 'https://cdn.svgporn.com/logos/wordpress-icon.svg',
89 | },
90 | };
91 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "lib": ["dom", "es2017"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "useUnknownInCatchVariables": false
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------