mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[CORL-235] Allow a user to download their comment history (#2449)
* Create preliminary UI for requesting a user's comment history CORL-235 * Create preliminary download handler CORL-235 * Create preliminary CSV exporter for downloading user's comments CORL-235 * Create preliminary download comments flow with email and landing page User can download their comments after requesting via an email. Does not include limiters preventing users from spamming the download flow. CORL-235 * Style the download comments landing page to match the spec CORL-235 * Update the profile download comments to respect 14 day download limit CORL-235 * Hook up request limiter on the comment download end points. CORL-235 * Create sorry dialogue for when the download comments link is invalid/expired CORL-235 * Create loading spinner for comment download landing page CORL-235 * Create an optimistic updater for requesting a comment download CORL-235 * Add localization for download comments landing page's sorry text CORL-235 * Adjust downloaded comment format and contents - Format the date to match tenant local - Format dates to be using numeric values with date and time - Add comment URL to exported CSV record CORL-235 * Rename `createCSV` to `createExportedContent` in the download handler CORL-235 * Make comment download callout full width and left justified CORL-235 * Update stream snapshots to account for the download comments changes CORL-235 * Update snapshots to correspond to download comments changes CORL-235 * Add date filter to the comment download Will filter by date of the issued token to only include comments before that time. CORL-235 * Clean up the layout of the list items in the download comments landing page CORL-235 * fix: localization fixes * fix: formatting * fix: download file enhancements * fix: addressed download request loophole * Update snapshots to address button changes on download comments landing page CORL-235 * Simplify download comment landing page components CORL-235 * Remove max-width calc's on account main layout and set them in child views Sets the max-width settings on the download route since it's custom set on all the othere routes (email and password). CORL-235 * Bump npm version in package.json CORL-235
This commit is contained in:
Generated
+248
-14
@@ -3351,6 +3351,14 @@
|
||||
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/archiver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-3.0.0.tgz",
|
||||
"integrity": "sha512-orghAMOF+//wSg4ru2znk6jt0eIPvKTtMVLH7XcYcjbcRyAXRClDlh27QVdqnAvVM37yu9xDP6Nh7egRhNr8tQ==",
|
||||
"requires": {
|
||||
"@types/glob": "*"
|
||||
}
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.2.tgz",
|
||||
@@ -3710,7 +3718,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz",
|
||||
"integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/events": "*",
|
||||
"@types/minimatch": "*",
|
||||
@@ -3915,8 +3922,7 @@
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA=="
|
||||
},
|
||||
"@types/mongodb": {
|
||||
"version": "3.1.22",
|
||||
@@ -5044,6 +5050,100 @@
|
||||
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
|
||||
"dev": true
|
||||
},
|
||||
"archiver": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-3.0.3.tgz",
|
||||
"integrity": "sha512-d0W7NUyXoLklozHHfvWnHoHS3dvQk8eB22pv5tBwcu1jEO5eZY8W+gHytkAaJ0R8fU2TnNThrWYxjvFlKvRxpw==",
|
||||
"requires": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^2.6.3",
|
||||
"buffer-crc32": "^0.2.1",
|
||||
"glob": "^7.1.4",
|
||||
"readable-stream": "^3.4.0",
|
||||
"tar-stream": "^2.1.0",
|
||||
"zip-stream": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"async": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
|
||||
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
|
||||
"integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
|
||||
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"archiver-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||
"requires": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
"lazystream": "^1.0.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.union": "^4.6.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"glob": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
|
||||
"integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"graceful-fs": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz",
|
||||
"integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg=="
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"archy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
|
||||
@@ -6469,8 +6569,7 @@
|
||||
"base64-js": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
|
||||
"integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw=="
|
||||
},
|
||||
"basic-auth": {
|
||||
"version": "2.0.1",
|
||||
@@ -6535,6 +6634,26 @@
|
||||
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz",
|
||||
"integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ="
|
||||
},
|
||||
"bl": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-3.0.0.tgz",
|
||||
"integrity": "sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==",
|
||||
"requires": {
|
||||
"readable-stream": "^3.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
|
||||
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bluebird": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
|
||||
@@ -6851,6 +6970,11 @@
|
||||
"isarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI="
|
||||
},
|
||||
"buffer-equal": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz",
|
||||
@@ -8038,6 +8162,24 @@
|
||||
"integrity": "sha1-xV2DzMG5TNUImk6T+niRxyY+Wao=",
|
||||
"dev": true
|
||||
},
|
||||
"compress-commons": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-2.0.0.tgz",
|
||||
"integrity": "sha512-gnETNngrfsAoLBENM8M0DoiCDJkHwz3OfIg4mBtqKDcRgE4oXNwHxHxgHvwKKlrcD7eZ7BVTy4l8t9xVF7q3FQ==",
|
||||
"requires": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^2.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^2.3.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"compressible": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.17.tgz",
|
||||
@@ -8581,6 +8723,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"crc": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
|
||||
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
|
||||
"requires": {
|
||||
"buffer": "^5.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==",
|
||||
"requires": {
|
||||
"base64-js": "^1.0.2",
|
||||
"ieee754": "^1.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"crc-32": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz",
|
||||
@@ -8591,6 +8752,15 @@
|
||||
"printj": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"crc32-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-2.0.0.tgz",
|
||||
"integrity": "sha1-483TtN8xaN10494/u8t7KX/pCPQ=",
|
||||
"requires": {
|
||||
"crc": "^3.4.4",
|
||||
"readable-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"create-ecdh": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz",
|
||||
@@ -9330,6 +9500,14 @@
|
||||
"integrity": "sha512-lAJUJP3M6HxFXbqtGRc0iZrdyeN+WzOWeY0q/VnFzI+kqVrYIzC7bWlKqCW7oCIdzoPkvfp82EVvrTlQ8zsWQg==",
|
||||
"dev": true
|
||||
},
|
||||
"csv-stringify": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.3.0.tgz",
|
||||
"integrity": "sha512-VMYPbE8zWz475smwqb9VbX9cj0y4J0PBl59UdcqzLkzXHZZ8dh4Rmbb0ZywsWEtUml4A96Hn7Q5MW9ppVghYzg==",
|
||||
"requires": {
|
||||
"lodash.get": "~4.4.2"
|
||||
}
|
||||
},
|
||||
"currently-unhandled": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
|
||||
@@ -12298,7 +12476,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz",
|
||||
"integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
@@ -14411,6 +14588,11 @@
|
||||
"readable-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
|
||||
},
|
||||
"fs-extra": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz",
|
||||
@@ -14446,8 +14628,7 @@
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "1.2.9",
|
||||
@@ -17543,8 +17724,7 @@
|
||||
"ieee754": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz",
|
||||
"integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA=="
|
||||
},
|
||||
"iferr": {
|
||||
"version": "0.1.5",
|
||||
@@ -20114,7 +20294,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz",
|
||||
"integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"readable-stream": "^2.0.5"
|
||||
}
|
||||
@@ -20728,6 +20907,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
|
||||
},
|
||||
"lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||
"integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw="
|
||||
},
|
||||
"lodash.escape": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
|
||||
@@ -20764,8 +20948,7 @@
|
||||
"lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
|
||||
"dev": true
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
|
||||
},
|
||||
"lodash.has": {
|
||||
"version": "4.5.2",
|
||||
@@ -20903,6 +21086,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz",
|
||||
"integrity": "sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw="
|
||||
},
|
||||
"lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg="
|
||||
},
|
||||
"lodash.uniq": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||
@@ -23643,7 +23831,7 @@
|
||||
"dependencies": {
|
||||
"async": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
|
||||
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
|
||||
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
|
||||
"dev": true
|
||||
}
|
||||
@@ -31259,6 +31447,30 @@
|
||||
"integrity": "sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg==",
|
||||
"dev": true
|
||||
},
|
||||
"tar-stream": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.0.tgz",
|
||||
"integrity": "sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==",
|
||||
"requires": {
|
||||
"bl": "^3.0.0",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
|
||||
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tdigest": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz",
|
||||
@@ -36549,6 +36761,28 @@
|
||||
"requires": {
|
||||
"zen-observable": "^0.8.0"
|
||||
}
|
||||
},
|
||||
"zip-stream": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-2.1.0.tgz",
|
||||
"integrity": "sha512-F/xoLqlQShgvn1BzHQCNiYIoo2R93GQIMH+tA6JC3ckMDkme4bnhEEXSferZcG5ea/6bZNx3GqSUHqT8TUO6uQ==",
|
||||
"requires": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"compress-commons": "^2.0.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
|
||||
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0",
|
||||
"npm": ">=6.7.0"
|
||||
"npm": ">=6.9.0"
|
||||
},
|
||||
"bugs": "https://github.com/coralproject/talk/issues",
|
||||
"contributors": [
|
||||
@@ -53,8 +53,10 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@coralproject/bunyan-prettystream": "^0.1.4",
|
||||
"@types/archiver": "^3.0.0",
|
||||
"akismet-api": "^4.2.0",
|
||||
"apollo-server-express": "^2.1.0",
|
||||
"archiver": "^3.0.3",
|
||||
"basic-auth": "^2.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^3.8.1",
|
||||
@@ -67,6 +69,7 @@
|
||||
"cookie": "^0.4.0",
|
||||
"cookie-parser": "^1.4.4",
|
||||
"cors": "^2.8.4",
|
||||
"csv-stringify": "^5.3.0",
|
||||
"dataloader": "^1.4.0",
|
||||
"dompurify": "^1.0.8",
|
||||
"dotenv": "^6.0.0",
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
}
|
||||
|
||||
.centered {
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
margin: 10% auto;
|
||||
padding: 0 calc(0.5 * var(--mini-unit));
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { makeRouteConfig, Route } from "found";
|
||||
import React from "react";
|
||||
|
||||
import DownloadRoute from "./routes/download/Download";
|
||||
import ConfirmRoute from "./routes/email/Confirm";
|
||||
import ResetRoute from "./routes/password/Reset";
|
||||
|
||||
@@ -12,5 +13,6 @@ export default makeRouteConfig(
|
||||
<Route path="email">
|
||||
<Route path="confirm" {...ConfirmRoute.routeConfig} />
|
||||
</Route>
|
||||
<Route path="download" {...DownloadRoute.routeConfig} />
|
||||
</Route>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.title {
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-size: calc(48rem / var(--rem-base));
|
||||
line-height: calc(42em / 48);
|
||||
text-align: center;
|
||||
|
||||
color: var(--palette-common-black);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing-2) var(--spacing-3) var(--spacing-3) var(--spacing-3);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionText {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: calc(24rem / var(--rem-base));
|
||||
line-height: calc(34em / 24);
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin-left: calc(-1 * var(--spacing-5));
|
||||
}
|
||||
|
||||
.list li {
|
||||
margin-bottom: calc(16em / 24);
|
||||
}
|
||||
|
||||
.listContent {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.bullet {
|
||||
display: inline-block;
|
||||
min-width: 24px;
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Flex, HorizontalGutter, Icon, Typography } from "coral-ui/components";
|
||||
|
||||
import styles from "./DownloadDescription.css";
|
||||
|
||||
const DownloadDescription: FunctionComponent = () => {
|
||||
return (
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="download-landingPage-title">
|
||||
<div className={styles.title}>Download Your Comment History</div>
|
||||
</Localized>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<Localized id="download-landingPage-description">
|
||||
<Typography variant="bodyCopy" className={styles.sectionText}>
|
||||
Your comment history will be downloaded into a .zip file. After
|
||||
your comment history is unzipped you will have a comma separated
|
||||
value (or .csv) file that you can easily import into your favorite
|
||||
spreadsheet application.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Localized id="download-landingPage-contentsDescription">
|
||||
<Typography variant="bodyCopy" className={styles.sectionText}>
|
||||
For each of your comments the following information is included:
|
||||
</Typography>
|
||||
</Localized>
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsDate">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
When you wrote the comment
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsUrl">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
The permalink URL for the comment
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsText">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
The comment text
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsStoryUrl">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
The URL on the article or story where the comment appears
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadDescription;
|
||||
@@ -0,0 +1,8 @@
|
||||
.form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
font-size: calc(18rem / var(--rem-base));
|
||||
line-height: calc(23em / 18);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Button, HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import styles from "./DownloadForm.css";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const DownloadForm: FunctionComponent<Props> = ({ token }) => {
|
||||
return (
|
||||
<HorizontalGutter size="double">
|
||||
<form
|
||||
className={styles.form}
|
||||
method="post"
|
||||
action="/api/account/download"
|
||||
>
|
||||
<input name="token" type="hidden" value={token} />
|
||||
<Localized id="download-landingPage-downloadComments ">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
fullWidth={false}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
Download My Comment History
|
||||
</Button>
|
||||
</Localized>
|
||||
</form>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadForm;
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(90 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import Loading from "coral-account/components/Loading";
|
||||
import { useToken } from "coral-framework/hooks";
|
||||
import { createFetch } from "coral-framework/lib/relay";
|
||||
import { withRouteConfig } from "coral-framework/lib/router";
|
||||
import { parseHashQuery } from "coral-framework/utils";
|
||||
import { HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import DownloadDescription from "./DownloadDescription";
|
||||
import DownloadForm from "./DownloadForm";
|
||||
import Sorry from "./Sorry";
|
||||
|
||||
import styles from "./DownloadRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"downloadToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
await rest.fetch<void>("/account/downloadcheck", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
token: string | undefined;
|
||||
}
|
||||
|
||||
const DownloadRoute: FunctionComponent<Props> = ({ token }) => {
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<HorizontalGutter size="double">
|
||||
<Loading />
|
||||
</HorizontalGutter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state !== "VALID" || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<HorizontalGutter size="double">
|
||||
<DownloadDescription />
|
||||
<Sorry />
|
||||
</HorizontalGutter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<DownloadDescription />
|
||||
<DownloadForm token={token!} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig<Props>({
|
||||
render: ({ match, Component }) => (
|
||||
<Component token={parseHashQuery(match.location.hash).downloadToken} />
|
||||
),
|
||||
})(DownloadRoute);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,7 @@
|
||||
.icon {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.callout {
|
||||
justify-content: left;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { CallOut, Flex, Icon } from "coral-ui/components";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
|
||||
import styles from "./Sorry.css";
|
||||
|
||||
const Sorry: FunctionComponent = () => {
|
||||
return (
|
||||
<CallOut color="error" fullWidth className={styles.callout}>
|
||||
<Flex>
|
||||
<Icon size="md" className={styles.icon}>
|
||||
error
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-sorry">
|
||||
Your download link is invalid.
|
||||
</Localized>
|
||||
</Flex>
|
||||
</CallOut>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sorry;
|
||||
@@ -0,0 +1 @@
|
||||
export { default, default as DownloadRoute } from "./DownloadRoute";
|
||||
@@ -30,39 +30,37 @@ const ConfirmForm: React.FunctionComponent<Props> = ({ onSuccess, token }) => {
|
||||
return;
|
||||
}, [token]);
|
||||
return (
|
||||
<div>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitting }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-emailConfirmation">
|
||||
<Typography variant="heading1">Email Confirmation</Typography>
|
||||
</Localized>
|
||||
<Localized id="confirmEmail-pleaseClickToConfirm">
|
||||
<Typography variant="bodyCopy">
|
||||
Click below to confirm your email address.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-confirmEmail">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Confirm email
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitting }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-emailConfirmation">
|
||||
<Typography variant="heading1">Email Confirmation</Typography>
|
||||
</Localized>
|
||||
<Localized id="confirmEmail-pleaseClickToConfirm">
|
||||
<Typography variant="bodyCopy">
|
||||
Click below to confirm your email address.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-confirmEmail">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Confirm email
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import ConfirmForm from "./ConfirmForm";
|
||||
import Sorry from "./Sorry";
|
||||
import Success from "./Success";
|
||||
|
||||
import styles from "./ConfirmRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"confirmToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
@@ -32,17 +34,37 @@ const ConfirmRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return <Loading />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return <Sorry reason={error} />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Sorry reason={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<ConfirmForm token={token!} onSuccess={onSuccess} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<ConfirmForm token={token!} onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Success />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Success />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import ResetPasswordForm from "./ResetPasswordForm";
|
||||
import Sorry from "./Sorry";
|
||||
import Success from "./Success";
|
||||
|
||||
import styles from "./ResetRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"resetToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
@@ -32,17 +34,37 @@ const ResetRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return <Loading />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return <Sorry reason={error} />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Sorry reason={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Success />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Success />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,46 +10,52 @@ exports[`renders form 1`] = `
|
||||
<div
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
<div
|
||||
className="ConfirmRoute-container"
|
||||
>
|
||||
<div
|
||||
className="ConfirmRoute-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
Email Confirmation
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Email Confirmation
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Click below to confirm your email address.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
Click below to confirm your email address.
|
||||
</p>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Confirm email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Confirm email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,22 +72,30 @@ exports[`renders missing confirm token 1`] = `
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
className="ConfirmRoute-container"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
className="ConfirmRoute-root"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,100 +10,108 @@ exports[`renders form 1`] = `
|
||||
<div
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
<div
|
||||
className="ResetRoute-container"
|
||||
>
|
||||
<div
|
||||
className="ResetRoute-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Please enter a new password to use to sign in to your account.
|
||||
Make sure it is unique and be sure to keep it secure.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
<div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<label
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
|
||||
>
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Please enter a new password to use to sign in to your account.
|
||||
Make sure it is unique and be sure to keep it secure.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
disabled={false}
|
||||
id="password"
|
||||
name="password"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Password"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide password"
|
||||
<label
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
htmlFor="password"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
Password
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
|
||||
>
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
>
|
||||
visibility
|
||||
</span>
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
disabled={false}
|
||||
id="password"
|
||||
name="password"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Password"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide password"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
visibility
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,22 +128,30 @@ exports[`renders missing reset token 1`] = `
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
className="ResetRoute-container"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
className="ResetRoute-root"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.root {
|
||||
max-width: 500px;
|
||||
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.recentRequest {
|
||||
margin-bottom: var(--spacing-3);
|
||||
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.callout {
|
||||
justify-content: left;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { DOWNLOAD_LIMIT_TIMEFRAME } from "coral-common/constants";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { reduceSeconds, UNIT } from "coral-framework/lib/i18n";
|
||||
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { DownloadCommentsContainer_viewer } from "coral-stream/__generated__/DownloadCommentsContainer_viewer.graphql";
|
||||
import { Button, CallOut, Icon, Typography } from "coral-ui/components";
|
||||
|
||||
import RequestCommentsDownloadMutation from "./RequestCommentsDownloadMutation";
|
||||
|
||||
import styles from "./DownloadCommentsContainer.css";
|
||||
|
||||
interface Props {
|
||||
viewer: DownloadCommentsContainer_viewer;
|
||||
}
|
||||
|
||||
const DownloadCommentsContainer: FunctionComponent<Props> = ({ viewer }) => {
|
||||
const requestComments = useMutation(RequestCommentsDownloadMutation);
|
||||
const onClick = useCallback(() => {
|
||||
requestComments();
|
||||
}, [requestComments]);
|
||||
|
||||
const { locales } = useCoralContext();
|
||||
const lastDownloadedAt = viewer.lastDownloadedAt
|
||||
? new Date(viewer.lastDownloadedAt)
|
||||
: null;
|
||||
const sinceLastDownload = lastDownloadedAt
|
||||
? Math.ceil((Date.now() - lastDownloadedAt.getTime()) / 1000)
|
||||
: 0;
|
||||
const canDownload =
|
||||
!lastDownloadedAt || sinceLastDownload >= DOWNLOAD_LIMIT_TIMEFRAME;
|
||||
const tilCanDownload = DOWNLOAD_LIMIT_TIMEFRAME - sinceLastDownload;
|
||||
const formatter = new Intl.DateTimeFormat(locales, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour12: true,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const { scaled, unit } = reduceSeconds(tilCanDownload, [
|
||||
UNIT.DAYS,
|
||||
UNIT.HOURS,
|
||||
UNIT.MINUTES,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Localized id="profile-settings-download-comments-title">
|
||||
<Typography variant="heading3" className={styles.title}>
|
||||
Download my comment history
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="profile-settings-download-comments-description"
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography variant="bodyCopy" className={styles.description}>
|
||||
You will receive an email with a link to download your comment
|
||||
history. You can make one download request every 14 days.
|
||||
</Typography>
|
||||
</Localized>
|
||||
{lastDownloadedAt && (
|
||||
<Localized
|
||||
id="profile-settings-download-comments-recentRequest"
|
||||
$timeStamp={formatter.format(lastDownloadedAt)}
|
||||
>
|
||||
<Typography variant="bodyCopy" className={styles.recentRequest}>
|
||||
Your most recent request: {formatter.format(lastDownloadedAt)}
|
||||
</Typography>
|
||||
</Localized>
|
||||
)}
|
||||
{canDownload ? (
|
||||
<Button variant="outlined" size="small" onClick={onClick}>
|
||||
<Localized
|
||||
id="profile-settings-download-comments-request-icon"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Icon size="sm" className={styles.icon}>
|
||||
file_download
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Localized id="profile-settings-download-comments-request">
|
||||
<span>Request comment history</span>
|
||||
</Localized>
|
||||
</Button>
|
||||
) : (
|
||||
<CallOut fullWidth className={styles.callout}>
|
||||
<Icon size="lg" className={styles.icon}>
|
||||
query_builder
|
||||
</Icon>
|
||||
<Localized
|
||||
id="profile-settings-download-comments-timeOut"
|
||||
$value={scaled}
|
||||
$unit={unit}
|
||||
>
|
||||
<span>
|
||||
You can submit another request in {scaled} {unit}
|
||||
</span>
|
||||
</Localized>
|
||||
</CallOut>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment DownloadCommentsContainer_viewer on User {
|
||||
id
|
||||
lastDownloadedAt
|
||||
}
|
||||
`,
|
||||
})(DownloadCommentsContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment, RecordSourceSelectorProxy } from "relay-runtime";
|
||||
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { RequestCommentsDownloadMutation as MutationTypes } from "coral-stream/__generated__/RequestCommentsDownloadMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const RequestCommentsDownloadMutation = createMutation(
|
||||
"requestCommentsDownload",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const updater = (store: RecordSourceSelectorProxy) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
const user = store.get(viewer.id);
|
||||
const now = new Date();
|
||||
|
||||
if (user !== null) {
|
||||
user.setValue(now.toISOString(), "lastDownloadedAt");
|
||||
}
|
||||
};
|
||||
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RequestCommentsDownloadMutation(
|
||||
$input: RequestCommentsDownloadInput!
|
||||
) {
|
||||
requestCommentsDownload(input: $input) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
optimisticUpdater: updater,
|
||||
updater,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default RequestCommentsDownloadMutation;
|
||||
@@ -7,6 +7,7 @@ import { SettingsContainer_viewer } from "coral-stream/__generated__/SettingsCon
|
||||
import { HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import ChangePasswordContainer from "./ChangePasswordContainer";
|
||||
import DownloadCommentsContainer from "./DownloadCommentsContainer";
|
||||
import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer";
|
||||
|
||||
import styles from "./SettingsContainer.css";
|
||||
@@ -20,6 +21,7 @@ const SettingsContainer: FunctionComponent<Props> = ({ viewer, settings }) => (
|
||||
<HorizontalGutter spacing={5} className={styles.root}>
|
||||
<IgnoreUserSettingsContainer viewer={viewer} />
|
||||
<ChangePasswordContainer settings={settings} />
|
||||
<DownloadCommentsContainer viewer={viewer} />
|
||||
</HorizontalGutter>
|
||||
);
|
||||
|
||||
@@ -27,6 +29,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment SettingsContainer_viewer on User {
|
||||
...IgnoreUserSettingsContainer_viewer
|
||||
...DownloadCommentsContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
|
||||
@@ -329,6 +329,45 @@ Commenters you ignore will still be able to see your comments.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
className="DownloadCommentsContainer-root"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary DownloadCommentsContainer-title"
|
||||
>
|
||||
Download my comment history
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary DownloadCommentsContainer-description"
|
||||
>
|
||||
You will receive an email with a link to download your comment history.
|
||||
You can make
|
||||
<strong>
|
||||
one download request every 14 days.
|
||||
</strong>
|
||||
</p>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantOutlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm DownloadCommentsContainer-icon"
|
||||
title="Request comment history"
|
||||
>
|
||||
file_download
|
||||
</span>
|
||||
<span>
|
||||
Request comment history
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -34,3 +34,9 @@ export const TOXICITY_MODEL_DEFAULT = "TOXICITY";
|
||||
*/
|
||||
export const TOXICITY_ENDPOINT_DEFAULT =
|
||||
"https://commentanalyzer.googleapis.com/v1alpha1";
|
||||
|
||||
/**
|
||||
* DOWNLOAD_LIMIT_TIMEFRAME is the number of seconds that a given download may
|
||||
* be made within.
|
||||
*/
|
||||
export const DOWNLOAD_LIMIT_TIMEFRAME = 14 * 86400;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import archiver from "archiver";
|
||||
import stringify from "csv-stringify";
|
||||
import DataLoader from "dataloader";
|
||||
import { Response } from "express";
|
||||
import Joi from "joi";
|
||||
import { kebabCase } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { getLatestRevision } from "coral-server/models/comment";
|
||||
import { Comment } from "coral-server/models/comment";
|
||||
import { retrieveManyStories } from "coral-server/models/story";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import { verifyDownloadTokenString } from "coral-server/services/users/download/download";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const USER_ID_LIMITER_TTL = "1d";
|
||||
|
||||
export type DownloadOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "signingConfig" | "config"
|
||||
>;
|
||||
|
||||
async function sendExport(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
user: Readonly<User>,
|
||||
latestContentDate: Date,
|
||||
res: Response
|
||||
) {
|
||||
// Create the date formatter to format the dates for the CSV.
|
||||
const formatter = Intl.DateTimeFormat(tenant.locale, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Create a DataLoader to load stories for each batch.
|
||||
const getStories = new DataLoader((ids: string[]) =>
|
||||
retrieveManyStories(mongo, tenant.id, ids)
|
||||
);
|
||||
|
||||
// Create a cursor to iterate over the user's comment's in order.
|
||||
const cursor = mongo
|
||||
.collection<Readonly<Comment>>("comments")
|
||||
.find({
|
||||
tenantID: tenant.id,
|
||||
authorID: user.id,
|
||||
createdAt: {
|
||||
$lt: latestContentDate,
|
||||
},
|
||||
})
|
||||
.sort({ createdAt: 1 });
|
||||
|
||||
// Collect all the user's comments in batches.
|
||||
let commentBatch: Array<Readonly<Comment>> = [];
|
||||
|
||||
// Generate the filename of the file that the user will download.
|
||||
const filename = `talk-${kebabCase(user.username)}-${kebabCase(
|
||||
formatter.format(latestContentDate)
|
||||
)}.zip`;
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename=${filename}`,
|
||||
});
|
||||
|
||||
// Create the zip archive we'll use to write all the exported files to.
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// Pipe this to the response writer directly.
|
||||
archive.pipe(res);
|
||||
|
||||
// Create all the csv writers that'll write the data to the archive.
|
||||
const csv = stringify();
|
||||
|
||||
// Add all the streams as files to the archive.
|
||||
archive.append(csv, { name: "comments-export/my_comments.csv" });
|
||||
|
||||
csv.write([
|
||||
"Comment ID",
|
||||
"Published Timestamp",
|
||||
"Article URL",
|
||||
"Comment URL",
|
||||
"Comment Text",
|
||||
]);
|
||||
|
||||
/**
|
||||
* writeAndFlushBatch will write the given batch of comments to the CSV and
|
||||
* flush out the batchfor the next run.
|
||||
*/
|
||||
const writeAndFlushBatch = async () => {
|
||||
const stories = await getStories.loadMany(
|
||||
commentBatch.map(({ storyID }) => storyID)
|
||||
);
|
||||
|
||||
for (let i = 0; i < commentBatch.length; i++) {
|
||||
const comment = commentBatch[i];
|
||||
const story = stories[i];
|
||||
if (!story) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
const commentID = comment.id;
|
||||
const createdAt = formatter.format(new Date(comment.createdAt));
|
||||
const storyURL = story.url;
|
||||
const commentURL = `${storyURL}?commentID=${commentID}`;
|
||||
const body = revision.body;
|
||||
|
||||
csv.write([commentID, createdAt, storyURL, commentURL, body]);
|
||||
}
|
||||
|
||||
commentBatch = [];
|
||||
};
|
||||
|
||||
while (await cursor.hasNext()) {
|
||||
const comment = await cursor.next();
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
|
||||
commentBatch.push(comment);
|
||||
|
||||
if (commentBatch.length >= BATCH_SIZE) {
|
||||
await writeAndFlushBatch();
|
||||
}
|
||||
}
|
||||
|
||||
if (commentBatch.length > 0) {
|
||||
await writeAndFlushBatch();
|
||||
}
|
||||
|
||||
csv.end();
|
||||
|
||||
// Mark the end of adding files, no more files can be added after this. Once
|
||||
// all the stream readers have finished writing, and have closed, the
|
||||
// archiver will close which will finish the HTTP request.
|
||||
await archive.finalize();
|
||||
}
|
||||
|
||||
export interface DownloadBody {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const DownloadBodySchema = Joi.object().keys({
|
||||
token: Joi.string().trim(),
|
||||
});
|
||||
|
||||
export const downloadHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
signingConfig,
|
||||
config,
|
||||
}: DownloadOptions): RequestHandler => {
|
||||
const userIDLimiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: USER_ID_LIMITER_TTL,
|
||||
max: 1,
|
||||
prefix: "userID",
|
||||
config,
|
||||
});
|
||||
|
||||
return async (req: Request, res, next) => {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Get the fields from the body. Validate will throw an error if the body
|
||||
// does not conform to the specification.
|
||||
const { token }: DownloadBody = validate(DownloadBodySchema, req.body);
|
||||
|
||||
// Decode the token so we can rate limit based on the user's ID.
|
||||
const { sub: userID } = decodeJWT(token);
|
||||
if (!userID) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
await userIDLimiter.test(req, userID);
|
||||
|
||||
try {
|
||||
const {
|
||||
token: { iat },
|
||||
user,
|
||||
} = await verifyDownloadTokenString(
|
||||
mongo,
|
||||
tenant,
|
||||
signingConfig,
|
||||
token,
|
||||
coral.now
|
||||
);
|
||||
|
||||
// Only load comments since this download token was issued.
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Send the export down the response.
|
||||
await sendExport(mongo, tenant, user, latestContentDate, res);
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export type DownloadCheckOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "signingConfig" | "config"
|
||||
>;
|
||||
|
||||
export const downloadCheckHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
signingConfig,
|
||||
config,
|
||||
}: DownloadCheckOptions): RequestHandler => {
|
||||
const userIDLimiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: USER_ID_LIMITER_TTL,
|
||||
max: 1,
|
||||
prefix: "userID",
|
||||
config,
|
||||
});
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Tenant is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
const tokenString = extractTokenFromRequest(req, true);
|
||||
if (!tokenString) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
const { sub: userID } = decodeJWT(tokenString);
|
||||
if (!userID) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
await userIDLimiter.test(req, userID);
|
||||
|
||||
await verifyDownloadTokenString(
|
||||
mongo,
|
||||
tenant,
|
||||
signingConfig,
|
||||
tokenString,
|
||||
coral.now
|
||||
);
|
||||
|
||||
return res.sendStatus(204);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./confirm";
|
||||
export * from "./invite";
|
||||
export * from "./download";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import bodyParser from "body-parser";
|
||||
import express from "express";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
@@ -5,6 +6,8 @@ import {
|
||||
confirmCheckHandler,
|
||||
confirmHandler,
|
||||
confirmRequestHandler,
|
||||
downloadCheckHandler,
|
||||
downloadHandler,
|
||||
inviteCheckHandler,
|
||||
inviteHandler,
|
||||
} from "coral-server/app/handlers";
|
||||
@@ -30,5 +33,14 @@ export function createNewAccountRouter(
|
||||
router.get("/invite", inviteCheckHandler(app));
|
||||
router.put("/invite", jsonMiddleware, inviteHandler(app));
|
||||
|
||||
router.get("/downloadcheck", downloadCheckHandler(app));
|
||||
router.post(
|
||||
"/download",
|
||||
bodyParser.urlencoded({
|
||||
extended: true,
|
||||
}),
|
||||
downloadHandler(app)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
removeBan,
|
||||
removeIgnore,
|
||||
removeSuspension,
|
||||
requestCommentsDownload,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setUsername,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
GQLRemoveUserBanInput,
|
||||
GQLRemoveUserIgnoreInput,
|
||||
GQLRemoveUserSuspensionInput,
|
||||
GQLRequestCommentsDownloadInput,
|
||||
GQLSetEmailInput,
|
||||
GQLSetPasswordInput,
|
||||
GQLSetUsernameInput,
|
||||
@@ -155,4 +157,14 @@ export const Users = (ctx: TenantContext) => ({
|
||||
ignore(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
|
||||
removeIgnore: async (input: GQLRemoveUserIgnoreInput) =>
|
||||
removeIgnore(ctx.mongo, ctx.tenant, ctx.user!, input.userID),
|
||||
requestCommentsDownload: async (input: GQLRequestCommentsDownloadInput) =>
|
||||
requestCommentsDownload(
|
||||
ctx.mongo,
|
||||
ctx.mailerQueue,
|
||||
ctx.tenant,
|
||||
ctx.config,
|
||||
ctx.signingConfig!,
|
||||
ctx.user!,
|
||||
ctx.now
|
||||
),
|
||||
});
|
||||
|
||||
@@ -177,4 +177,8 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.removeIgnore(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
requestCommentsDownload: async (sourc, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.requestCommentsDownload(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1599,6 +1599,12 @@ type User {
|
||||
createdAt is the time that the User was created at.
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
"""
|
||||
lastDownloadedAt the last time the user made a download request
|
||||
of their account data.
|
||||
"""
|
||||
lastDownloadedAt: Time
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -4400,6 +4406,24 @@ type RemoveUserIgnorePayload {
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
#########################
|
||||
# requestCommentsDownload
|
||||
#########################
|
||||
|
||||
input RequestCommentsDownloadInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type RequestCommentsDownloadPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
## Mutation
|
||||
##################
|
||||
@@ -4663,6 +4687,13 @@ type Mutation {
|
||||
"""
|
||||
removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload!
|
||||
@auth(permit: [SUSPENDED, BANNED])
|
||||
|
||||
"""
|
||||
requestCommentsDownload allows a user to request to download their comments.
|
||||
"""
|
||||
requestCommentsDownload(
|
||||
input: RequestCommentsDownloadInput!
|
||||
): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -45,3 +45,8 @@ email-subject-invite = Coral Team invite
|
||||
email-notification-template-invite =
|
||||
You have been invited to join the { $organizationName } team on Coral. Finish
|
||||
setting up your account <a data-l10n-name="invite">here</a>.
|
||||
|
||||
email-subject-downloadComments = Your comments are ready for download
|
||||
email-notification-template-downloadComments =
|
||||
Your comments from { $organizationName } as of { $date } are now available for download.<br /><br />
|
||||
<a data-l10n-name="downloadUrl">Download my comment archive</a>
|
||||
@@ -292,6 +292,12 @@ export interface User extends TenantResource {
|
||||
*/
|
||||
ignoredUsers: IgnoredUser[];
|
||||
|
||||
/**
|
||||
* lastDownloadedAt is the last time the user requested to download their
|
||||
* user data.
|
||||
*/
|
||||
lastDownloadedAt?: Date;
|
||||
|
||||
/**
|
||||
* createdAt is the time that the User was created at.
|
||||
*/
|
||||
@@ -1794,3 +1800,36 @@ export async function removeUserIgnore(
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function setUserLastDownloadedAt(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now: Date
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
},
|
||||
{
|
||||
$set: { lastDownloadedAt: now },
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the ignore operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "layouts/user-notification.html" %}
|
||||
|
||||
{% block content %}
|
||||
Your comments from {{ context.organizationName }} as of {{ context.date }} are
|
||||
now available for download.<br /><br />
|
||||
<a data-l10n-name="downloadUrl" href="{{ context.downloadUrl }}">Download my comment archive</a>
|
||||
{% endblock %}
|
||||
@@ -62,12 +62,22 @@ export type InviteEmailTemplate = UserNotificationContext<
|
||||
}
|
||||
>;
|
||||
|
||||
export type DownloadCommentsTemplate = UserNotificationContext<
|
||||
"download-comments",
|
||||
{
|
||||
username: string;
|
||||
date: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type Templates =
|
||||
| BanTemplate
|
||||
| ConfirmEmailTemplate
|
||||
| ForgotPasswordTemplate
|
||||
| InviteEmailTemplate
|
||||
| PasswordChangeTemplate
|
||||
| SuspendTemplate;
|
||||
| SuspendTemplate
|
||||
| DownloadCommentsTemplate;
|
||||
|
||||
export { Templates as Template };
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Joi from "joi";
|
||||
import { isNull } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import { Config } from "coral-server/config";
|
||||
import { TokenInvalidError, UserNotFoundError } from "coral-server/errors";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { retrieveUser } from "coral-server/models/user";
|
||||
import {
|
||||
JWTSigningConfig,
|
||||
signString,
|
||||
StandardClaims,
|
||||
StandardClaimsSchema,
|
||||
verifyJWT,
|
||||
} from "coral-server/services/jwt";
|
||||
|
||||
interface DownloadToken extends Required<StandardClaims> {
|
||||
aud: "download";
|
||||
}
|
||||
|
||||
const DownloadTokenSchema = StandardClaimsSchema.keys({
|
||||
aud: Joi.string().only("download"),
|
||||
});
|
||||
|
||||
export async function generateDownloadLink(
|
||||
userID: string,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
now: Date
|
||||
) {
|
||||
const nowDate = DateTime.fromJSDate(now);
|
||||
const nowSeconds = Math.round(nowDate.toSeconds());
|
||||
const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds());
|
||||
|
||||
const downloadToken: DownloadToken = {
|
||||
jti: uuid.v4(),
|
||||
iss: tenant.id,
|
||||
sub: userID,
|
||||
exp: expiresAt,
|
||||
iat: nowSeconds,
|
||||
nbf: nowSeconds,
|
||||
aud: "download",
|
||||
};
|
||||
|
||||
const token = await signString(signingConfig, downloadToken);
|
||||
|
||||
return constructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
`/account/download#downloadToken=${token}`
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDownloadToken(
|
||||
token: DownloadToken | object
|
||||
): Error | null {
|
||||
const { error } = Joi.validate(token, DownloadTokenSchema, {
|
||||
presence: "required",
|
||||
});
|
||||
return error || null;
|
||||
}
|
||||
|
||||
export function isDownloadToken(
|
||||
token: DownloadToken | object
|
||||
): token is DownloadToken {
|
||||
return isNull(validateDownloadToken(token));
|
||||
}
|
||||
|
||||
export async function verifyDownloadTokenString(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
signingConfig: JWTSigningConfig,
|
||||
tokenString: string,
|
||||
now: Date
|
||||
) {
|
||||
const token = verifyJWT(tokenString, signingConfig, now, {
|
||||
issuer: tenant.id,
|
||||
audience: "download",
|
||||
});
|
||||
|
||||
if (!isDownloadToken(token)) {
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"does not conform to the download token schema"
|
||||
);
|
||||
}
|
||||
|
||||
const { sub: userID, iss } = token;
|
||||
|
||||
const user = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
if (iss !== tenant.id) {
|
||||
throw new TokenInvalidError(tokenString, "invalid tenant");
|
||||
}
|
||||
|
||||
return { token, user };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { DOWNLOAD_LIMIT_TIMEFRAME } from "coral-common/constants";
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
DuplicateEmailError,
|
||||
DuplicateUserError,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
retrieveUser,
|
||||
retrieveUserWithEmail,
|
||||
setUserEmail,
|
||||
setUserLastDownloadedAt,
|
||||
setUserLocalProfile,
|
||||
setUserUsername,
|
||||
suspendUser,
|
||||
@@ -52,6 +55,8 @@ import { userIsStaff } from "coral-server/models/user/helpers";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
|
||||
|
||||
import logger from "coral-server/logger";
|
||||
import { generateDownloadLink } from "./download/download";
|
||||
import { validateEmail, validatePassword, validateUsername } from "./helpers";
|
||||
|
||||
export type InsertUser = InsertUserInput;
|
||||
@@ -681,3 +686,59 @@ export async function removeIgnore(
|
||||
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
export async function requestCommentsDownload(
|
||||
mongo: Db,
|
||||
mailer: MailerQueue,
|
||||
tenant: Tenant,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
user: User,
|
||||
now: Date
|
||||
) {
|
||||
// Check to see if the user is allowed to download this now.
|
||||
if (
|
||||
user.lastDownloadedAt &&
|
||||
DateTime.fromJSDate(user.lastDownloadedAt)
|
||||
.plus({ seconds: DOWNLOAD_LIMIT_TIMEFRAME })
|
||||
.toSeconds() >= DateTime.fromJSDate(now).toSeconds()
|
||||
) {
|
||||
throw new Error("requested download too early");
|
||||
}
|
||||
|
||||
const downloadUrl = await generateDownloadLink(
|
||||
user.id,
|
||||
tenant,
|
||||
config,
|
||||
signingConfig,
|
||||
now
|
||||
);
|
||||
|
||||
await setUserLastDownloadedAt(mongo, tenant.id, user.id, now);
|
||||
|
||||
if (user.email) {
|
||||
await mailer.add({
|
||||
tenantID: tenant.id,
|
||||
message: {
|
||||
to: user.email,
|
||||
},
|
||||
template: {
|
||||
name: "download-comments",
|
||||
context: {
|
||||
username: user.username!,
|
||||
date: Intl.DateTimeFormat(tenant.locale).format(now),
|
||||
downloadUrl,
|
||||
organizationName: tenant.organization.name,
|
||||
organizationURL: tenant.organization.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
{ userID: user.id },
|
||||
"could not send download email because the user does not have an email address"
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,24 @@ confirmEmail-successfullyConfirmed = Email successfully confirmed
|
||||
confirmEmail-youMayClose =
|
||||
You may now close this window.
|
||||
|
||||
## Download
|
||||
|
||||
download-landingPage-title = Download Your Comment History
|
||||
download-landingPage-description =
|
||||
Your comment history will be downloaded into a .zip file. After your
|
||||
comment history is unzipped you will have a comma separated value (or .csv)
|
||||
file that you can easily import into your favorite spreadsheet
|
||||
application.
|
||||
download-landingPage-contentsDescription =
|
||||
For each of your comments the following information is included:
|
||||
download-landingPage-contentsDate =
|
||||
When you wrote the comment
|
||||
download-landingPage-contentsUrl =
|
||||
The permalink URL for the comment
|
||||
download-landingPage-contentsText =
|
||||
The comment text
|
||||
download-landingPage-contentsStoryUrl =
|
||||
The URL on the article or story where the comment appears
|
||||
download-landingPage-downloadComments =
|
||||
Download My Comment History
|
||||
download-landingPage-sorry = Your download link is invalid.
|
||||
@@ -173,6 +173,19 @@ profile-settings-changePassword-button = Change Password
|
||||
profile-settings-changePassword-updated =
|
||||
Your password has been updated
|
||||
|
||||
profile-settings-download-comments-title = Download my comment history
|
||||
profile-settings-download-comments-description =
|
||||
You will receive an email with a link to download your comment history.
|
||||
You can make <strong>one download request every 14 days.</strong>
|
||||
profile-settings-download-comments-request =
|
||||
Request comment history
|
||||
profile-settings-download-comments-request-icon =
|
||||
.title = Request comment history
|
||||
profile-settings-download-comments-recentRequest =
|
||||
Your most recent request: { $timeStamp }
|
||||
profile-settings-download-comments-timeOut =
|
||||
You can submit another request in { framework-timeago-time }
|
||||
|
||||
## Report Comment Popover
|
||||
comments-reportPopover =
|
||||
.description = A dialog for reporting comments
|
||||
@@ -247,8 +260,8 @@ configure-openStream-openStream = Open Stream
|
||||
comments-tombstone-ignore = This comment is hidden because you ignored {$username}
|
||||
|
||||
suspendInfo-heading = Your account has been temporarily suspended from commenting.
|
||||
suspendInfo-info =
|
||||
suspendInfo-info =
|
||||
In accordance with { $organization }'s community guidelines your
|
||||
account has been temporarily suspended. While suspended you will not
|
||||
be able to comment, respect or report comments. Please rejoin the
|
||||
conversation on { $until }
|
||||
conversation on { $until }
|
||||
|
||||
Reference in New Issue
Block a user