Compare commits
270 Commits
333ac773f6
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a810d531b7 | ||
|
|
91445cc240 | ||
| 85b9aa8e2b | |||
| 7309ba1436 | |||
|
|
07efab3782 | ||
|
|
375cda1082 | ||
| 67b0122d5a | |||
| 6aef08d7e8 | |||
| a5248af4a3 | |||
| d9f45d52f9 | |||
| dc80b686ce | |||
| 892cf4c595 | |||
| e2b641736d | |||
| bb1fc7519f | |||
| 014d4081e6 | |||
| 877678b745 | |||
| a3da157ae3 | |||
| 4f97010f99 | |||
| f38edff942 | |||
| 73c82aefe2 | |||
| 7df6668dc6 | |||
| 60e2d549cc | |||
| e5155a3da1 | |||
| b922675491 | |||
| 53e77e46dd | |||
| 8c652ab29b | |||
| 06d9052386 | |||
| 0e2c4ed08b | |||
|
|
713faebf51 | ||
|
|
93a230298d | ||
| 86063b27e8 | |||
| 57fe2cbe13 | |||
| 6b4b3642f9 | |||
| 844a462482 | |||
| d52f0a106a | |||
| a001f2fde3 | |||
| 5ad933f1c6 | |||
|
|
799c8d66c0 | ||
| 93caec3719 | |||
| e30e43d762 | |||
| c69c3a7126 | |||
| bdb544a624 | |||
|
|
c8bdaa10eb | ||
| f17f830453 | |||
|
|
761c49de45 | ||
|
|
6474ae1f4b | ||
|
|
5fef073839 | ||
|
|
a2164d8791 | ||
|
|
128b18ab56 | ||
|
|
3da4b2bf9e | ||
|
|
5da836c47c | ||
|
|
43965e2ea7 | ||
| 2e6bd3bd9f | |||
| d3e5ac5c37 | |||
| db1291836e | |||
| e0c50dcf62 | |||
| 6bac80a280 | |||
| 61fffbb13e | |||
| 0abe3aebee | |||
| 1ca61d72c9 | |||
|
|
0f7d13ebf9 | ||
|
|
8008504828 | ||
|
|
2aead1b4b1 | ||
|
|
37d4e36561 | ||
|
|
a410836539 | ||
| 5334c5970b | |||
|
|
421101a2c9 | ||
|
|
ef2430319d | ||
|
|
36faf15a62 | ||
| 710e00fdc2 | |||
| b2545e2f76 | |||
| 44ac98faa8 | |||
| d4cafd2f79 | |||
| de2b0e1940 | |||
| 361000e59b | |||
| ff35e53367 | |||
| 77ce5c8ca7 | |||
| e8e5c70843 | |||
| 4472c3fbdd | |||
|
|
0d73106d0e | ||
|
|
cfb1906b5b | ||
| b742857940 | |||
|
|
3d4babb280 | ||
|
|
c695bec8e3 | ||
| 9ca7363388 | |||
| 44041cac92 | |||
|
|
8ce439f78a | ||
| 3b772f8b4a | |||
| 59820a2f01 | |||
| d724d8093c | |||
|
|
71ef3718c8 | ||
| 6456ce8dcc | |||
| 5ad8a2d2ba | |||
|
|
190732fb00 | ||
|
|
cd3cbda801 | ||
|
|
72472e9d5e | ||
|
|
1fdb4bfe8c | ||
|
|
357b8df364 | ||
| 41a9c65afb | |||
| 4e1df0eeee | |||
| 4270374a67 | |||
| 9b9254cc13 | |||
|
|
2fb8601e3a | ||
| 4272c45b9e | |||
| 47274a9e7c | |||
| b2ebc2992b | |||
| 41a33398b0 | |||
|
|
27501f0898 | ||
|
|
1642f1e748 | ||
|
|
6e82db7cff | ||
|
|
8702ad0d22 | ||
|
|
fdb2fae3b9 | ||
|
|
14c501d124 | ||
| cd0a31e6f5 | |||
| f7f38789d2 | |||
| f4f762b31c | |||
| f6338c05ee | |||
| d1d6bf51b8 | |||
| f46a60b5dd | |||
| 11163dfad9 | |||
| 7cb9e2aa52 | |||
| 145a1da37e | |||
| bce003e508 | |||
| 45f0a14661 | |||
| 42fde503e3 | |||
| 6b65e31649 | |||
| 9677a344c2 | |||
| e4a5629cff | |||
| c4125822cb | |||
| 6f2da589b1 | |||
| 1ebfc997eb | |||
| dea3f78173 | |||
| 053ee4a748 | |||
| acd5593c95 | |||
|
|
d4a9e7e364 | ||
|
|
91875e7305 | ||
|
|
abd751d562 | ||
| 9c7b138d06 | |||
| b34e7daddf | |||
| 4cb434fd5d | |||
| 1639e7cf25 | |||
| 8f2bebe8ae | |||
| 810f307442 | |||
| a4bdd2e922 | |||
| 08e1ce6486 | |||
| e88eea7f36 | |||
| ea156fac13 | |||
| a95d5db24a | |||
| 453256f874 | |||
| 7bf488d4fe | |||
| 230773a917 | |||
| 79d93994c2 | |||
| bab4a62540 | |||
| f84a2c2750 | |||
| 2321e1d6e8 | |||
| af976ba838 | |||
| d08541fdae | |||
| fa92beed27 | |||
| 9e1ae2abe5 | |||
| ad39ea05c2 | |||
| 151c8154c4 | |||
| 21a6348afc | |||
| 210605c8e4 | |||
| 33a340326f | |||
| 3f8596aacc | |||
| fd112bd447 | |||
| 7d6b210ee1 | |||
| 6c28828c0a | |||
| 6af239378c | |||
| 4ff7d908d4 | |||
| 17c901b1de | |||
| f7b5dbf4ce | |||
| 7f02ba29a3 | |||
| 20c4613533 | |||
| a44fc1d6d0 | |||
| b86543b404 | |||
| 7d0007e4d9 | |||
| ddd32e7f44 | |||
| 8a9bb100ea | |||
| c48b8246f9 | |||
| b32a3d85e9 | |||
| 8571c78a53 | |||
| eba68e2aaa | |||
| e2df848e96 | |||
| 9acba28b85 | |||
| bef56fce10 | |||
| fccc4edb63 | |||
| 0a42edf595 | |||
| f4f5fc7730 | |||
| eeaacaf202 | |||
| d9aebfebd3 | |||
| 7078f7b9e6 | |||
| d316f4924b | |||
| 1df2d3ed05 | |||
| 4e877c15f6 | |||
| ef95708d02 | |||
| 7cbdc7a099 | |||
| c748869c44 | |||
| 60e11e23d4 | |||
| 883687f1c3 | |||
| 4466ceed99 | |||
| 6d6e5266b4 | |||
| 581a374b05 | |||
| 1009574721 | |||
| 50cae65214 | |||
| 48a46cf6f1 | |||
| 60b2bf35fb | |||
| cb5a7135ac | |||
| a7a9e35766 | |||
| f029835e15 | |||
| 017a172df3 | |||
| 7837122a95 | |||
| 0093255246 | |||
| 30bd53fb6f | |||
| ca22930012 | |||
| c7c5bda014 | |||
| 19aa572c95 | |||
| 03fae5dd95 | |||
| 80818a8861 | |||
| d29a8d9637 | |||
| f0b0231515 | |||
| b73d2a3b58 | |||
| 22cba5babf | |||
| 708ac51f23 | |||
| a91ffc88b9 | |||
| d727c2841b | |||
| 226a97732d | |||
| c94dd7743b | |||
| 64e38cb8ff | |||
| e61ac31710 | |||
| 3fbf68b117 | |||
| d4390483d9 | |||
| 8dea2091af | |||
| e3696e3ac5 | |||
|
|
027825b155 | ||
| 911203c190 | |||
| 2da0394003 | |||
| 4a65d095db | |||
| 8ea5779312 | |||
| 144ab76716 | |||
|
|
8da2c8cc30 | ||
|
|
570b31e2d6 | ||
|
|
07f542ca16 | ||
|
|
62e0fc51c2 | ||
|
|
94b600e527 | ||
|
|
5388e6052c | ||
|
|
21fe5a0279 | ||
|
|
ffba89a7b5 | ||
|
|
31954d2690 | ||
| 340d0a5219 | |||
| 2d2785d6a0 | |||
| 41d6e5fc73 | |||
| 7412d67c33 | |||
| 83db5302ad | |||
| 75f9f20ea3 | |||
| e43c45ebea | |||
| 708032311a | |||
| 5dead960ae | |||
| 12d81b79c7 | |||
| f3dc81e6eb | |||
| ef5f81932d | |||
| 214a264179 | |||
| 9b183a4b6c | |||
| f365cc9e3c | |||
| 9059f7a9a7 | |||
| e6cd86618e | |||
| c3fd27b140 | |||
| cf2e800dec | |||
| b60383cfe9 | |||
| c7d93db6f2 |
@@ -1,7 +1,3 @@
|
||||
|
||||
# I tried setting values here and using `vue-cli-service build --mode development`
|
||||
# but it didn't create some things in "dist":
|
||||
# - the "css" directory with the CSS extracted from Vue files
|
||||
# - the sw_scripts-combined* files
|
||||
#
|
||||
# ¯\_(ツ)_/¯
|
||||
# I tried and failed to set things here with vue-cli-service but
|
||||
# things may be more reliable with vite so let's try again.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Only the variables that start with VUE_APP_ are seen in the application process.env in Vue.
|
||||
VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||
VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VUE_APP_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
|
||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
|
||||
@@ -2,6 +2,7 @@ module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
@@ -9,9 +10,9 @@ module.exports = {
|
||||
"@vue/typescript/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
// parserOptions: {
|
||||
// ecmaVersion: 2020,
|
||||
// },
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
|
||||
27
.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
4
.gitignore
vendored
@@ -27,3 +27,7 @@ pnpm-debug.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
137
CHANGELOG.md
@@ -5,12 +5,145 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## ?
|
||||
### Added
|
||||
- Send list of contacts to someone
|
||||
### Changed
|
||||
- Moved contact actions from list onto detail page
|
||||
|
||||
|
||||
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30
|
||||
### Fixed
|
||||
- Bad "give" verbiage on offer page
|
||||
- Failing offer test
|
||||
|
||||
|
||||
## [0.3.19] - 2024.08.18 - ee9c14942ceba993bf21a11249601f205158ec71
|
||||
### Added
|
||||
- Update of an offer
|
||||
- Recipient description in offer list
|
||||
### Fixed
|
||||
- List of offers wasn't showing.
|
||||
- Destination page after sharing photo was wrong.
|
||||
|
||||
|
||||
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
|
||||
### Added
|
||||
- Photos on more screens
|
||||
### Fixed
|
||||
- Share of a photo, including sharing a photo from webkit/Safari which never worked
|
||||
### Changed in DB or environment
|
||||
- Nothing (though there's a new temp field in IndexedDB)
|
||||
|
||||
|
||||
## [0.3.15] - 2024.08.04 - c8f0f2c2b16b9f0b4b47d40f7bf29058c7baa68e
|
||||
### Added
|
||||
- Edit gives
|
||||
- Page to edit claim JSON before submitting
|
||||
- Update of imported contacts
|
||||
- Improve messaging on give dialog
|
||||
- Section for gives provided by plan
|
||||
- Deletion of an identity
|
||||
- UI for choosing a passkey creation (not enabled on prod)
|
||||
- Cache signatures for reports for passkey-signed requests
|
||||
- Refactor: consolidate alternative signing, eg. for passkeys & did:peer
|
||||
- Playwright tests
|
||||
### Changed
|
||||
- Linked projects display below description (instead of at bottom)
|
||||
### Fixed
|
||||
- Visibility toggle appearance
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.4] - 2024.03.21
|
||||
## [0.3.14] - 2024.06.22 - 1611d22892f683f43856d2503eee7f391b6bbce8
|
||||
### Added
|
||||
- Clearer give-confirmation screen
|
||||
- BX currency https://thebx.medium.com/
|
||||
- Deselection of project on gifted details page
|
||||
### Fixed
|
||||
- Don't show registration pop-up for a new contact that is registered
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
|
||||
### Added
|
||||
- Photos on projects
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
|
||||
### Fixed
|
||||
- Photo share (share_target) failed because requests were sent to server
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
|
||||
### Added
|
||||
- Choose a file for gifts, and a URL for gifts & profiles
|
||||
### Fixed
|
||||
- Multiple button pushes were required to switch camera
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
|
||||
### Added
|
||||
- Share an image
|
||||
- Choose a file on the device for a profile image
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
|
||||
### Added
|
||||
- Offers on contacts page
|
||||
- Checks on front page until they show as registered
|
||||
### Changed
|
||||
- Scanned contacts now add immediately and prompt for registration.
|
||||
- Better UI for gives on contact page
|
||||
- Better UI for all confirmation messages
|
||||
### Fixed
|
||||
- Repeated elements at top of main feed
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
|
||||
### Added
|
||||
- Profile image for user
|
||||
### Fixed
|
||||
- Slow loading of home page feed
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
|
||||
### Added
|
||||
- Filter on home page feed
|
||||
- Ability to set time of daily notification
|
||||
- Jump to app on click of notification
|
||||
### Changed
|
||||
- Built with vite
|
||||
- Descriptions on home page to include projects
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.6] - 2024.03.24 - 3a07e31d6313ab95711265562d9023c42916e141
|
||||
### Added
|
||||
- Button to mirror photo during video
|
||||
- More detailed onboarding help screen
|
||||
- Public-data blurb
|
||||
### Changed in DB or environment
|
||||
- Nothing
|
||||
|
||||
|
||||
## [0.3.5] - 2024.03.23 - 28754bdfb1e11aa221dd49a5dce4219b69cf6a9d
|
||||
### Added
|
||||
- Photo on gift records
|
||||
### Fixed
|
||||
|
||||
@@ -2,5 +2,10 @@
|
||||
|
||||
Welcome! We are happy to have your help with this project.
|
||||
|
||||
Note that all contributions will be under our
|
||||
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
|
||||
We expect contributions to include automated tests and pass linting. Run the `test-all` task.
|
||||
Note that some previous features don't have tests and adding more will make you friends quick.
|
||||
|
||||
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
|
||||
|
||||
If you want to see a code of conduct, we're probably not the people you want to hang with.
|
||||
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.
|
||||
|
||||
91
README.md
@@ -10,39 +10,46 @@ See [project.task.yaml](project.task.yaml) for current priorities.
|
||||
|
||||
## Setup
|
||||
|
||||
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
|
||||
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
### Compile and hot-reloads for development
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build the test & production app
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
### Lint and fix files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
### Compile and minify for test & production
|
||||
|
||||
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
|
||||
|
||||
* `npx prettier --write ./sw_scripts/`
|
||||
|
||||
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
|
||||
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
|
||||
|
||||
* Commit everything (since the commit hash is used the app).
|
||||
|
||||
* Record what version is currently on production.
|
||||
|
||||
* Run the correct build
|
||||
* Run the correct build:
|
||||
|
||||
* Test
|
||||
* Staging
|
||||
```
|
||||
# (See .env.development for more details.)
|
||||
# (Let's replace this with a .env.development or .env.staging file.)
|
||||
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VUE_APP_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build
|
||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=yep npm run build
|
||||
```
|
||||
|
||||
* Production
|
||||
@@ -51,12 +58,10 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=htt
|
||||
npm run build
|
||||
```
|
||||
|
||||
* Get on the server and back up 3 DBs and the time-safari folder.
|
||||
* Get on the server and back up the time-safari/dist folder.
|
||||
|
||||
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
|
||||
|
||||
* Revert src/constants/app.ts and package.json (if that was prod).
|
||||
|
||||
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
||||
|
||||
* [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
|
||||
@@ -66,6 +71,41 @@ npm run build
|
||||
|
||||
## Tests
|
||||
|
||||
### Automated
|
||||
|
||||
Use the locally running Endorser server:
|
||||
|
||||
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
|
||||
```
|
||||
test/test.sh
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
|
||||
* Now run the local tests:
|
||||
```
|
||||
npm run test-all
|
||||
```
|
||||
|
||||
Note that a test will sometimes fail and rerunning may succeed (and repeat if a different test fails).
|
||||
|
||||
|
||||
|
||||
|
||||
It's possible to use the global test Endorser (ledger) server (but currently the tests don't all succeed):
|
||||
`npx playwright test`
|
||||
|
||||
|
||||
|
||||
|
||||
It's possible to run with a minimal set of data: the following starts with the bare minimum of test data (but currently the tests don't all succeed):
|
||||
```
|
||||
rm ../endorser-ch-test-local.sqlite3
|
||||
NODE_ENV=test-local npm run flyway migrate
|
||||
NODE_ENV=test-local npm run test test/controller0
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
|
||||
|
||||
### Register new user on test server
|
||||
|
||||
On the test server, User #0 has rights to register others, so you can start
|
||||
@@ -92,11 +132,11 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
||||
|
||||
### Manual walk-through test
|
||||
|
||||
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act.
|
||||
- Use a mobile user as well as a desktop user.
|
||||
- Backup seed & data & get a CSV dump from Endorser Mobile.
|
||||
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
|
||||
- Use a mobile user as well as a desktop user.
|
||||
- Check that the version is updated.
|
||||
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
|
||||
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
|
||||
- Make sure that it's using the test API (under Identity in 'Advanced').
|
||||
- Clear the browser data again. (See "Reset" below.)
|
||||
- Go to the account page before visiting the home page to see that there is no ID.
|
||||
@@ -106,15 +146,19 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
||||
- Copy the contact URL.
|
||||
- On each page, verify the messaging, and that they cannot take action.
|
||||
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
|
||||
- On the contacts page, check that they can add User #0 even without their own ID.
|
||||
- As User #0 in another browser on the test API, add a give & a project.
|
||||
- `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
||||
- With the new user on the home page, see the feed that shows User #0 in network but without the name.
|
||||
- As the new user on the contacts page, add User #0 as a contact.
|
||||
- On the home page, see the feed that shows User #0 with a name.
|
||||
- On the contacts page, check that they can add a contact even without their own ID.
|
||||
- Install the PWA.
|
||||
- As User 0 in another browser on the test API, add a give & a project.
|
||||
- Note that some combinations of desktop with mobile emulation stretch the image.
|
||||
- Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
||||
- Add new user as a contact (which allows them to see User 0).
|
||||
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
|
||||
- As the new user, import contacts & identifiers.
|
||||
- As the new user on the contacts page, add User 0 as a contact.
|
||||
- On the home page, see the feed that shows User 0 with a name.
|
||||
- Switch back to the generated identifier.
|
||||
- On the account page, check that they see messages on limits.
|
||||
- As User #0, register the ID.
|
||||
- As User 0, register the ID.
|
||||
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
|
||||
- On the contacts page, check that they cannot register someone else yet.
|
||||
- Walk through the functions on each page.
|
||||
@@ -122,13 +166,14 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
||||
- Export & import, both seed and contacts & settings.
|
||||
- Choose location on the search map.
|
||||
- Offer, deliver a give, and confirm. Create a third user and test connections.
|
||||
- On mobile, share an image with the app.
|
||||
- Switch to "no identifier" to see that things look OK without any ID.
|
||||
|
||||
### Clear/Reset data & restart
|
||||
|
||||
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.)
|
||||
* Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".)
|
||||
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals/`; in Firefox, go to `about:serviceworkers`.)
|
||||
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals`; in Firefox, go to `about:serviceworkers`.)
|
||||
* Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.)
|
||||
|
||||
(If you find more, add them to the HelpNotificationsView.vue file.)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
||||
76
doc/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# TimeSafari Docs
|
||||
|
||||
## Generating PDF from Markdown on OSx
|
||||
|
||||
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew.
|
||||
|
||||
### Set Up
|
||||
|
||||
```bash
|
||||
brew install pandoc
|
||||
|
||||
brew install basictex
|
||||
|
||||
# Setting up LaTex packages
|
||||
|
||||
# First update tlmgr
|
||||
sudo tlmgr update --self
|
||||
|
||||
# Then install LaTex packages
|
||||
sudo tlmgr install bbding
|
||||
sudo tlmgr install enumitem
|
||||
sudo tlmgr install environ
|
||||
sudo tlmgr install fancyhdr
|
||||
sudo tlmgr install framed
|
||||
sudo tlmgr install import
|
||||
sudo tlmgr install lastpage # Enables Page X of Y
|
||||
sudo tlmgr install mdframed
|
||||
sudo tlmgr install multirow
|
||||
sudo tlmgr install needspace
|
||||
sudo tlmgr install ntheorem
|
||||
sudo tlmgr install tabu
|
||||
sudo tlmgr install tcolorbox
|
||||
sudo tlmgr install textpos
|
||||
sudo tlmgr install titlesec
|
||||
sudo tlmgr install titling # Required for the fancy headers used
|
||||
sudo tlmgr install threeparttable
|
||||
sudo tlmgr install trimspaces
|
||||
sudo tlmgr install tocloft # Required for \tableofcontents generation
|
||||
sudo tlmgr install varwidth
|
||||
sudo tlmgr install wrapfig
|
||||
|
||||
# Install fonts
|
||||
sudo tlmgr install cmbright
|
||||
sudo tlmgr install collection-fontsrecommended # And set up fonts
|
||||
sudo tlmgr install fira
|
||||
sudo tlmgr install fontaxes
|
||||
sudo tlmgr install libertine # The main font the doc uses
|
||||
sudo tlmgr install opensans
|
||||
sudo tlmgr install sourceserifpro
|
||||
|
||||
```
|
||||
|
||||
#### References
|
||||
|
||||
The following guide was adapted to this project except that we install with Brew and have a few more packages.
|
||||
|
||||
Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x
|
||||
|
||||
### Usage
|
||||
|
||||
Use the `pandoc` command to generate a PDF.
|
||||
|
||||
```bash
|
||||
pandoc usage-guide.md -o usage-guide.pdf
|
||||
```
|
||||
|
||||
And you can open the PDF with the `open` command.
|
||||
|
||||
```bash
|
||||
open usage-guide.pdf
|
||||
```
|
||||
|
||||
Or use this one-liner
|
||||
```bash
|
||||
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf
|
||||
```
|
||||
BIN
doc/images/01_infura-api-keys.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
doc/images/02-infura-key-detail.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
doc/images/03-infura-api-key-id.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
doc/images/04-pwa-chrome-devtools.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
doc/images/05-pwa-account-button.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
doc/images/06-pwa-account-page.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
doc/images/07-pwa-did-copied.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
doc/images/08-endorser-sqlite-row-added.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
doc/images/09-pwa-second-profile-first-open.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
doc/images/10-pwa-second-user-did.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
doc/images/11-pwa-first-user-add-contact.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
doc/images/12-pwa-first-user-contact-added.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
doc/images/13-pwa-first-user-register-second-user-btn.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
doc/images/14-pwa-first-user-register-yes.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
doc/images/timesafari-logo-binoculars.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
doc/images/timesafari-logo.png
Normal file
|
After Width: | Height: | Size: 463 KiB |
316
doc/usage-guide.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
geometry: margin=1in
|
||||
header-includes:
|
||||
- \usepackage{graphicx}
|
||||
- \usepackage{titling}
|
||||
- \usepackage{fancyhdr}
|
||||
- \usepackage{lastpage}
|
||||
- \pagestyle{fancy}
|
||||
- \fancyhead[L]{Time Safari Usage Guide}
|
||||
- \fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
|
||||
- \fancyhead[R]{}
|
||||
- \fancyfoot[L]{}
|
||||
- \fancyfoot[C]{}
|
||||
- \fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}
|
||||
- \usepackage{tocloft}
|
||||
- \usepackage{libertine}
|
||||
- \renewcommand{\familydefault}{\sfdefault}
|
||||
- \fancypagestyle{tocstyle}{
|
||||
\fancyhead[L]{Time Safari Usage Guide}
|
||||
\fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
|
||||
\fancyhead[R]{}
|
||||
\fancyfoot[L]{}
|
||||
\fancyfoot[C]{}
|
||||
\fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}}
|
||||
---
|
||||
|
||||
\begin{titlepage}
|
||||
\centering
|
||||
\vspace*{\fill}
|
||||
{\huge\textbf{TimeSafari Usage guide}}
|
||||
|
||||
\vspace{1cm}
|
||||
{\Large Signing up users, adding contacts, and adding gifts.}
|
||||
|
||||
\vspace{1cm}
|
||||
\includegraphics[width=0.5\textwidth]{images/timesafari-logo.png}
|
||||
\vspace*{\fill}
|
||||
|
||||
\vspace{1cm}
|
||||
{\Large Trent Larson, Kent Bull}
|
||||
|
||||
\vspace{0.5cm}
|
||||
{\large 2024-06-25}
|
||||
|
||||
\end{titlepage}
|
||||
|
||||
\clearpage
|
||||
|
||||
\begin{center}
|
||||
\includegraphics[width=2cm]{images/timesafari-logo-binoculars.png}
|
||||
\end{center}
|
||||
\tableofcontents
|
||||
|
||||
\clearpage
|
||||
|
||||
|
||||
# Purpose of Document
|
||||
|
||||
Both end-users and development team members need to know how to use TimeSafari.
|
||||
This document serves to show how to use every feature of the TimeSafari platform.
|
||||
|
||||
Sections of this document are geared specifically for software developers and quality assurance
|
||||
team members.
|
||||
|
||||
Companion videos will also describe end-to-end workflows for the end-user.
|
||||
|
||||
# TimeSafari
|
||||
|
||||
## Overview
|
||||
|
||||
\pagebreak
|
||||
|
||||
# 1 - End Users
|
||||
|
||||
This section covers application usage for people who will use TimeSafari as intended. It is a
|
||||
simplified guide illustrating how to gain value from using TimeSafari.
|
||||
|
||||
\pagebreak
|
||||
|
||||
# 2 - Software Developers
|
||||
|
||||
This section is tailored for software developers seeking to use the application during development,
|
||||
quality assurance, and testing.
|
||||
|
||||
# Bootstrapping a local development environment
|
||||
|
||||
The first concern a software developer has when working on TimeSafari is to set up a local
|
||||
development environment. This section will guide you through the process.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Have the following installed on your local machine:
|
||||
- Node.js and NPM
|
||||
- A web browser. For this guide, we will use Google Chrome.
|
||||
- Git
|
||||
- A code editor
|
||||
|
||||
2. Create an API key on Infura. This is necessary for the Endorser API to connect to the Ethereum
|
||||
blockchain.
|
||||
- You can create an account on Infura [here](https://infura.io/).\
|
||||
Click "CREATE NEW API KEY" and label the key. Then click "API Keys" in the top menu bar to
|
||||
be taken back to the list of keys.
|
||||
|
||||
Click "VIEW STATS" on the key you want to use.
|
||||
|
||||
{ width=550px }
|
||||
|
||||
- Go to the key detail page. Then click "MANAGE API KEY".
|
||||
|
||||
{ width=550px }
|
||||
|
||||
- Click the copy and paste button next to the string of alphanumeric characters.\
|
||||
This is your API, also known as your project ID.
|
||||
|
||||
{width=550px }
|
||||
|
||||
- Save this for later during the Endorser API setup. This will go in your `INFURA_PROJECT_ID`
|
||||
environment variable.
|
||||
|
||||
|
||||
## Setup steps
|
||||
|
||||
### 1. Clone the following repositories from their respective Git hosts:
|
||||
- [TimeSafari Frontend](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa)\
|
||||
This is a Progressive Web App (PWA) built with VueJS and TypeScript.
|
||||
Note that the clone command here is different from the one you would use for GitHub.
|
||||
|
||||
```bash
|
||||
git clone git clone \
|
||||
ssh://git@gitea.anomalistdesign.com:222/trent_larson/crowd-funder-for-time-pwa.git
|
||||
```
|
||||
|
||||
- [TimeSafari Backend - Endorser API](https://github.com/trentlarson/endorser-ch)\
|
||||
This is a NodeJS service providing the backend for TimeSafari.
|
||||
|
||||
```bash
|
||||
git clone git@github.com:trentlarson/endorser-ch.git
|
||||
```
|
||||
|
||||
\pagebreak
|
||||
|
||||
### 2. Database creation
|
||||
|
||||
#### Alternative 1 - use test data
|
||||
|
||||
To generate a development database and perform user setup you can run a local test with instructions
|
||||
below to generate sample data. Then copy the test database, rename it to `-dev` as below:\
|
||||
`cp ../endorser-ch-test-local.sqlite3 ../endorser-ch-dev.sqlite3` \
|
||||
and rerun `npm run dev` to give yourself user #0 and others from the ETHR_CRED_DATA in [the endorser.ch test util file](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js#L90)
|
||||
|
||||
#### Alternative 2 - boostrap single seed user
|
||||
|
||||
In this method you will end up with two accounts in the database, one for the first boostrap user,
|
||||
and the second as the primary user you will use during testing. The first user will invite the
|
||||
second user to the app.
|
||||
|
||||
1. Install dependencies and environment variables.\
|
||||
In endorser-ch install dependencies and set up environment variables to allow starting it up in
|
||||
development mode.
|
||||
```bash
|
||||
cd endorser-ch
|
||||
npm clean install # or npm ci
|
||||
cp .env.local .env
|
||||
```
|
||||
Edit the .env file's INFURA_PROJECT_ID with the value you saved earlier in the
|
||||
prerequisites.\
|
||||
Then create the SQLite database by running `npm run flyway migrate` with environment variables
|
||||
set correctly to select the default SQLite development user as follows.
|
||||
```bash
|
||||
export NODE_ENV=dev
|
||||
export DBUSER=sa
|
||||
export DBPASS=sasa
|
||||
npm run flyway migrate
|
||||
```
|
||||
The first run of flyway migrate may take some time to complete because the entire Flyway
|
||||
distribution must be downloaded prior to executing migrations.
|
||||
|
||||
Successful output looks similar to the following:
|
||||
|
||||
```
|
||||
Database: jdbc:sqlite:../endorser-ch-dev.sqlite3 (SQLite 3.41)
|
||||
Schema history table "main"."flyway_schema_history" does not exist yet
|
||||
Successfully validated 10 migrations (execution time 00:00.034s)
|
||||
Creating Schema History table "main"."flyway_schema_history" ...
|
||||
Current version of schema "main": << Empty Schema >>
|
||||
Migrating schema "main" to version "1 - initial-anew"
|
||||
Migrating schema "main" to version "2 - registration"
|
||||
Migrating schema "main" to version "3 - plan project"
|
||||
Migrating schema "main" to version "4 - offer gave"
|
||||
Migrating schema "main" to version "5 - more confirmations"
|
||||
Migrating schema "main" to version "6 - providers urls"
|
||||
Migrating schema "main" to version "7 - hash nonce"
|
||||
Migrating schema "main" to version "8 - project location"
|
||||
Migrating schema "main" to version "9 - plan links"
|
||||
Migrating schema "main" to version "10 - gift or trade"
|
||||
Successfully applied 10 migrations to schema "main", now at version v10 (execution time 00:00.043s)
|
||||
A Flyway report has been generated here: /Users/kbull/code/timesafari/endorser-ch/report.html
|
||||
```
|
||||
|
||||
\pagebreak
|
||||
|
||||
2. Generate the first user in TimeSafari PWA and bootstrap that user in Endorser's database.\
|
||||
As TimeSafari is an invite-only platform the first user must be manually bootstrapped since
|
||||
no other users exist to be able to invite the first user. This first user must be added manually
|
||||
to the SQLite database used by Endorser. In this setup you generate the first user from the PWA.
|
||||
|
||||
This user is automatically generated on first usage of the TimeSafari PWA. Bootstrapping that
|
||||
user is required so that this first user can register other users.
|
||||
- Change directories into `crowd-funder-for-time-pwa`
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cd crowd-funder-for-time-pwa
|
||||
```
|
||||
|
||||
- Ensure the `.env.development` file exists and has the following values:
|
||||
|
||||
```env
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
- Install dependencies and run in dev mode. For now don't worry about configuring the app. All we
|
||||
need is to generate the first root user and this happens automatically on app startup.
|
||||
|
||||
```bash
|
||||
npm clean install # or npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Open the app in a browser and go to the developer tools. It is recommended to use a completely
|
||||
separate browser profile so you do not clear out your existing user account. We will be
|
||||
completely resetting the PWA app state prior to generating the first user.
|
||||
|
||||
In the Developer Tools go to the Application tab.
|
||||
|
||||
{width=350px}
|
||||
|
||||
Click the "Clear site data" button and then refresh the page.
|
||||
|
||||
- Click the account button in the bottom right corner of the page.
|
||||
|
||||
{width=150px}
|
||||
|
||||
- This will take you to the account page titled "Your Identity" on which you can see your DID,
|
||||
a `did:ethr` DID in this case.
|
||||
|
||||
{width=350px}
|
||||
|
||||
- Copy the DID by selecting it and copying it to the clipboard or by clicking the copy and paste
|
||||
button as shown in the image.
|
||||
|
||||
{width=200px}
|
||||
|
||||
In our case this DID is:\
|
||||
`did:ethr:0xe4B783c74c8B0e229524e44d0cD898D272E02CD6`
|
||||
|
||||
- Add that DID to the following echoed SQL statement where it says `YOUR_DID`
|
||||
|
||||
```bash
|
||||
echo "INSERT INTO registration (did, maxClaims, maxRegs, epoch)
|
||||
VALUES ('YOUR_DID', 100, 10000, 1719348718092);"
|
||||
| sqlite3 ./endorser-ch-dev.sqlite3
|
||||
```
|
||||
|
||||
and run this command in the parent directory just above the `endorser-ch` directory.
|
||||
|
||||
It needs to be the parent directory of your `endorser-ch` repository because when
|
||||
`endorser-ch` creates the SQLite database it depends on it creates it in the parent directory
|
||||
of `endorser-ch`.
|
||||
|
||||
- You can verify with an SQL browser tool that your record has been added to the `registration`
|
||||
table.
|
||||
|
||||
{width=350px}
|
||||
|
||||
3. Then start the Endorser service in development mode with the following commands.
|
||||
|
||||
```bash
|
||||
cd ./endorser-ch
|
||||
export NODE_ENV=dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Endorser service on port 3000.
|
||||
4. Create the second user by opening up a separate browser profile or incognito session, opening the
|
||||
TimeSafari PWA at `http://localhost:8080`. You will see the yellow banner stating "Someone must
|
||||
register you before you can give or offer."
|
||||
|
||||
{width=350px}
|
||||
|
||||
- If you want to ensure you have a fresh user account then open the developer tools, clear the
|
||||
Application data as before, and then refresh the page. This will generate a new user in the
|
||||
browser's IndexedDB database.
|
||||
5. Go to the second users' account page to copy the DID.
|
||||
|
||||
{width=350px}
|
||||
|
||||
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account
|
||||
|
||||
{width=350px}
|
||||
|
||||
7. Click the "+" plus icon to add the user.
|
||||
|
||||
{width=350px}
|
||||
|
||||
8. Then click the register button to register the second user.
|
||||
|
||||
{width=350px}
|
||||
|
||||
9. Click "YES" on the dialog that shows up.
|
||||
|
||||
{width=350px}
|
||||
|
||||
After this a notification will pop up indicating whether registration was successful or not.
|
||||
|
||||
10. You have finished the initial set up of users.
|
||||
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>TimeSafari</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
19370
package-lock.json
generated
126
package.json
@@ -1,95 +1,101 @@
|
||||
{
|
||||
"name": "TimeSafari",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"version": "0.3.21-beta",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"dev": "vite",
|
||||
"serve": "vite preview",
|
||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
|
||||
"test-local": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^5.3.5",
|
||||
"@dicebear/core": "^5.3.5",
|
||||
"@dicebear/collection": "^5.4.1",
|
||||
"@dicebear/core": "^5.4.1",
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
"@tweenjs/tween.js": "^21.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@veramo/core": "^5.4.1",
|
||||
"@veramo/credential-w3c": "^5.4.1",
|
||||
"@veramo/data-store": "^5.4.1",
|
||||
"@veramo/did-manager": "^5.4.1",
|
||||
"@veramo/did-provider-ethr": "^5.4.1",
|
||||
"@veramo/did-resolver": "^5.4.1",
|
||||
"@veramo/key-manager": "^5.4.1",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.0",
|
||||
"@tweenjs/tween.js": "^21.1.1",
|
||||
"@veramo/core": "^5.6.0",
|
||||
"@veramo/credential-w3c": "^5.6.0",
|
||||
"@veramo/data-store": "^5.6.0",
|
||||
"@veramo/did-manager": "^5.6.0",
|
||||
"@veramo/did-provider-ethr": "^5.6.0",
|
||||
"@veramo/did-provider-peer": "^6.0.0",
|
||||
"@veramo/did-resolver": "^5.6.0",
|
||||
"@veramo/key-manager": "^5.6.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"axios": "^1.5.0",
|
||||
"buffer": "^6.0.3",
|
||||
"asn1-ber": "^1.2.2",
|
||||
"axios": "^1.6.8",
|
||||
"cbor-x": "^1.5.9",
|
||||
"class-transformer": "^0.5.1",
|
||||
"core-js": "^3.32.1",
|
||||
"dexie": "^3.2.4",
|
||||
"dexie-export-import": "^4.0.7",
|
||||
"did-jwt": "^7.2.7",
|
||||
"ethereum-cryptography": "^2.1.2",
|
||||
"dexie": "^3.2.7",
|
||||
"dexie-export-import": "^4.1.1",
|
||||
"did-jwt": "^7.4.7",
|
||||
"ethereum-cryptography": "^2.1.3",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"ethr-did-resolver": "^8.1.2",
|
||||
"git-describe": "^4.1.1",
|
||||
"jdenticon": "^3.2.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"js-yaml": "^4.1.0",
|
||||
"localstorage-slim": "^2.5.0",
|
||||
"localstorage-slim": "^2.7.0",
|
||||
"lru-cache": "^10.2.0",
|
||||
"luxon": "^3.4.4",
|
||||
"merkletreejs": "^0.3.11",
|
||||
"moment": "^2.29.4",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"ramda": "^0.29.0",
|
||||
"readable-stream": "^4.4.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"ramda": "^0.29.1",
|
||||
"readable-stream": "^4.5.2",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"simple-vue-camera": "^1.1.3",
|
||||
"three": "^0.156.1",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"util": "^0.12.5",
|
||||
"vue": "^3.3.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "^3.0.2",
|
||||
"vue-qrcode-reader": "^5.4.1",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue-facing-decorator": "^3.0.4",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-qrcode-reader": "^5.5.3",
|
||||
"vue-router": "^4.3.0",
|
||||
"web-did-resolver": "^2.0.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.4",
|
||||
"@types/ramda": "^0.29.3",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/ramda": "^0.29.11",
|
||||
"@types/three": "^0.155.1",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||
"@vue/cli-plugin-pwa": "~5.0.8",
|
||||
"@vue/cli-plugin-router": "~5.0.8",
|
||||
"@vue/cli-plugin-typescript": "~5.0.8",
|
||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.1.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "~5.2.2"
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-pwa": "^0.19.8"
|
||||
}
|
||||
}
|
||||
|
||||
98
playwright.config-local.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './test-playwright',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:8080',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ["clipboard-read"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
{
|
||||
name: 'Google Chrome',
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
},
|
||||
],
|
||||
|
||||
/* Configure global timeout; default is 30000 milliseconds */
|
||||
// the image upload will often not succeed at 5 seconds
|
||||
timeout: 20000,
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
/**
|
||||
* This could be an array of servers, meaning we could start the Endorser server as well:
|
||||
* {
|
||||
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
|
||||
* url: 'http://localhost:3000',
|
||||
* reuseExistingServer: !process.env.CI,
|
||||
* },
|
||||
*
|
||||
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
|
||||
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
|
||||
* in the user's settings so that it can be blanked out and the default is used.
|
||||
*/
|
||||
webServer: {
|
||||
command:
|
||||
"VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
|
||||
url: "http://localhost:8080",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
82
playwright.config.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './test-playwright',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'https://test.timesafari.app',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ["clipboard-read"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command:
|
||||
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
|
||||
// url: "http://localhost:8080",
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
@@ -1,178 +1,4 @@
|
||||
|
||||
tasks :
|
||||
|
||||
- bug - landscape doesn't show full camera
|
||||
- bug - got blank screen and errors on iPhone with no bottom tabs
|
||||
- add to readme - check version, close tabs & restart phone if necessary
|
||||
- bug maybe - a new give remembers the previous project
|
||||
- alert & stop if give amount < 0
|
||||
- add warning that all data (except ID) is public
|
||||
- onboarding video
|
||||
- .2 when adding a claim on home screen, push that claim to the top of the list
|
||||
|
||||
- 24 allow a person record with interests, including location; purpose? contact methods? enhance other connections the same? (suggestion from Philippines) assignee-group:ui
|
||||
|
||||
- .1 on feed, don't show "to someone anonymous" if it's to a project
|
||||
- .1 on ideas, put an "x" to close it assignee-group:ui
|
||||
- 16 save data backups in Google
|
||||
- 16 generate and use passkeys for identities
|
||||
|
||||
- .2 fix give dialog from "more contacts" off home page to allow giving to this user
|
||||
- .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page assignee-group:ui
|
||||
- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window)
|
||||
- .2 don't show a warning on a totally new project when the authorized agent is set
|
||||
- .2 anchor hash into BTC
|
||||
- .2 list the "show more" contacts alphabetically
|
||||
|
||||
- .5 make Time Safari a share_target for images
|
||||
|
||||
- 08 add image on profile
|
||||
|
||||
- ask to detect location & record it in settings
|
||||
- if personal location is set, show potential local affiliations
|
||||
|
||||
- 24 compelling UI for credential presentations
|
||||
- discover who in my network has activity on a project
|
||||
|
||||
- 24 compelling UI for statistics (eg. World?)
|
||||
|
||||
- 01 in the feed, group by project or contact or topic or time/$ (via BC); new projects, offers, search area, etc assignee-group:ui
|
||||
- 01 separate not-on-platform vs totally anonymous; terminology "unidentified"?
|
||||
- .2 add links between projects assignee-group:ui
|
||||
- 24 make the contact browsing on the front page something that invites more action
|
||||
|
||||
- .5 change server plan & project endpoints to use jwtId as identifier rather than rowid
|
||||
- 16 edit offers & gives, or revoke allowing re-creation
|
||||
- .1 When available in the server, give message for 'nonAmountGiven' on offers on ProjectsView page.
|
||||
- .1 Add help instructions for "Encryption key has changed" error. (It is a problem if localStorage is cleared, but the contacts & settings remain and they have to restore their seeds.)
|
||||
- .1 show better error when user with no ID goes to the "My Project" page
|
||||
- 01 in front page prompt for ideas for gratitude :
|
||||
- randomize (not show in order)
|
||||
- checkboxes - show non-person-oriented messages, show only contacts, show only projects
|
||||
|
||||
- .5 add a notice on the front page if their notifications are off
|
||||
- 08 allow user to add a time when they want their daily notification
|
||||
|
||||
- .5 prompt for the name directly when they visit the QR scan page
|
||||
- 01 mark a project as inactive
|
||||
- 01 add share button for sending a message to confirmers when we can't see the claim (like the "visible" links)
|
||||
- .5 add TimeSafari as a shareable app https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
|
||||
- .5 choose a project's alternative agent ("authorized representative") via a contact chooser (not just copy-paste a DID)
|
||||
- .5 find out why clicking quickly back-and-forth onto the "my project" page often shows error "You need an identifier to load your projects." (easier to reproduce on desktop?)
|
||||
- .5 bug - it didn't show the "fulfills offer" on the claim detail page for a give that had one - https://test.timesafari.app/claim/01HMFWRPA3PD6Q9EYFKX3MC41J
|
||||
- 01 replace all "confirm" prompts with nicer modal
|
||||
- .1 hide project-create button on project page if not registered
|
||||
- .1 hide offer & give buttons on project list page if not registered
|
||||
- .1 add cursor-pointer on the icons for giving on the project page, and on the list of projects on the discover page
|
||||
- .2 record when InfiniteScroll hits the end of the list and don't trigger any more loads (feed, project list, give & offer lists)
|
||||
|
||||
- bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.)
|
||||
- refactor - supply the projectId to the OfferDialog just like we do with the GiftedDialog offerId (in the "open" method, maybe as well as an attribute)
|
||||
- the confirm button on each give on the ProjectViewView page doesn't have all the context of the ClaimView page, so it can show sometimes inappropriately; consider consolidation
|
||||
- make the "give" on contact screen work like other give (allowing donation vs current blank)
|
||||
- .2 on ClaimView, the "ask someone" should refer to "visible" IDs, or to confirmations only if confirmations are visible
|
||||
- message "send them to this page" on ClaimView should be a link (for installed app)
|
||||
- When we update a version, desktop browser users have seen nothing happen after clicking on the contact page QR and on the account page "Help"; errors show in the console. Reload fixed it. If this happens on mobile, ask the user to reload.
|
||||
|
||||
- 01 show my VCs - most interesting, or via search
|
||||
- 04 allow user to download & prove chains of VCs, mine + ones I can see about me from others
|
||||
|
||||
- revenue to support server operation
|
||||
|
||||
- .1 copy button for seed
|
||||
- .5 If notifications are not enabled, add message to front page with link/button to enable
|
||||
- make server endpoint for full English description of limits
|
||||
- create a help-desk document & add screenshots
|
||||
|
||||
- .1 update "offer" units to have same functionality as "give" units
|
||||
- .5 add a link to any 'give' records that fulfill an offer on ClaimView
|
||||
- 01 on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed)
|
||||
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list)
|
||||
- bug (that is hard to reproduce) - got error adding on Firefox user #0 as contact for themselves
|
||||
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function"
|
||||
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
|
||||
- 04 remove 'rowid' references (that are sqlite-specific); may involve server
|
||||
- 04 look at other examples for better onboarding UI, eg friend.tech
|
||||
- .5 Add inactive flag / end date, start date to project
|
||||
- .3 check that Android shows "back" buttons on screens without bottom tray
|
||||
- .1 Make give description text box into something that expands as they type?
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?)
|
||||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
|
||||
- .5 Display a more appealing confirmation on the map when erasing the marker
|
||||
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
|
||||
- switch some checks for activeDid to check for isRegistered
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
||||
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
|
||||
- warn if they're using the web (android only?)
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps
|
||||
https://web.dev/articles/get-installed-related-apps
|
||||
- .5 fix the "onboarding help" list of instructions so that it always formats right (currently doesn't show numbers aligned on Google Pixel 6a, iPhone 11 Pro, iPhone 12 mini)
|
||||
- .5 make the "onboarding help" it so that it doesn't cover the QR icon on the contacts page
|
||||
- .5 fix masked icon (because some of the top-right of the binoculars is cut off)
|
||||
|
||||
- contacts v+ :
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||
- .2 show error to user when adding a duplicate contact
|
||||
- 01 parse input more robustly (with CSV lib and not commas)
|
||||
|
||||
- stats v1 :
|
||||
- 01 show numeric stats
|
||||
- 04 show different graphic for projects vs people (gnome?) on world
|
||||
- 01 link to world for specific stats
|
||||
- .5 don't load another instance of a bush if it already exists
|
||||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
|
||||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
||||
|
||||
- .5 show seed phrase in a QR code for transfer to another device
|
||||
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
|
||||
- .5 don't show "Offer" on project screen if they aren't registered
|
||||
- 01 especially for iOS, check for new version & update, eg. https://stackoverflow.com/questions/52221805/any-way-yet-to-auto-update-or-just-clear-the-cache-on-a-pwa-on-ios
|
||||
|
||||
- 24 Move to Vite
|
||||
- 32 accept images for projects
|
||||
- 32 accept images for contacts
|
||||
- import project interactions from GitHub/GitLab and manage signing
|
||||
|
||||
- show total time offered to & fulfilled to a project
|
||||
- show total time offered by & fulfilled by a contact
|
||||
|
||||
- linking between projects or plans :
|
||||
- show total time given to & from a project
|
||||
- terminology:
|
||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
|
||||
|
||||
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
|
||||
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
|
||||
|
||||
- Stats :
|
||||
- 01 point out user's location on the world
|
||||
- 01 present a credential selected from the stats
|
||||
- 04 show gives spreading to other places
|
||||
- badge for most gives/receives/confirms per day/week/month
|
||||
- badge for amount given/offered to your project
|
||||
- set a goal of given/offers
|
||||
|
||||
- automated tests, eg. pup-test or cypress
|
||||
|
||||
- Notifications (wake on the phone, push notifications)
|
||||
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee
|
||||
- pull instead of push, maybe via scheduled runs
|
||||
- have a notification pop-up on Mac screen
|
||||
|
||||
- 16 Connect with phone contacts - this may be a whole different app, because we want a quick link A) to the same phone contact and B) from the phone contact app
|
||||
|
||||
- Support KERI AIDs
|
||||
- Support Peer DIDs
|
||||
- Support messaging through DIDComm
|
||||
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
|
||||
|
||||
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
|
||||
|
||||
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections -- and that allows spam
|
||||
- .1 When Chrome shows compatibility https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||
then change the canShare check in this app to check the real canShare() method.
|
||||
|
||||
log :
|
||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
||||
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27
|
||||
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
230
src/App.vue
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<!-- https://github.com/emmanuelsw/notiwind -->
|
||||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
||||
<NotificationGroup group="alert">
|
||||
<div
|
||||
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
||||
@@ -129,6 +129,10 @@
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
|
||||
<!--
|
||||
This "group" of "modal" is the prompt for an answer.
|
||||
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
|
||||
-->
|
||||
<NotificationGroup group="modal">
|
||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||
<Notification
|
||||
@@ -142,12 +146,19 @@
|
||||
move="transition duration-500"
|
||||
move-delay="delay-300"
|
||||
>
|
||||
<!-- see NotificationIface in constants/app.ts -->
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="w-full"
|
||||
role="alert"
|
||||
>
|
||||
<!--
|
||||
Type of "confirm" will post a message.
|
||||
With onYes function, show a "Yes" button to call that function.
|
||||
With onNo function, show a "No" button to call that function,
|
||||
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
||||
-->
|
||||
<div
|
||||
v-if="notification.type === 'confirm'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
@@ -156,11 +167,13 @@
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">
|
||||
<span class="font-semibold text-lg">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
</span>
|
||||
<p class="text-sm mb-2">{{ notification.text }}</p>
|
||||
|
||||
<button
|
||||
v-if="notification.onYes"
|
||||
@click="
|
||||
notification.onYes();
|
||||
close(notification.id);
|
||||
@@ -168,13 +181,58 @@
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Yes
|
||||
{{ notification.yesText ? ", " + notification.yesText : "" }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
v-if="notification.onNo"
|
||||
@click="
|
||||
notification.onNo(stopAsking);
|
||||
close(notification.id);
|
||||
stopAsking = false; // reset value
|
||||
"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
No {{ notification.noText ? ", " + notification.noText : "" }}
|
||||
</button>
|
||||
|
||||
<label
|
||||
v-if="notification.promptToStopAsking && notification.onNo"
|
||||
for="toggleStopAsking"
|
||||
class="flex items-center justify-between cursor-pointer my-4"
|
||||
@click="stopAsking = !stopAsking"
|
||||
>
|
||||
<!-- label -->
|
||||
<span class="ml-2">... and do not ask again.</span>
|
||||
<!-- toggle -->
|
||||
<div class="relative ml-2">
|
||||
<!-- input -->
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="stopAsking"
|
||||
name="stopAsking"
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
@click="
|
||||
notification.onCancel
|
||||
? notification.onCancel(stopAsking)
|
||||
: null;
|
||||
close(notification.id);
|
||||
stopAsking = false; // reset value
|
||||
"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
{{ notification.onYes ? "Cancel" : "Close" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +246,7 @@
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p v-if="serviceWorkerReady" class="text-lg mb-4">
|
||||
Would you like to <b>turn on</b> notifications for this app?
|
||||
Would you like to be notified of new activity once a day?
|
||||
</p>
|
||||
<p v-else class="text-lg mb-4">
|
||||
Waiting for system initialization, which may take up to 10
|
||||
@@ -196,22 +254,42 @@
|
||||
<fa icon="spinner" spin />
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="serviceWorkerReady"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
@click="
|
||||
close(notification.id);
|
||||
turnOnNotifications();
|
||||
"
|
||||
>
|
||||
Turn on Notifications
|
||||
</button>
|
||||
<div v-if="serviceWorkerReady">
|
||||
<span class="flex flex-row justify-center">
|
||||
<span class="mt-2">Yes, tell me at: </span>
|
||||
<input
|
||||
type="number"
|
||||
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
|
||||
v-model="hourInput"
|
||||
/>
|
||||
<span
|
||||
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
|
||||
@click="hourAm = !hourAm"
|
||||
>
|
||||
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
|
||||
<span v-else> PM <fa icon="chevron-up" /> </span>
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
|
||||
@click="
|
||||
() => {
|
||||
if (checkHour()) {
|
||||
close(notification.id);
|
||||
turnOnNotifications();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
Turn on Daily Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
No, Not Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,8 +372,11 @@
|
||||
<style></style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import axios from "axios";
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
|
||||
import * as libsUtil from "@/libs/util";
|
||||
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
data: string;
|
||||
@@ -319,6 +400,10 @@ interface VapidResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
|
||||
notifyTime: { utcHour: number };
|
||||
}
|
||||
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
@@ -328,8 +413,11 @@ import { sendTestThroughPushServer } from "@/libs/util";
|
||||
export default class App extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
stopAsking = false;
|
||||
b64 = "";
|
||||
serviceWorkerReady = false;
|
||||
hourAm = true;
|
||||
hourInput = "8";
|
||||
serviceWorkerReady = true;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
@@ -340,25 +428,29 @@ export default class App extends Vue {
|
||||
pushUrl = settings.webPushServer;
|
||||
}
|
||||
|
||||
await axios
|
||||
.get(pushUrl + "/web-push/vapid")
|
||||
.then((response: VapidResponse) => {
|
||||
this.b64 = response.data?.vapidKey || "";
|
||||
console.log("Got vapid key:", this.b64);
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
console.log("New service worker is now controlling the page");
|
||||
if (pushUrl.startsWith("http://localhost")) {
|
||||
console.log("Not checking for VAPID in this local environment.");
|
||||
} else {
|
||||
await axios
|
||||
.get(pushUrl + "/web-push/vapid")
|
||||
.then((response: VapidResponse) => {
|
||||
this.b64 = response.data?.vapidKey || "";
|
||||
console.log("Got vapid key:", this.b64);
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
console.log("New service worker is now controlling the page");
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!this.b64) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Could not set notifications.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
if (!this.b64) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Could not set notifications.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.location.host.startsWith("localhost")) {
|
||||
@@ -458,6 +550,48 @@ export default class App extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
// this allows us to show an error without closing the dialog
|
||||
checkHour() {
|
||||
if (!libsUtil.isNumeric(this.hourInput)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Not a Number",
|
||||
text: "The time must be an hour number.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const hourNum = libsUtil.numberOrZero(this.hourInput);
|
||||
if (!Number.isInteger(hourNum)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Not a Whole Number",
|
||||
text: "The time must be a whole hour number.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (hourNum < 1 || 12 < hourNum) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Not a Whole Number",
|
||||
text: "The time must be an hour between 1 and 12.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async turnOnNotifications() {
|
||||
return this.askPermission()
|
||||
.then((permission) => {
|
||||
@@ -483,13 +617,25 @@ export default class App extends Vue {
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.sendSubscriptionToServer(subscription);
|
||||
return subscription;
|
||||
// we already checked that this is a valid hour number
|
||||
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
|
||||
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
|
||||
const hourNum = adjHourNum % 24;
|
||||
const utcHour =
|
||||
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
|
||||
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
|
||||
|
||||
const subscriptionWithTime: PushSubscriptionWithTime = {
|
||||
notifyTime: { utcHour: finalUtcHour },
|
||||
...subscription.toJSON(),
|
||||
};
|
||||
await this.sendSubscriptionToServer(subscriptionWithTime);
|
||||
return subscriptionWithTime;
|
||||
} else {
|
||||
throw new Error("Subscription object is not available.");
|
||||
}
|
||||
})
|
||||
.then(async (subscription) => {
|
||||
.then(async (subscription: PushSubscriptionWithTime) => {
|
||||
console.log(
|
||||
"Subscription data sent to server and all finished successfully.",
|
||||
);
|
||||
@@ -580,7 +726,7 @@ export default class App extends Vue {
|
||||
}
|
||||
|
||||
private sendSubscriptionToServer(
|
||||
subscription: PushSubscription,
|
||||
subscription: PushSubscriptionWithTime,
|
||||
): Promise<void> {
|
||||
console.log("About to send subscription...", subscription);
|
||||
return fetch("/web-push/subscribe", {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
|
||||
@@ -5,20 +5,36 @@
|
||||
import { createAvatar, StyleOptions } from "@dicebear/core";
|
||||
import { avataaars } from "@dicebear/collection";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop contact: Contact;
|
||||
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
|
||||
@Prop iconSize = 0;
|
||||
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
|
||||
|
||||
generateIcon() {
|
||||
const options: StyleOptions<object> = {
|
||||
seed: this.entityId || "",
|
||||
size: this.iconSize,
|
||||
};
|
||||
const avatar = createAvatar(avataaars, options);
|
||||
const svgString = avatar.toString();
|
||||
return svgString;
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
if (imageUrl) {
|
||||
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
} else {
|
||||
const identifier = this.contact?.did || this.entityId;
|
||||
if (!identifier) {
|
||||
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
}
|
||||
// https://api.dicebear.com/8.x/avataaars/svg?seed=
|
||||
// ... does not render things with the same seed as this library.
|
||||
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
|
||||
// ... which looks similar to '' at the dicebear site but which is different.
|
||||
const options: StyleOptions<object> = {
|
||||
seed: (identifier as string) || "",
|
||||
size: this.iconSize,
|
||||
};
|
||||
const avatar = createAvatar(avataaars, options);
|
||||
const svgString = avatar.toString();
|
||||
return svgString;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
219
src/components/FeedFilters.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div v-if="visible" id="dialogFeedFilters" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Feed Filters</h1>
|
||||
|
||||
<p class="mb-4 font-bold">Show only activities that…</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer"
|
||||
@click="toggleHasVisibleDid()"
|
||||
>
|
||||
<!-- label -->
|
||||
<div>Include someone visible to me</div>
|
||||
<!-- toggle -->
|
||||
<div class="relative ml-2">
|
||||
<!-- input -->
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="hasVisibleDid"
|
||||
name="toggleFilterFromMyContacts"
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<em>or</em>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer"
|
||||
@click="
|
||||
hasSearchBox
|
||||
? toggleNearby()
|
||||
: $router.push({ name: 'search-area' })
|
||||
"
|
||||
>
|
||||
<!-- label -->
|
||||
<div>Are nearby</div>
|
||||
<!-- toggle -->
|
||||
<div v-if="hasSearchBox" class="relative ml-2">
|
||||
<!-- input -->
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="isNearby"
|
||||
name="toggleFilterNearby"
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
</div>
|
||||
<div v-else class="relative ml-2">
|
||||
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
|
||||
Select Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4">
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="setAll()"
|
||||
>
|
||||
Set All
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="clearAll()"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="done()"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import {
|
||||
LMap,
|
||||
LMarker,
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { db } from "@/db/index";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LRectangle,
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
})
|
||||
export default class FeedFilters extends Vue {
|
||||
onCloseIfChanged = () => {};
|
||||
hasSearchBox = false;
|
||||
hasVisibleDid = false;
|
||||
isNearby = false;
|
||||
settingChanged = false;
|
||||
visible = false;
|
||||
|
||||
async open(onCloseIfChanged: () => void) {
|
||||
this.onCloseIfChanged = onCloseIfChanged;
|
||||
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.hasVisibleDid = !!settings?.filterFeedByVisible;
|
||||
this.isNearby = !!settings?.filterFeedByNearby;
|
||||
if (settings?.searchBoxes && settings.searchBoxes.length > 0) {
|
||||
this.hasSearchBox = true;
|
||||
}
|
||||
|
||||
this.settingChanged = false;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
async toggleHasVisibleDid() {
|
||||
this.settingChanged = true;
|
||||
this.hasVisibleDid = !this.hasVisibleDid;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByVisible: this.hasVisibleDid,
|
||||
});
|
||||
}
|
||||
|
||||
async toggleNearby() {
|
||||
this.settingChanged = true;
|
||||
this.isNearby = !this.isNearby;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
}
|
||||
|
||||
async clearAll() {
|
||||
if (this.hasVisibleDid || this.isNearby) {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
|
||||
this.hasVisibleDid = false;
|
||||
this.isNearby = false;
|
||||
}
|
||||
|
||||
async setAll() {
|
||||
if (!this.hasVisibleDid || !this.isNearby) {
|
||||
this.settingChanged = true;
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
|
||||
this.hasVisibleDid = true;
|
||||
this.isNearby = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.settingChanged) {
|
||||
this.onCloseIfChanged();
|
||||
}
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
done() {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
#dialogFeedFilters.dialog-overlay {
|
||||
z-index: 99999;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,12 @@
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ message }} {{ giver?.name || "somebody not named" }}
|
||||
{{ customTitle }}
|
||||
</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What was received"
|
||||
placeholder="What was given"
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row justify-center">
|
||||
@@ -15,7 +15,7 @@
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] }}
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@@ -24,6 +24,7 @@
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
id="inputGivenAmount"
|
||||
type="number"
|
||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||
v-model="amountInput"
|
||||
@@ -45,33 +46,41 @@
|
||||
description,
|
||||
giverDid: giver?.did,
|
||||
giverName: giver?.name,
|
||||
message,
|
||||
offerId,
|
||||
projectId,
|
||||
recipientDid: receiver?.did,
|
||||
recipientName: receiver?.name,
|
||||
unitCode,
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
More Options
|
||||
Photo & more options ...
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
<fa
|
||||
icon="circle-info"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
@click="explainData()"
|
||||
/>
|
||||
</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,7 +92,7 @@ import { NotificationIface } from "@/constants/app";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiverReceiverInputInfo,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
@@ -94,9 +103,7 @@ import { Contact } from "@/db/tables/contacts";
|
||||
export default class GiftedDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@Prop message = "";
|
||||
@Prop projectId = "";
|
||||
@Prop showGivenToUser = false;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
@@ -104,22 +111,32 @@ export default class GiftedDialog extends Vue {
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
callbackOnSuccess?: (amount: number) => void = () => {};
|
||||
customTitle?: string;
|
||||
description = "";
|
||||
givenToUser = false;
|
||||
giver?: GiverInputInfo; // undefined means no identified giver agent
|
||||
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||
isTrade = false;
|
||||
offerId = "";
|
||||
receiver?: GiverReceiverInputInfo;
|
||||
unitCode = "HUR";
|
||||
visible = false;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async open(giver?: GiverInputInfo, offerId?: string) {
|
||||
async open(
|
||||
giver?: GiverReceiverInputInfo,
|
||||
receiver?: GiverReceiverInputInfo,
|
||||
offerId?: string,
|
||||
customTitle?: string,
|
||||
callbackOnSuccess?: (amount: number) => void,
|
||||
) {
|
||||
this.customTitle = customTitle;
|
||||
this.description = "";
|
||||
this.giver = giver || {};
|
||||
this.giver = giver;
|
||||
this.receiver = receiver;
|
||||
// if we show "given to user" selection, default checkbox to true
|
||||
this.givenToUser = this.showGivenToUser;
|
||||
this.amountInput = "0";
|
||||
this.callbackOnSuccess = callbackOnSuccess;
|
||||
this.offerId = offerId || "";
|
||||
|
||||
try {
|
||||
@@ -134,7 +151,7 @@ export default class GiftedDialog extends Vue {
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
if (!this.giver.name) {
|
||||
if (this.giver && !this.giver.name) {
|
||||
this.giver.name = didInfo(
|
||||
this.giver.did,
|
||||
this.activeDid,
|
||||
@@ -189,12 +206,50 @@ export default class GiftedDialog extends Vue {
|
||||
eraseValues() {
|
||||
this.description = "";
|
||||
this.giver = undefined;
|
||||
this.givenToUser = this.showGivenToUser;
|
||||
this.amountInput = "0";
|
||||
this.unitCode = "HUR";
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a give.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parseFloat(this.amountInput) < 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
text: "You may not send a negative number.",
|
||||
title: "",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.description && !parseFloat(this.amountInput)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${
|
||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.close();
|
||||
this.$notify(
|
||||
{
|
||||
@@ -208,6 +263,7 @@ export default class GiftedDialog extends Vue {
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordGive(
|
||||
(this.giver?.did as string) || null,
|
||||
(this.receiver?.did as string) || null,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
@@ -219,52 +275,27 @@ export default class GiftedDialog extends Vue {
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param recipientDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
* @param amount may be 0
|
||||
* @param unitCode may be omitted, defaults to "HUR"
|
||||
*/
|
||||
public async recordGive(
|
||||
async recordGive(
|
||||
giverDid: string | null,
|
||||
recipientDid: string | null,
|
||||
description: string,
|
||||
amountInput: number,
|
||||
amount: number,
|
||||
unitCode: string = "HUR",
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description && !amountInput) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.givenToUser ? this.activeDid : undefined,
|
||||
this.activeDid,
|
||||
giverDid as string,
|
||||
recipientDid as string,
|
||||
description,
|
||||
amountInput,
|
||||
amount,
|
||||
unitCode,
|
||||
this.projectId,
|
||||
this.offerId,
|
||||
@@ -296,11 +327,14 @@ export default class GiftedDialog extends Vue {
|
||||
},
|
||||
7000,
|
||||
);
|
||||
if (this.callbackOnSuccess) {
|
||||
this.callbackOnSuccess(amount);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Error with give recordation caught:", error);
|
||||
const message =
|
||||
const errorMessage =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
@@ -309,7 +343,7 @@ export default class GiftedDialog extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
text: errorMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -339,6 +373,18 @@ export default class GiftedDialog extends Vue {
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Data Sharing",
|
||||
text: libsUtil.PRIVACY_MESSAGE,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Here's one:</h1>
|
||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||
Here's one:
|
||||
<div
|
||||
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
|
||||
@click="cancel"
|
||||
>
|
||||
<fa icon="xmark" class="w-[1em]"></fa>
|
||||
</div>
|
||||
</h1>
|
||||
<span class="flex justify-between">
|
||||
<span
|
||||
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
|
||||
|
||||
177
src/components/ImageMethodDialog.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
Camera or Other?
|
||||
</div>
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
@click="close()"
|
||||
>
|
||||
<fa icon="xmark" class="w-[1em]"></fa>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-center mt-8">
|
||||
<div class>
|
||||
<fa
|
||||
icon="camera"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||
@click="openPhotoDialog()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input type="file" @change="uploadImageFile" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2">
|
||||
... or paste a URL:
|
||||
<input type="text" v-model="imageUrl" class="border-2" />
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
<fa
|
||||
v-if="imageUrl"
|
||||
icon="check"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
|
||||
@click="acceptUrl"
|
||||
/>
|
||||
<!-- so that there's no shifting when it becomes visible -->
|
||||
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoDialog ref="photoDialog" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import { ref } from "vue";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import PhotoDialog from "@/components/PhotoDialog.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>();
|
||||
|
||||
@Component({
|
||||
components: { PhotoDialog },
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
claimType: string;
|
||||
crop: boolean = false;
|
||||
imageCallback: (imageUrl?: string) => void = () => {};
|
||||
imageUrl?: string;
|
||||
visible = false;
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
openPhotoDialog(blob?: Blob, fileName?: string) {
|
||||
this.visible = false;
|
||||
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
blob,
|
||||
fileName,
|
||||
);
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
this.visible = false;
|
||||
|
||||
inputImageFileNameRef.value = event.target.files[0];
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||
// ... plus it has a `type` property from my testing
|
||||
const file = inputImageFileNameRef.value;
|
||||
if (file != null) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const data = e.target?.result as ArrayBuffer;
|
||||
if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], {
|
||||
type: file.type,
|
||||
});
|
||||
this.openPhotoDialog(blob, file.name as string);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file as Blob);
|
||||
}
|
||||
}
|
||||
|
||||
async acceptUrl() {
|
||||
this.visible = false;
|
||||
if (this.crop) {
|
||||
try {
|
||||
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
|
||||
responseType: "blob", // This ensures the data is returned as a Blob
|
||||
});
|
||||
const fullUrl = new URL(this.imageUrl as string);
|
||||
const fileName = fullUrl.pathname.split("/").pop() as string;
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
urlBlobResponse.data as Blob,
|
||||
fileName,
|
||||
);
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error retrieving that image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.imageCallback(this.imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
|
||||
<input
|
||||
type="text"
|
||||
data-testId="inputDescription"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Description, prerequisites, terms, etc."
|
||||
v-model="description"
|
||||
@@ -23,6 +24,7 @@
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
data-testId="inputOfferAmount"
|
||||
type="number"
|
||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||
v-model="amountInput"
|
||||
@@ -34,34 +36,45 @@
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row mt-2">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
Expiration
|
||||
<div class="mt-4 flex justify-center">
|
||||
<span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'offer-details',
|
||||
query: {
|
||||
amountInput,
|
||||
description,
|
||||
offererDid: activeDid,
|
||||
projectId,
|
||||
projectName,
|
||||
recipientDid,
|
||||
recipientName,
|
||||
unitCode: amountUnitCode,
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Conditions & more options...
|
||||
</router-link>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
||||
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
|
||||
v-model="expirationDateInput"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center mt-6 mb-2 italic">
|
||||
Sign & Send to publish to the world
|
||||
</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -79,8 +92,8 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
export default class OfferDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@Prop message = "";
|
||||
@Prop projectId = "";
|
||||
@Prop projectId?;
|
||||
@Prop projectName?;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
@@ -89,16 +102,22 @@ export default class OfferDialog extends Vue {
|
||||
amountUnitCode = "HUR";
|
||||
description = "";
|
||||
expirationDateInput = "";
|
||||
recipientDid? = "";
|
||||
recipientName? = "";
|
||||
visible = false;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async open() {
|
||||
async open(recipientDid?: string, recipientName?: string) {
|
||||
try {
|
||||
this.recipientDid = recipientDid;
|
||||
this.recipientName = recipientName;
|
||||
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.error("Error retrieving settings from database:", err);
|
||||
@@ -211,15 +230,15 @@ export default class OfferDialog extends Vue {
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
this.activeDid,
|
||||
description,
|
||||
amount,
|
||||
unitCode,
|
||||
expirationDateInput,
|
||||
this.recipientDid,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-2 bg-black/50 text-white leading-none"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
<span v-if="uploading"> Uploading... </span>
|
||||
<span v-else-if="blob"> Look Good? </span>
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-lg text-center p-2 leading-none absolute right-0 top-0 text-white"
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
@click="close()"
|
||||
>
|
||||
<fa icon="xmark" class="w-[1em]"></fa>
|
||||
@@ -20,45 +20,99 @@
|
||||
</div>
|
||||
|
||||
<div v-if="uploading" class="flex justify-center">
|
||||
<fa icon="spinner" class="fa-spin fa-3x text-center block" />
|
||||
<fa
|
||||
icon="spinner"
|
||||
class="fa-spin fa-3x text-center block px-12 py-12"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="blob">
|
||||
<div
|
||||
class="flex justify-center gap-2 absolute bottom-[1rem] left-[1rem] right-[1rem] bg-black/50 px-4 py-2"
|
||||
>
|
||||
<div v-if="crop">
|
||||
<VuePictureCropper
|
||||
:boxStyle="{
|
||||
backgroundColor: '#f8f8f8',
|
||||
margin: 'auto',
|
||||
}"
|
||||
:img="createBlobURL(blob)"
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 9 / 9,
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
<!-- This gives a round cropper.
|
||||
:presetMode="{
|
||||
mode: 'round',
|
||||
}"
|
||||
-->
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="createBlobURL(blob)"
|
||||
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
||||
<button
|
||||
@click="uploadImage"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white font-bold py-2 px-4 rounded-md"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRetry"
|
||||
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
||||
>
|
||||
<button
|
||||
@click="retryImage"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white font-bold py-2 px-4 rounded-md"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else ref="cameraContainer">
|
||||
<!--
|
||||
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically:
|
||||
:resolution="{ width: 375, height: 812 }"
|
||||
-->
|
||||
<camera facingMode="environment" autoplay ref="camera">
|
||||
<camera
|
||||
facingMode="environment"
|
||||
autoplay
|
||||
ref="camera"
|
||||
@started="cameraStarted()"
|
||||
>
|
||||
<div
|
||||
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 landscape:right-0 landscape:top-0 landscape:bottom-0 flex landscape:flex-row justify-center items-center portrait:pb-2 landscape:pr-4"
|
||||
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
|
||||
>
|
||||
<button
|
||||
@click="takeImage"
|
||||
@click="takeImage()"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
>
|
||||
<fa icon="camera" class="w-[1em]"></fa>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
|
||||
>
|
||||
<button
|
||||
@click="swapMirrorClass()"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
>
|
||||
<fa icon="left-right" class="w-[1em]"></fa>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
|
||||
<button
|
||||
@click="switchCamera()"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
>
|
||||
<fa icon="rotate" class="w-[1em]"></fa>
|
||||
</button>
|
||||
</div>
|
||||
</camera>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,23 +123,27 @@
|
||||
import axios from "axios";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||
import { getIdentity } from "@/libs/util";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
|
||||
@Component({ components: { Camera } })
|
||||
export default class GiftedPhotoDialog extends Vue {
|
||||
@Component({ components: { Camera, VuePictureCropper } })
|
||||
export default class PhotoDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDeviceNumber = 0;
|
||||
activeDid = "";
|
||||
blob: Blob | null = null;
|
||||
setImage: (arg: string) => void = () => {};
|
||||
imageHeight?: number = window.innerHeight / 2;
|
||||
imageWidth?: number = window.innerWidth / 2;
|
||||
imageWarning = ".";
|
||||
blob?: Blob;
|
||||
claimType = "";
|
||||
crop = false;
|
||||
fileName?: string;
|
||||
mirror = false;
|
||||
numDevices = 0;
|
||||
setImageCallback: (arg: string) => void = () => {};
|
||||
showRetry = true;
|
||||
uploading = false;
|
||||
visible = false;
|
||||
|
||||
@@ -111,13 +169,31 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void) {
|
||||
open(
|
||||
setImageFn: (arg: string) => void,
|
||||
claimType: string,
|
||||
crop?: boolean,
|
||||
blob?: Blob, // for image upload, just to use the cropping function
|
||||
inputFileName?: string,
|
||||
) {
|
||||
this.visible = true;
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "none";
|
||||
}
|
||||
this.setImage = setImageFn;
|
||||
this.setImageCallback = setImageFn;
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = inputFileName;
|
||||
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
|
||||
this.showRetry = false;
|
||||
} else {
|
||||
this.blob = undefined;
|
||||
this.fileName = undefined;
|
||||
this.showRetry = true;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -126,7 +202,30 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
}
|
||||
this.blob = null;
|
||||
this.blob = undefined;
|
||||
}
|
||||
|
||||
async cameraStarted() {
|
||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||
if (cameraComponent) {
|
||||
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
|
||||
this.mirror = cameraComponent.facingMode === "user";
|
||||
// figure out which device is active
|
||||
const currentDeviceId = cameraComponent.currentDeviceID();
|
||||
const devices = await cameraComponent.devices(["videoinput"]);
|
||||
this.activeDeviceNumber = devices.findIndex(
|
||||
(device) => device.deviceId === currentDeviceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async switchCamera() {
|
||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
||||
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
|
||||
const devices = await cameraComponent?.devices(["videoinput"]);
|
||||
await cameraComponent?.changeCamera(
|
||||
devices[this.activeDeviceNumber].deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
async takeImage(/* payload: MouseEvent */) {
|
||||
@@ -167,10 +266,13 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
|
||||
// The resolution is only necessary because of that mobile portrait-orientation case.
|
||||
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
|
||||
this.blob = await cameraComponent?.snapshot({
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
}); // png is default; if that changes, change extension in formData.append
|
||||
this.blob =
|
||||
(await cameraComponent?.snapshot({
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
})) || undefined;
|
||||
// png is default
|
||||
this.fileName = "snapshot.png";
|
||||
if (!this.blob) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -185,8 +287,12 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async retryImage() {
|
||||
this.blob = null;
|
||||
this.blob = undefined;
|
||||
}
|
||||
|
||||
/****
|
||||
@@ -200,7 +306,6 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
<canvas id="canvas" width="320" height="240"></canvas>
|
||||
|
||||
async cameraClicked() {
|
||||
console.log("camera_button clicked");
|
||||
const video = document.querySelector("#video");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
@@ -211,7 +316,6 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
}
|
||||
}
|
||||
photoSnapped() {
|
||||
console.log("snap_photo clicked");
|
||||
const video = document.querySelector("#video");
|
||||
const canvas = document.querySelector("#canvas");
|
||||
if (
|
||||
@@ -232,17 +336,21 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
|
||||
// data url of the image
|
||||
const image_data_url = canvas?.toDataURL("image/jpeg");
|
||||
console.log(image_data_url);
|
||||
}
|
||||
}
|
||||
****/
|
||||
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
const identifier = await getIdentity(this.activeDid);
|
||||
const token = await accessToken(identifier);
|
||||
|
||||
if (this.crop) {
|
||||
this.blob = (await cropper?.getBlob()) || undefined;
|
||||
}
|
||||
|
||||
const token = await accessToken(this.activeDid);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
// axios fills in Content-Type of multipart/form-data
|
||||
};
|
||||
const formData = new FormData();
|
||||
if (!this.blob) {
|
||||
@@ -259,8 +367,8 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
this.uploading = false;
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot()
|
||||
formData.append("claimType", "GiveAction");
|
||||
formData.append("image", this.blob, this.fileName || "snapshot.png");
|
||||
formData.append("claimType", this.claimType);
|
||||
try {
|
||||
const response = await axios.post(
|
||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||
@@ -269,9 +377,8 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
);
|
||||
this.uploading = false;
|
||||
|
||||
this.visible = false;
|
||||
this.blob = null;
|
||||
this.setImage(response.data.url as string);
|
||||
this.close();
|
||||
this.setImageCallback(response.data.url as string);
|
||||
} catch (error) {
|
||||
console.error("Error uploading the image", error);
|
||||
this.$notify(
|
||||
@@ -279,12 +386,23 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error saving the picture. Please try again.",
|
||||
text: "There was an error saving the picture.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = null;
|
||||
this.blob = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
swapMirrorClass() {
|
||||
this.mirror = !this.mirror;
|
||||
if (this.mirror) {
|
||||
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
|
||||
} else {
|
||||
(this.$refs.cameraContainer as HTMLElement).classList.remove(
|
||||
"mirror-video",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,4 +429,12 @@ export default class GiftedPhotoDialog extends Vue {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.mirror-video {
|
||||
transform: scaleX(-1);
|
||||
-webkit-transform: scaleX(-1); /* For Safari */
|
||||
-moz-transform: scaleX(-1); /* For Firefox */
|
||||
-ms-transform: scaleX(-1); /* For IE */
|
||||
-o-transform: scaleX(-1); /* For Opera */
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<div v-html="generateIdenticon()" class="w-fit"></div>
|
||||
<a
|
||||
v-if="linkToFull && imageUrl"
|
||||
:href="imageUrl"
|
||||
target="_blank"
|
||||
class="h-full w-full object-contain"
|
||||
>
|
||||
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
v-html="generateIdenticon()"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { toSvg } from "jdenticon";
|
||||
@@ -21,11 +33,17 @@ const BLANK_CONFIG = {
|
||||
export default class ProjectIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = 0;
|
||||
@Prop imageUrl = "";
|
||||
@Prop linkToFull = false;
|
||||
|
||||
generateIdenticon() {
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||
return svgString;
|
||||
if (this.imageUrl) {
|
||||
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
|
||||
} else {
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||
return svgString;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}"
|
||||
>
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
||||
<fa icon="house-chimney" class="fa-fw"></fa>
|
||||
<fa icon="house-chimney" class="fa-fw" />
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
@@ -28,7 +28,7 @@
|
||||
:to="{ name: 'discover' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
<fa icon="magnifying-glass" class="fa-fw" />
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
@@ -44,7 +44,7 @@
|
||||
:to="{ name: 'projects' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="folder-open" class="fa-fw"></fa>
|
||||
<fa icon="hand" class="fa-fw" />
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Contacts -->
|
||||
@@ -60,7 +60,7 @@
|
||||
:to="{ name: 'contacts' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="users" class="fa-fw"></fa>
|
||||
<fa icon="users" class="fa-fw" />
|
||||
</router-link>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
@@ -76,7 +76,7 @@
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center py-3 px-1"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw"></fa>
|
||||
<fa icon="circle-user" class="fa-fw" />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<div class="text-center text-red-500">{{ message }}</div>
|
||||
<div class="absolute right-5 top-3">
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
:to="{ name: 'help' }"
|
||||
class="text-xs uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md ml-1"
|
||||
>
|
||||
Help
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from "axios";
|
||||
import * as R from "ramda";
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
|
||||
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { getHeaders } from "@/libs/endorserServer";
|
||||
|
||||
const ANIMATION_DURATION_SECS = 10;
|
||||
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
|
||||
@@ -19,17 +18,7 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const activeDid = settings?.activeDid || "";
|
||||
const apiServer = settings?.apiServer;
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
const headers = await getHeaders(activeDid);
|
||||
|
||||
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
|
||||
const resp = await axios.get(url, { headers: headers });
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* See also ../libs/veramo/setup.ts
|
||||
*/
|
||||
export enum AppString {
|
||||
// This is used in titles and verbiage inside the app.
|
||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
||||
APP_NAME = "Time Safari",
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||
@@ -20,16 +24,21 @@ export enum AppString {
|
||||
}
|
||||
|
||||
export const DEFAULT_ENDORSER_API_SERVER =
|
||||
process.env.VUE_APP_DEFAULT_ENDORSER_API_SERVER ||
|
||||
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
||||
AppString.TEST_ENDORSER_API_SERVER;
|
||||
|
||||
export const DEFAULT_IMAGE_API_SERVER =
|
||||
process.env.VUE_APP_DEFAULT_IMAGE_API_SERVER ||
|
||||
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
|
||||
AppString.TEST_IMAGE_API_SERVER;
|
||||
|
||||
export const DEFAULT_PUSH_SERVER =
|
||||
window.location.protocol + "//" + window.location.host;
|
||||
|
||||
export const IMAGE_TYPE_PROFILE = "profile";
|
||||
|
||||
export const PASSKEYS_ENABLED =
|
||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||
|
||||
/**
|
||||
* The possible values for "group" and "type" are in App.vue.
|
||||
* From the notiwind package
|
||||
@@ -38,6 +47,11 @@ export interface NotificationIface {
|
||||
group: string; // "alert" | "modal"
|
||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||
title: string;
|
||||
text: string;
|
||||
text?: string;
|
||||
noText?: string;
|
||||
onCancel?: (stopAsking: boolean) => Promise<void>;
|
||||
onNo?: (stopAsking: boolean) => Promise<void>;
|
||||
onYes?: () => Promise<void>;
|
||||
promptToStopAsking?: boolean;
|
||||
yesText?: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Settings,
|
||||
SettingsSchema,
|
||||
} from "./tables/settings";
|
||||
import { Temp, TempSchema } from "./tables/temp";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
|
||||
// Define types for tables that hold sensitive and non-sensitive data
|
||||
@@ -16,6 +17,7 @@ type NonsensitiveTables = {
|
||||
contacts: Table<Contact>;
|
||||
logs: Table<Log>;
|
||||
settings: Table<Settings>;
|
||||
temp: Table<Temp>;
|
||||
};
|
||||
|
||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||
@@ -25,14 +27,7 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
|
||||
// Initialize Dexie databases for sensitive and non-sensitive data
|
||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const SensitiveSchemas = { ...AccountsSchema };
|
||||
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
const NonsensitiveSchemas = {
|
||||
...ContactSchema,
|
||||
...LogSchema,
|
||||
...SettingsSchema,
|
||||
};
|
||||
|
||||
// Manage the encryption key. If not present in localStorage, create and store it.
|
||||
const secret =
|
||||
@@ -42,15 +37,22 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
|
||||
// Apply encryption to the sensitive database using the secret key
|
||||
encrypted(accountsDB, { secretKey: secret });
|
||||
|
||||
// Define the schema for our databases
|
||||
accountsDB.version(1).stores(SensitiveSchemas);
|
||||
// v1 was contacts & settings
|
||||
// v2 added logs
|
||||
db.version(2).stores(NonsensitiveSchemas);
|
||||
// Define the schemas for our databases
|
||||
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
|
||||
accountsDB.version(1).stores(AccountsSchema);
|
||||
// v1 also had contacts & settings
|
||||
// v2 added Log
|
||||
db.version(2).stores({
|
||||
...ContactSchema,
|
||||
...LogSchema,
|
||||
...SettingsSchema,
|
||||
});
|
||||
// v3 added Temp
|
||||
db.version(3).stores(TempSchema);
|
||||
|
||||
// Event handler to initialize the non-sensitive database with default settings
|
||||
db.on("populate", () => {
|
||||
db.settings.add({
|
||||
db.on("populate", async () => {
|
||||
await db.settings.add({
|
||||
id: MASTER_SETTINGS_KEY,
|
||||
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
||||
});
|
||||
|
||||
@@ -3,41 +3,46 @@
|
||||
*/
|
||||
export type Account = {
|
||||
/**
|
||||
* Auto-generated ID by Dexie.
|
||||
* Auto-generated ID by Dexie
|
||||
*/
|
||||
id?: number;
|
||||
|
||||
/**
|
||||
* The date the account was created.
|
||||
* The date the account was created
|
||||
*/
|
||||
dateCreated: string;
|
||||
|
||||
/**
|
||||
* The derivation path for the account.
|
||||
* The derivation path for the account, if this is from a mnemonic
|
||||
*/
|
||||
derivationPath: string;
|
||||
derivationPath?: string;
|
||||
|
||||
/**
|
||||
* Decentralized Identifier (DID) for the account.
|
||||
* Decentralized Identifier (DID) for the account
|
||||
*/
|
||||
did: string;
|
||||
|
||||
/**
|
||||
* Stringified JSON containing underlying key material.
|
||||
* Based on the IIdentifier type from Veramo.
|
||||
* Stringified JSON containing underlying key material, if generated from a mnemonic
|
||||
* Based on the IIdentifier type from Veramo
|
||||
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
|
||||
*/
|
||||
identity: string;
|
||||
identity?: string;
|
||||
|
||||
/**
|
||||
* The public key in hexadecimal format.
|
||||
* The mnemonic phrase for the account, if this is from a mnemonic
|
||||
*/
|
||||
mnemonic?: string;
|
||||
|
||||
/**
|
||||
* The Webauthn credential ID in hex, if this is from a passkey
|
||||
*/
|
||||
passkeyCredIdHex?: string;
|
||||
|
||||
/**
|
||||
* The public key in hexadecimal format
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
|
||||
/**
|
||||
* The mnemonic passphrase for the account.
|
||||
*/
|
||||
mnemonic: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,10 @@ export interface Contact {
|
||||
did: string;
|
||||
name?: string;
|
||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||
profileImageUrl?: string;
|
||||
publicKeyBase64?: string;
|
||||
seesMe?: boolean;
|
||||
registered?: boolean;
|
||||
seesMe?: boolean; // cached value of the server setting
|
||||
registered?: boolean; // cached value of the server setting
|
||||
}
|
||||
|
||||
export const ContactSchema = {
|
||||
|
||||
@@ -16,11 +16,18 @@ export type Settings = {
|
||||
|
||||
activeDid?: string; // Active Decentralized ID
|
||||
apiServer?: string; // API server URL
|
||||
firstName?: string; // User's first name
|
||||
|
||||
filterFeedByNearby?: boolean; // filter by nearby
|
||||
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
|
||||
|
||||
firstName?: string; // user's full name
|
||||
hideRegisterPromptOnNewContact?: boolean;
|
||||
isRegistered?: boolean;
|
||||
lastName?: string; // deprecated - put all names in firstName
|
||||
lastNotifiedClaimId?: string; // Last notified claim ID
|
||||
lastViewedClaimId?: string; // Last viewed claim ID
|
||||
lastNotifiedClaimId?: string;
|
||||
lastViewedClaimId?: string;
|
||||
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
||||
profileImageUrl?: string;
|
||||
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
||||
reminderOn?: boolean; // Toggle to enable or disable reminders
|
||||
|
||||
@@ -31,13 +38,18 @@ export type Settings = {
|
||||
}>;
|
||||
|
||||
showContactGivesInline?: boolean; // Display contact inline or not
|
||||
showShortcutBvc?: boolean; // Show shortcut for BVC actions
|
||||
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
|
||||
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
|
||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||
warnIfProdServer?: boolean; // Warn if using a production server
|
||||
warnIfTestServer?: boolean; // Warn if using a testing server
|
||||
webPushServer?: string; // Web Push server URL
|
||||
};
|
||||
|
||||
export function isAnyFeedFilterOn(settings: Settings): boolean {
|
||||
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for the Settings table in the database.
|
||||
*/
|
||||
@@ -49,3 +61,5 @@ export const SettingsSchema = {
|
||||
* Constants.
|
||||
*/
|
||||
export const MASTER_SETTINGS_KEY = 1;
|
||||
|
||||
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;
|
||||
|
||||
14
src/db/tables/temp.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
|
||||
|
||||
export type Temp = {
|
||||
id: string;
|
||||
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
|
||||
blobB64?: string; // base64-encoded blob
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema for the Temp table in the database.
|
||||
*/
|
||||
export const TempSchema = {
|
||||
temp: "id",
|
||||
};
|
||||
@@ -3,14 +3,18 @@ import { getRandomBytesSync } from "ethereum-cryptography/random";
|
||||
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
||||
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
||||
import { HDNode } from "@ethersproject/hdnode";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as u8a from "uint8arrays";
|
||||
|
||||
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
|
||||
import {
|
||||
createEndorserJwtForDid,
|
||||
ENDORSER_JWT_URL_LOCATION,
|
||||
} from "@/libs/endorserServer";
|
||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
||||
import { decodeEndorserJwt } from "@/libs/crypto/vc";
|
||||
|
||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
||||
|
||||
export const LOCAL_KMS_NAME = "local";
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -31,7 +35,7 @@ export const newIdentifier = (
|
||||
keys: [
|
||||
{
|
||||
kid: publicHex,
|
||||
kms: "local",
|
||||
kms: LOCAL_KMS_NAME,
|
||||
meta: { derivationPath: derivationPath },
|
||||
privateKeyHex: privateHex,
|
||||
publicKeyHex: publicHex,
|
||||
@@ -64,6 +68,10 @@ export const deriveAddress = (
|
||||
return [address, privateHex, publicHex, derivationPath];
|
||||
};
|
||||
|
||||
export const generateRandomBytes = (numBytes: number): Uint8Array => {
|
||||
return getRandomBytesSync(numBytes);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -77,81 +85,20 @@ export const generateSeed = (): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retreive an access token
|
||||
* Retrieve an access token, or "" if no DID is provided.
|
||||
*
|
||||
* @param {IIdentifier} identifier
|
||||
* @return {*}
|
||||
*/
|
||||
export const accessToken = async (identifier: IIdentifier) => {
|
||||
const did: string = identifier.did;
|
||||
const privateKeyHex: string = identifier.keys[0].privateKeyHex as string;
|
||||
|
||||
const signer = SimpleSigner(privateKeyHex);
|
||||
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + 60; // add one minute
|
||||
|
||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
|
||||
const jwt: string = await didJwt.createJWT(tokenPayload, {
|
||||
alg,
|
||||
issuer: did,
|
||||
signer,
|
||||
});
|
||||
return jwt;
|
||||
};
|
||||
|
||||
export const sign = async (privateKeyHex: string) => {
|
||||
const signer = SimpleSigner(privateKeyHex);
|
||||
|
||||
return signer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copied out of did-jwt since it's deprecated in that library.
|
||||
*
|
||||
* The SimpleSigner returns a configured function for signing data.
|
||||
*
|
||||
* @example
|
||||
* const signer = SimpleSigner(process.env.PRIVATE_KEY)
|
||||
* signer(data, (err, signature) => {
|
||||
* ...
|
||||
* })
|
||||
*
|
||||
* @param {String} hexPrivateKey a hex encoded private key
|
||||
* @return {Function} a configured signer function
|
||||
*/
|
||||
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
|
||||
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
|
||||
return async (data) => {
|
||||
const signature = (await signer(data)) as string;
|
||||
return fromJose(signature);
|
||||
};
|
||||
}
|
||||
|
||||
// from did-jwt/util; see SimpleSigner above
|
||||
export function fromJose(signature: string): {
|
||||
r: string;
|
||||
s: string;
|
||||
recoveryParam?: number;
|
||||
} {
|
||||
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
|
||||
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
|
||||
throw new TypeError(
|
||||
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
|
||||
);
|
||||
export const accessToken = async (did?: string) => {
|
||||
if (did) {
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + 60; // add one minute
|
||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||
return createEndorserJwtForDid(did, tokenPayload);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
const r = bytesToHex(signatureBytes.slice(0, 32));
|
||||
const s = bytesToHex(signatureBytes.slice(32, 64));
|
||||
const recoveryParam =
|
||||
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
|
||||
return { r, s, recoveryParam };
|
||||
}
|
||||
|
||||
// from did-jwt/util; see SimpleSigner above
|
||||
export function bytesToHex(b: Uint8Array): string {
|
||||
return u8a.toString(b, "base16");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@return results of uportJwtPayload:
|
||||
@@ -169,7 +116,7 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
|
||||
}
|
||||
|
||||
// JWT format: { header, payload, signature, data }
|
||||
const jwt = didJwt.decodeJWT(jwtText);
|
||||
const jwt = decodeEndorserJwt(jwtText);
|
||||
|
||||
return jwt.payload;
|
||||
};
|
||||
|
||||
96
src/libs/crypto/vc/didPeer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Buffer } from "buffer/";
|
||||
import { decode as cborDecode } from "cbor-x";
|
||||
import { bytesToMultibase, multibaseToBytes } from "did-jwt";
|
||||
|
||||
import { getWebCrypto } from "@/libs/crypto/vc/passkeyHelpers";
|
||||
|
||||
export const PEER_DID_PREFIX = "did:peer:";
|
||||
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* similar code is in crowd-funder-for-time-pwa libs/crypto/vc/passkeyDidPeer.ts verifyJwtWebCrypto
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function verifyPeerSignature(
|
||||
payloadBytes: Buffer,
|
||||
issuerDid: string,
|
||||
signatureBytes: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
||||
|
||||
const WebCrypto = await getWebCrypto();
|
||||
const verifyAlgorithm = {
|
||||
name: "ECDSA",
|
||||
hash: { name: "SHA-256" },
|
||||
};
|
||||
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
|
||||
const keyAlgorithm = {
|
||||
name: "ECDSA",
|
||||
namedCurve: publicKeyJwk.crv,
|
||||
};
|
||||
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
|
||||
"jwk",
|
||||
publicKeyJwk,
|
||||
keyAlgorithm,
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
const verified = await WebCrypto.subtle.verify(
|
||||
verifyAlgorithm,
|
||||
publicKeyCryptoKey,
|
||||
signatureBytes,
|
||||
payloadBytes,
|
||||
);
|
||||
return verified;
|
||||
}
|
||||
|
||||
export function cborToKeys(publicKeyBytes: Uint8Array) {
|
||||
const jwkObj = cborDecode(publicKeyBytes);
|
||||
if (
|
||||
jwkObj[1] != 2 || // kty "EC"
|
||||
jwkObj[3] != -7 || // alg "ES256"
|
||||
jwkObj[-1] != 1 || // crv "P-256"
|
||||
jwkObj[-2].length != 32 || // x
|
||||
jwkObj[-3].length != 32 // y
|
||||
) {
|
||||
throw new Error("Unable to extract key.");
|
||||
}
|
||||
const publicKeyJwk = {
|
||||
alg: "ES256",
|
||||
crv: "P-256",
|
||||
kty: "EC",
|
||||
x: arrayToBase64Url(jwkObj[-2]),
|
||||
y: arrayToBase64Url(jwkObj[-3]),
|
||||
};
|
||||
const publicKeyBuffer = Buffer.concat([
|
||||
Buffer.from(jwkObj[-2]),
|
||||
Buffer.from(jwkObj[-3]),
|
||||
]);
|
||||
return { publicKeyJwk, publicKeyBuffer };
|
||||
}
|
||||
|
||||
export function toBase64Url(anythingB64: string) {
|
||||
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function arrayToBase64Url(anything: Uint8Array) {
|
||||
return toBase64Url(Buffer.from(anything).toString("base64"));
|
||||
}
|
||||
|
||||
export function peerDidToPublicKeyBytes(did: string) {
|
||||
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
|
||||
}
|
||||
|
||||
export function createPeerDid(publicKeyBytes: Uint8Array) {
|
||||
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
|
||||
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
|
||||
const methodSpecificId = bytesToMultibase(
|
||||
publicKeyBytes,
|
||||
"base58btc",
|
||||
"p256-pub",
|
||||
);
|
||||
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
|
||||
}
|
||||
112
src/libs/crypto/vc/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
|
||||
*
|
||||
* The goal is to make this folder similar across projects, then move it to a library.
|
||||
* Other projects: endorser-ch, image-api
|
||||
*
|
||||
*/
|
||||
|
||||
import * as didJwt from "did-jwt";
|
||||
import { JWTDecoded } from "did-jwt/lib/JWT";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import * as u8a from "uint8arrays";
|
||||
|
||||
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer";
|
||||
|
||||
export const ETHR_DID_PREFIX = "did:ethr:";
|
||||
|
||||
/**
|
||||
* Meta info about a key
|
||||
*/
|
||||
export interface KeyMeta {
|
||||
/**
|
||||
* Decentralized ID for the key
|
||||
*/
|
||||
did: string;
|
||||
/**
|
||||
* Stringified IIDentifier object from Veramo
|
||||
*/
|
||||
identity?: string;
|
||||
/**
|
||||
* The Webauthn credential ID in hex, if this is from a passkey
|
||||
*/
|
||||
passkeyCredIdHex?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether a key is from a passkey
|
||||
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
|
||||
*/
|
||||
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
|
||||
return !!keyMeta?.passkeyCredIdHex;
|
||||
}
|
||||
|
||||
export async function createEndorserJwtForKey(
|
||||
account: KeyMeta,
|
||||
payload: object,
|
||||
) {
|
||||
if (account?.identity) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const identity: IIdentifier = JSON.parse(account.identity!);
|
||||
const privateKeyHex = identity.keys[0].privateKeyHex;
|
||||
const signer = await SimpleSigner(privateKeyHex as string);
|
||||
return didJwt.createJWT(payload, {
|
||||
issuer: account.did,
|
||||
signer: signer,
|
||||
});
|
||||
} else if (account?.passkeyCredIdHex) {
|
||||
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
|
||||
} else {
|
||||
throw new Error("No identity data found to sign for DID " + account.did);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied out of did-jwt since it's deprecated in that library.
|
||||
*
|
||||
* The SimpleSigner returns a configured function for signing data.
|
||||
*
|
||||
* @example
|
||||
* const signer = SimpleSigner(privateKeyHexString)
|
||||
* signer(data, (err, signature) => {
|
||||
* ...
|
||||
* })
|
||||
*
|
||||
* @param {String} hexPrivateKey a hex encoded private key
|
||||
* @return {Function} a configured signer function
|
||||
*/
|
||||
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
|
||||
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
|
||||
return async (data) => {
|
||||
const signature = (await signer(data)) as string;
|
||||
return fromJose(signature);
|
||||
};
|
||||
}
|
||||
|
||||
// from did-jwt/util; see SimpleSigner above
|
||||
function fromJose(signature: string): {
|
||||
r: string;
|
||||
s: string;
|
||||
recoveryParam?: number;
|
||||
} {
|
||||
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
|
||||
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
|
||||
throw new TypeError(
|
||||
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
|
||||
);
|
||||
}
|
||||
const r = bytesToHex(signatureBytes.slice(0, 32));
|
||||
const s = bytesToHex(signatureBytes.slice(32, 64));
|
||||
const recoveryParam =
|
||||
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
|
||||
return { r, s, recoveryParam };
|
||||
}
|
||||
|
||||
// from did-jwt/util; see SimpleSigner above
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return u8a.toString(b, "base16");
|
||||
}
|
||||
|
||||
export function decodeEndorserJwt(jwt: string): JWTDecoded {
|
||||
return didJwt.decodeJWT(jwt);
|
||||
}
|
||||
539
src/libs/crypto/vc/passkeyDidPeer.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import { Buffer } from "buffer/";
|
||||
import { JWTPayload } from "did-jwt";
|
||||
import { DIDResolutionResult } from "did-resolver";
|
||||
import { sha256 } from "ethereum-cryptography/sha256.js";
|
||||
import {
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
|
||||
import {
|
||||
Base64URLString,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
} from "@simplewebauthn/types";
|
||||
|
||||
import { AppString } from "@/constants/app";
|
||||
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers";
|
||||
import {
|
||||
arrayToBase64Url,
|
||||
cborToKeys,
|
||||
peerDidToPublicKeyBytes,
|
||||
verifyPeerSignature,
|
||||
} from "@/libs/crypto/vc/didPeer";
|
||||
|
||||
export interface JWK {
|
||||
kty: string;
|
||||
crv: string;
|
||||
x: string;
|
||||
y: string;
|
||||
}
|
||||
|
||||
export async function registerCredential(passkeyName?: string) {
|
||||
const options: PublicKeyCredentialCreationOptionsJSON =
|
||||
await generateRegistrationOptions({
|
||||
rpName: AppString.APP_NAME,
|
||||
rpID: window.location.hostname,
|
||||
userName: passkeyName || AppString.APP_NAME + " User",
|
||||
// Don't prompt users for additional information about the authenticator
|
||||
// (Recommended for smoother UX)
|
||||
attestationType: "none",
|
||||
authenticatorSelection: {
|
||||
// Defaults
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
// Optional
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
});
|
||||
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
||||
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
||||
const attResp = await startRegistration(options);
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: attResp,
|
||||
expectedChallenge: options.challenge,
|
||||
expectedOrigin: window.location.origin,
|
||||
expectedRPID: window.location.hostname,
|
||||
});
|
||||
|
||||
// references for parsing auth data and getting the public key
|
||||
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
||||
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
||||
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
||||
|
||||
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
|
||||
if (attResp.rawId !== credIdBase64Url) {
|
||||
console.log("Warning! The raw ID does not match the credential ID.");
|
||||
}
|
||||
const credIdHex = Buffer.from(
|
||||
base64URLStringToArrayBuffer(credIdBase64Url),
|
||||
).toString("hex");
|
||||
const { publicKeyJwk } = cborToKeys(
|
||||
verification.registrationInfo?.credentialPublicKey as Uint8Array,
|
||||
);
|
||||
|
||||
return {
|
||||
authData: verification.registrationInfo?.attestationObject,
|
||||
credIdHex: credIdHex,
|
||||
publicKeyJwk: publicKeyJwk,
|
||||
publicKeyBytes: verification.registrationInfo
|
||||
?.credentialPublicKey as Uint8Array,
|
||||
};
|
||||
}
|
||||
|
||||
export class PeerSetup {
|
||||
public authenticatorData?: ArrayBuffer;
|
||||
public challenge?: Uint8Array;
|
||||
public clientDataJsonBase64Url?: Base64URLString;
|
||||
public signature?: Base64URLString;
|
||||
|
||||
public async createJwtSimplewebauthn(
|
||||
issuerDid: string,
|
||||
payload: object,
|
||||
credIdHex: string,
|
||||
expMinutes: number = 1,
|
||||
) {
|
||||
const credentialId = arrayBufferToBase64URLString(
|
||||
Buffer.from(credIdHex, "hex").buffer,
|
||||
);
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
||||
const fullPayload = {
|
||||
...payload,
|
||||
exp: expiryTime,
|
||||
iat: issuedAt,
|
||||
iss: issuerDid,
|
||||
};
|
||||
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
|
||||
// const payloadHash: Uint8Array = sha256(this.challenge);
|
||||
const options: PublicKeyCredentialRequestOptionsJSON =
|
||||
await generateAuthenticationOptions({
|
||||
challenge: this.challenge,
|
||||
rpID: window.location.hostname,
|
||||
allowCredentials: [{ id: credentialId }],
|
||||
});
|
||||
// console.log("simple authentication options", options);
|
||||
|
||||
const clientAuth = await startAuthentication(options);
|
||||
// console.log("simple credential get", clientAuth);
|
||||
|
||||
const authenticatorDataBase64Url = clientAuth.response.authenticatorData;
|
||||
this.authenticatorData = Buffer.from(
|
||||
clientAuth.response.authenticatorData,
|
||||
"base64",
|
||||
).buffer;
|
||||
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON;
|
||||
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
||||
this.signature = clientAuth.response.signature;
|
||||
|
||||
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
|
||||
const headerBase64 = Buffer.from(JSON.stringify(header))
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const dataInJwt = {
|
||||
AuthenticationDataB64URL: authenticatorDataBase64Url,
|
||||
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
|
||||
exp: expiryTime,
|
||||
iat: issuedAt,
|
||||
iss: issuerDid,
|
||||
};
|
||||
const dataInJwtString = JSON.stringify(dataInJwt);
|
||||
const payloadBase64 = Buffer.from(dataInJwtString)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const signature = clientAuth.response.signature;
|
||||
|
||||
return headerBase64 + "." + payloadBase64 + "." + signature;
|
||||
}
|
||||
|
||||
public async createJwtNavigator(
|
||||
issuerDid: string,
|
||||
payload: object,
|
||||
credIdHex: string,
|
||||
expMinutes: number = 1,
|
||||
) {
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
||||
const fullPayload = {
|
||||
...payload,
|
||||
exp: expiryTime,
|
||||
iat: issuedAt,
|
||||
iss: issuerDid,
|
||||
};
|
||||
const dataToSignString = JSON.stringify(fullPayload);
|
||||
const dataToSignBuffer = Buffer.from(dataToSignString);
|
||||
const credentialId = Buffer.from(credIdHex, "hex");
|
||||
|
||||
// console.log("lower credentialId", credentialId);
|
||||
this.challenge = new Uint8Array(dataToSignBuffer);
|
||||
const options = {
|
||||
publicKey: {
|
||||
allowCredentials: [
|
||||
{
|
||||
id: credentialId,
|
||||
type: "public-key" as const,
|
||||
},
|
||||
],
|
||||
challenge: this.challenge.buffer,
|
||||
rpID: window.location.hostname,
|
||||
userVerification: "preferred" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.get(options);
|
||||
// console.log("nav credential get", credential);
|
||||
|
||||
this.authenticatorData = credential?.response.authenticatorData;
|
||||
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
|
||||
this.authenticatorData as ArrayBuffer,
|
||||
);
|
||||
|
||||
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
|
||||
credential?.response.clientDataJSON,
|
||||
);
|
||||
|
||||
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
|
||||
const headerBase64 = Buffer.from(JSON.stringify(header))
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const dataInJwt = {
|
||||
AuthenticationDataB64URL: authenticatorDataBase64Url,
|
||||
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
|
||||
exp: expiryTime,
|
||||
iat: issuedAt,
|
||||
iss: issuerDid,
|
||||
};
|
||||
const dataInJwtString = JSON.stringify(dataInJwt);
|
||||
const payloadBase64 = Buffer.from(dataInJwtString)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const origSignature = Buffer.from(credential?.response.signature).toString(
|
||||
"base64",
|
||||
);
|
||||
this.signature = origSignature
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
|
||||
return jwt;
|
||||
}
|
||||
|
||||
// To use this, add the asn1-ber library and add this import:
|
||||
// import asn1 from "asn1-ber";
|
||||
//
|
||||
// return a low-level signing function, similar to createJWS approach
|
||||
// async webAuthnES256KSigner(credentialID: string) {
|
||||
// return async (data: string | Uint8Array) => {
|
||||
// // get signature from WebAuthn
|
||||
// const signature = await this.generateWebAuthnSignature(data);
|
||||
//
|
||||
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
||||
// const signatureBuffer = Buffer.from(signature);
|
||||
// console.log("lower signature inside signer", signature);
|
||||
// console.log("lower buffer signature inside signer", signatureBuffer);
|
||||
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
||||
// // Decode the DER-encoded signature to extract R and S values
|
||||
// const reader = new asn1.BerReader(signatureBuffer);
|
||||
// console.log("lower after reader");
|
||||
// reader.readSequence();
|
||||
// console.log("lower after read sequence");
|
||||
// const r = reader.readString(asn1.Ber.Integer, true);
|
||||
// console.log("lower after r");
|
||||
// const s = reader.readString(asn1.Ber.Integer, true);
|
||||
// console.log("lower after r & s");
|
||||
//
|
||||
// // Ensure R and S are 32 bytes each
|
||||
// const rBuffer = Buffer.from(r);
|
||||
// const sBuffer = Buffer.from(s);
|
||||
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
||||
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
||||
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
||||
// const rPadded =
|
||||
// rWithoutPrefix.length < 32
|
||||
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
||||
// : rWithoutPrefix;
|
||||
// const sPadded =
|
||||
// rWithoutPrefix.length < 32
|
||||
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
||||
// : sWithoutPrefix;
|
||||
//
|
||||
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
||||
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
||||
// console.log(
|
||||
// "lower combinedSignature",
|
||||
// combinedSignature.length,
|
||||
// combinedSignature,
|
||||
// );
|
||||
//
|
||||
// const combSig64 = combinedSignature.toString("base64");
|
||||
// console.log("lower combSig64", combSig64);
|
||||
// const combSig64Url = combSig64
|
||||
// .replace(/\+/g, "-")
|
||||
// .replace(/\//g, "_")
|
||||
// .replace(/=+$/, "");
|
||||
// console.log("lower combSig64Url", combSig64Url);
|
||||
// return combSig64Url;
|
||||
// };
|
||||
// }
|
||||
}
|
||||
|
||||
export async function createDidPeerJwt(
|
||||
did: string,
|
||||
credIdHex: string,
|
||||
payload: object,
|
||||
): Promise<string> {
|
||||
const peerSetup = new PeerSetup();
|
||||
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex);
|
||||
return jwt;
|
||||
}
|
||||
|
||||
// I'd love to use this but it doesn't verify.
|
||||
// Requires:
|
||||
// npm install @noble/curves
|
||||
// ... and this import:
|
||||
// import { p256 } from "@noble/curves/p256";
|
||||
export async function verifyJwtP256(
|
||||
credIdHex: string,
|
||||
issuerDid: string,
|
||||
authenticatorData: ArrayBuffer,
|
||||
challenge: Uint8Array,
|
||||
clientDataJsonBase64Url: Base64URLString,
|
||||
signature: Base64URLString,
|
||||
) {
|
||||
const authDataFromBase = Buffer.from(authenticatorData);
|
||||
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
|
||||
const sigBuffer = Buffer.from(signature, "base64");
|
||||
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
|
||||
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
||||
|
||||
// Hash the client data
|
||||
const hash = sha256(clientDataFromBase);
|
||||
|
||||
// Construct the preimage
|
||||
const preimage = Buffer.concat([authDataFromBase, hash]);
|
||||
|
||||
const isValid = p256.verify(
|
||||
finalSigBuffer,
|
||||
new Uint8Array(preimage),
|
||||
publicKeyBytes,
|
||||
);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export async function verifyJwtSimplewebauthn(
|
||||
credIdHex: string,
|
||||
issuerDid: string,
|
||||
authenticatorData: ArrayBuffer,
|
||||
challenge: Uint8Array,
|
||||
clientDataJsonBase64Url: Base64URLString,
|
||||
signature: Base64URLString,
|
||||
) {
|
||||
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
|
||||
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
||||
const credId = arrayBufferToBase64URLString(
|
||||
Buffer.from(credIdHex, "hex").buffer,
|
||||
);
|
||||
const authOpts: VerifyAuthenticationResponseOpts = {
|
||||
authenticator: {
|
||||
credentialID: credId,
|
||||
credentialPublicKey: publicKeyBytes,
|
||||
counter: 0,
|
||||
},
|
||||
expectedChallenge: arrayToBase64Url(challenge),
|
||||
expectedOrigin: window.location.origin,
|
||||
expectedRPID: window.location.hostname,
|
||||
response: {
|
||||
authenticatorAttachment: "platform",
|
||||
clientExtensionResults: {},
|
||||
id: credId,
|
||||
rawId: credId,
|
||||
response: {
|
||||
authenticatorData: authData,
|
||||
clientDataJSON: clientDataJsonBase64Url,
|
||||
signature: signature,
|
||||
},
|
||||
type: "public-key",
|
||||
},
|
||||
};
|
||||
const verification = await verifyAuthenticationResponse(authOpts);
|
||||
return verification.verified;
|
||||
}
|
||||
|
||||
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
|
||||
export async function verifyJwtWebCrypto(
|
||||
credId: Base64URLString,
|
||||
issuerDid: string,
|
||||
authenticatorData: ArrayBuffer,
|
||||
challenge: Uint8Array,
|
||||
clientDataJsonBase64Url: Base64URLString,
|
||||
signature: Base64URLString,
|
||||
) {
|
||||
const authDataFromBase = Buffer.from(authenticatorData);
|
||||
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
|
||||
const sigBuffer = Buffer.from(signature, "base64");
|
||||
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
|
||||
|
||||
// Hash the client data
|
||||
const hash = sha256(clientDataFromBase);
|
||||
|
||||
// Construct the preimage
|
||||
const preimage = Buffer.concat([authDataFromBase, hash]);
|
||||
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
|
||||
if (!did.startsWith("did:peer:0z")) {
|
||||
throw new Error(
|
||||
"This only verifies a peer DID, method 0, encoded base58btc.",
|
||||
);
|
||||
}
|
||||
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
|
||||
// (another reference is the @aviarytech/did-peer resolver)
|
||||
|
||||
/**
|
||||
* Looks like JsonWebKey2020 isn't too difficult:
|
||||
* - change context security/suites link to jws-2020/v1
|
||||
* - change publicKeyMultibase to publicKeyJwk generated with cborToKeys
|
||||
* - change type to JsonWebKey2020
|
||||
*/
|
||||
|
||||
const id = did.split(":")[2];
|
||||
const multibase = id.slice(1);
|
||||
const encnumbasis = multibase.slice(1);
|
||||
const didDocument = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
],
|
||||
assertionMethod: [did + "#" + encnumbasis],
|
||||
authentication: [did + "#" + encnumbasis],
|
||||
capabilityDelegation: [did + "#" + encnumbasis],
|
||||
capabilityInvocation: [did + "#" + encnumbasis],
|
||||
id: did,
|
||||
keyAgreement: undefined,
|
||||
service: undefined,
|
||||
verificationMethod: [
|
||||
{
|
||||
controller: did,
|
||||
id: did + "#" + encnumbasis,
|
||||
publicKeyMultibase: multibase,
|
||||
type: "EcdsaSecp256k1VerificationKey2019",
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
didDocument,
|
||||
didDocumentMetadata: {},
|
||||
didResolutionMetadata: { contentType: "application/did+ld+json" },
|
||||
};
|
||||
}
|
||||
|
||||
// convert COSE public key to PEM format
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function COSEtoPEM(cose: Buffer) {
|
||||
// const alg = cose.get(3); // Algorithm
|
||||
const x = cose[-2]; // x-coordinate
|
||||
const y = cose[-3]; // y-coordinate
|
||||
|
||||
// Ensure the coordinates are in the correct format
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error because it complains about the type of x and y
|
||||
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
|
||||
|
||||
// Convert to PEM format
|
||||
const pem = `-----BEGIN PUBLIC KEY-----
|
||||
${pubKeyBuffer.toString("base64")}
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
return pem;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function base64urlDecode(input: string) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
|
||||
const str = atob(input + pad);
|
||||
const bytes = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bytes[i] = str.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function base64urlEncode(buffer: ArrayBuffer) {
|
||||
const str = String.fromCharCode(...new Uint8Array(buffer));
|
||||
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
// from @simplewebauthn/browser
|
||||
function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let str = "";
|
||||
for (const charCode of bytes) {
|
||||
str += String.fromCharCode(charCode);
|
||||
}
|
||||
const base64String = btoa(str);
|
||||
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
// from @simplewebauthn/browser
|
||||
function base64URLStringToArrayBuffer(base64URLString: string) {
|
||||
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLength = (4 - (base64.length % 4)) % 4;
|
||||
const padded = base64.padEnd(base64.length + padLength, "=");
|
||||
const binary = atob(padded);
|
||||
const buffer = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function pemToCryptoKey(pem: string) {
|
||||
const binaryDerString = atob(
|
||||
pem
|
||||
.split("\n")
|
||||
.filter((x) => !x.includes("-----"))
|
||||
.join(""),
|
||||
);
|
||||
const binaryDer = new Uint8Array(binaryDerString.length);
|
||||
for (let i = 0; i < binaryDerString.length; i++) {
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i);
|
||||
}
|
||||
// console.log("binaryDer", binaryDer.buffer);
|
||||
return await window.crypto.subtle.importKey(
|
||||
"spki",
|
||||
binaryDer.buffer,
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["verify"],
|
||||
);
|
||||
}
|
||||
105
src/libs/crypto/vc/passkeyHelpers.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
|
||||
import { AsnParser } from "@peculiar/asn1-schema";
|
||||
import { ECDSASigValue } from "@peculiar/asn1-ecc";
|
||||
|
||||
/**
|
||||
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
|
||||
*
|
||||
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
|
||||
*/
|
||||
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
|
||||
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
|
||||
let rBytes = new Uint8Array(parsedSignature.r);
|
||||
let sBytes = new Uint8Array(parsedSignature.s);
|
||||
|
||||
if (shouldRemoveLeadingZero(rBytes)) {
|
||||
rBytes = rBytes.slice(1);
|
||||
}
|
||||
|
||||
if (shouldRemoveLeadingZero(sBytes)) {
|
||||
sBytes = sBytes.slice(1);
|
||||
}
|
||||
|
||||
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]);
|
||||
|
||||
return finalSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence
|
||||
* should be removed based on the following logic:
|
||||
*
|
||||
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0,
|
||||
* then remove the leading 0x0 byte"
|
||||
*/
|
||||
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
|
||||
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
|
||||
}
|
||||
|
||||
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
|
||||
/**
|
||||
* Combine multiple Uint8Arrays into a single Uint8Array
|
||||
*/
|
||||
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
|
||||
let pointer = 0;
|
||||
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
|
||||
|
||||
const toReturn = new Uint8Array(totalLength);
|
||||
|
||||
arrays.forEach((arr) => {
|
||||
toReturn.set(arr, pointer);
|
||||
pointer += arr.length;
|
||||
});
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
|
||||
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined;
|
||||
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
|
||||
/**
|
||||
* Hello there! If you came here wondering why this method is asynchronous when use of
|
||||
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
|
||||
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()`
|
||||
* become synchronous if we make this synchronous (since nothing else in that method is async)
|
||||
* which represents a breaking API change in this library's core API.
|
||||
*
|
||||
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense
|
||||
* to keep this method asynchronous.
|
||||
*/
|
||||
const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise(
|
||||
(resolve, reject) => {
|
||||
if (webCrypto) {
|
||||
return resolve(webCrypto);
|
||||
}
|
||||
/**
|
||||
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
|
||||
* support (and Node v20+)
|
||||
*/
|
||||
const _globalThisCrypto =
|
||||
_getWebCryptoInternals.stubThisGlobalThisCrypto();
|
||||
if (_globalThisCrypto) {
|
||||
webCrypto = _globalThisCrypto;
|
||||
return resolve(webCrypto);
|
||||
}
|
||||
// We tried to access it both in Node and globally, so bail out
|
||||
return reject(new MissingWebCrypto());
|
||||
},
|
||||
);
|
||||
return toResolve;
|
||||
}
|
||||
class MissingWebCrypto extends Error {
|
||||
constructor() {
|
||||
const message = "An instance of the Crypto API could not be located";
|
||||
super(message);
|
||||
this.name = "MissingWebCrypto";
|
||||
}
|
||||
}
|
||||
// Make it possible to stub return values during testing
|
||||
const _getWebCryptoInternals = {
|
||||
stubThisGlobalThisCrypto: () => globalThis.crypto,
|
||||
// Make it possible to reset the `webCrypto` at the top of the file
|
||||
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => {
|
||||
webCrypto = newCrypto;
|
||||
},
|
||||
};
|
||||
193
src/libs/util.ts
@@ -1,28 +1,38 @@
|
||||
// many of these are also found in endorser-mobile utility.ts
|
||||
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Buffer } from "buffer";
|
||||
import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import {
|
||||
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
|
||||
MASTER_SETTINGS_KEY,
|
||||
} from "@/db/tables/settings";
|
||||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
||||
import { GenericServerRecord, containsHiddenDid } from "@/libs/endorserServer";
|
||||
import {
|
||||
containsHiddenDid,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
OfferVerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as serverUtil from "@/libs/endorserServer";
|
||||
import { KeyMeta } from "@/libs/crypto/vc";
|
||||
import { createPeerDid } from "@/libs/crypto/vc/didPeer";
|
||||
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, too),
|
||||
// and make sure they can take all actions while the notification shows.
|
||||
export const ONBOARD_MESSAGE =
|
||||
"1) Read through all their yellow prompts. 2) Add them to your Contacts by scanning with the QR icon that is by the input box. 3) Click the person icon to register them. 4) Show them your QR so they'll scan you. 5) Have them enable notifications.";
|
||||
export const PRIVACY_MESSAGE =
|
||||
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
|
||||
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
|
||||
|
||||
/* eslint-disable prettier/prettier */
|
||||
export const UNIT_SHORT: Record<string, string> = {
|
||||
"BTC": "BTC",
|
||||
"BX": "BX",
|
||||
"ETH": "ETH",
|
||||
"HUR": "Hours",
|
||||
"USD": "US $",
|
||||
@@ -32,6 +42,7 @@ export const UNIT_SHORT: Record<string, string> = {
|
||||
/* eslint-disable prettier/prettier */
|
||||
export const UNIT_LONG: Record<string, string> = {
|
||||
"BTC": "Bitcoin",
|
||||
"BX": "Buxbe",
|
||||
"ETH": "Ethereum",
|
||||
"HUR": "hours",
|
||||
"USD": "dollars",
|
||||
@@ -58,9 +69,13 @@ export function iconForUnitCode(unitCode: string) {
|
||||
}
|
||||
|
||||
// from https://stackoverflow.com/a/175787/845494
|
||||
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
|
||||
//
|
||||
export function isNumeric(str: string): boolean {
|
||||
return !isNaN(+str);
|
||||
// This ignore commentary is because typescript complains when you pass a string to isNaN.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return !isNaN(str) && !isNaN(parseFloat(str));
|
||||
}
|
||||
|
||||
export function numberOrZero(str: string): number {
|
||||
@@ -71,10 +86,34 @@ export const isGlobalUri = (uri: string) => {
|
||||
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
||||
};
|
||||
|
||||
export const giveIsConfirmable = (veriClaim: GenericServerRecord) => {
|
||||
export const isGiveAction = (
|
||||
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
||||
) => {
|
||||
return veriClaim.claimType === "GiveAction";
|
||||
};
|
||||
|
||||
export const nameForDid = (
|
||||
activeDid: string,
|
||||
contacts: Array<Contact>,
|
||||
did: string,
|
||||
): string => {
|
||||
if (did === activeDid) {
|
||||
return "you";
|
||||
}
|
||||
const contact = R.find((con) => con.did == did, contacts);
|
||||
return nameForContact(contact);
|
||||
};
|
||||
|
||||
export const nameForContact = (
|
||||
contact?: Contact,
|
||||
capitalize?: boolean,
|
||||
): string => {
|
||||
return (
|
||||
(contact?.name as string) ||
|
||||
(capitalize ? "This" : "this") + " unnamed user"
|
||||
);
|
||||
};
|
||||
|
||||
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
||||
fn();
|
||||
useClipboard()
|
||||
@@ -87,24 +126,58 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
||||
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||
*/
|
||||
export const isGiveRecordTheUserCanConfirm = (
|
||||
veriClaim: GenericServerRecord,
|
||||
isRegistered: boolean,
|
||||
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
||||
activeDid: string,
|
||||
confirmerIdList: string[] = [],
|
||||
) => {
|
||||
return (
|
||||
giveIsConfirmable(veriClaim) &&
|
||||
isRegistered &&
|
||||
isGiveAction(veriClaim) &&
|
||||
!confirmerIdList.includes(activeDid) &&
|
||||
veriClaim.issuer !== activeDid &&
|
||||
!containsHiddenDid(veriClaim.claim)
|
||||
);
|
||||
};
|
||||
|
||||
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
|
||||
// Extract the content type and the Base64 data
|
||||
const [metadata, base64] = base64DataUrl.split(",");
|
||||
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
|
||||
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
|
||||
|
||||
const byteCharacters = atob(base64);
|
||||
const byteArrays = [];
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||
const slice = byteCharacters.slice(offset, offset + sliceSize);
|
||||
|
||||
const byteNumbers = new Array(slice.length);
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i);
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
byteArrays.push(byteArray);
|
||||
}
|
||||
return new Blob(byteArrays, { type: contentType });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the DID of the person who offered, or undefined if hidden
|
||||
* @param veriClaim is expected to have fields: claim and issuer
|
||||
*/
|
||||
export const offerGiverDid: (
|
||||
arg0: GenericServerRecord,
|
||||
arg0: GenericCredWrapper<OfferVerifiableCredential>,
|
||||
) => string | undefined = (veriClaim) => {
|
||||
let giver;
|
||||
if (
|
||||
@@ -122,8 +195,13 @@ export const offerGiverDid: (
|
||||
* @returns true if the user can fulfill the offer
|
||||
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
||||
*/
|
||||
export const canFulfillOffer = (veriClaim: GenericServerRecord) => {
|
||||
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim));
|
||||
export const canFulfillOffer = (
|
||||
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
||||
) => {
|
||||
return !!(
|
||||
veriClaim.claimType === "Offer" &&
|
||||
offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
|
||||
);
|
||||
};
|
||||
|
||||
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
|
||||
@@ -192,20 +270,17 @@ export function findAllVisibleToDids(
|
||||
*
|
||||
**/
|
||||
|
||||
export const getIdentity = async (activeDid: string): Promise<IIdentifier> => {
|
||||
export interface AccountKeyInfo extends Account, KeyMeta {}
|
||||
|
||||
export const getAccount = async (
|
||||
activeDid: string,
|
||||
): Promise<AccountKeyInfo | undefined> => {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
`Attempted to load Offer records for DID ${activeDid} but no identifier was found`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
return account;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -238,8 +313,49 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
return newId.did;
|
||||
};
|
||||
|
||||
export const registerAndSavePasskey = async (
|
||||
keyName: string,
|
||||
): Promise<Account> => {
|
||||
const cred = await registerCredential(keyName);
|
||||
const publicKeyBytes = cred.publicKeyBytes;
|
||||
const did = createPeerDid(publicKeyBytes as Uint8Array);
|
||||
const passkeyCredIdHex = cred.credIdHex as string;
|
||||
|
||||
const account = {
|
||||
dateCreated: new Date().toISOString(),
|
||||
did,
|
||||
passkeyCredIdHex,
|
||||
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
|
||||
};
|
||||
await accountsDB.open();
|
||||
await accountsDB.accounts.add(account);
|
||||
return account;
|
||||
};
|
||||
|
||||
export const registerSaveAndActivatePasskey = async (
|
||||
keyName: string,
|
||||
): Promise<Account> => {
|
||||
const account = await registerAndSavePasskey(keyName);
|
||||
|
||||
await db.open();
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: account.did,
|
||||
});
|
||||
|
||||
return account;
|
||||
};
|
||||
|
||||
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const passkeyExpirationSeconds =
|
||||
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
|
||||
60;
|
||||
return passkeyExpirationSeconds;
|
||||
};
|
||||
|
||||
export const sendTestThroughPushServer = async (
|
||||
subscription: PushSubscription,
|
||||
subscriptionJSON: PushSubscriptionJSON,
|
||||
skipFilter: boolean,
|
||||
): Promise<AxiosResponse> => {
|
||||
await db.open();
|
||||
@@ -254,28 +370,11 @@ export const sendTestThroughPushServer = async (
|
||||
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
|
||||
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
||||
|
||||
const auth = Buffer.from(subscription.getKey("auth"));
|
||||
const authB64 = auth
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const p256dh = Buffer.from(subscription.getKey("p256dh"));
|
||||
const p256dhB64 = p256dh
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const newPayload = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
auth: authB64,
|
||||
p256dh: p256dhB64,
|
||||
},
|
||||
message: `Test, where you will see this message ${
|
||||
skipFilter ? "un" : ""
|
||||
}filtered.`,
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
|
||||
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
||||
...subscriptionJSON,
|
||||
};
|
||||
console.log("Sending a test web push message:", newPayload);
|
||||
const payloadStr = JSON.stringify(newPayload);
|
||||
|
||||
55
src/main.ts
@@ -1,5 +1,5 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./registerServiceWorker";
|
||||
import router from "./router";
|
||||
@@ -11,17 +11,22 @@ import "./assets/styles/tailwind.css";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
@@ -32,6 +37,7 @@ import {
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
@@ -43,8 +49,11 @@ import {
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
@@ -60,6 +69,7 @@ import {
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
@@ -71,17 +81,22 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
@@ -92,6 +107,7 @@ library.add(
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
@@ -103,8 +119,11 @@ library.add(
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
@@ -120,6 +139,7 @@ library.add(
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
@@ -133,11 +153,38 @@ library.add(
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
|
||||
createApp(App)
|
||||
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
|
||||
function setupGlobalErrorHandler(app: VueApp) {
|
||||
// @ts-expect-error 'cause we cannot see why config is not defined
|
||||
app.config.errorHandler = (
|
||||
err: Error,
|
||||
instance: ComponentPublicInstance | null,
|
||||
info: string,
|
||||
) => {
|
||||
console.error(
|
||||
"Ouch! Global Error Handler. Info:",
|
||||
info,
|
||||
"Error:",
|
||||
err,
|
||||
"Instance:",
|
||||
instance,
|
||||
);
|
||||
// Want to show a nice notiwind notification but can't figure out how.
|
||||
alert(
|
||||
(err.message || "Something bad happened") +
|
||||
" - Try reloading or restarting the app.",
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
.component("fa", FontAwesomeIcon)
|
||||
.component("camera", Camera)
|
||||
.use(createPinia())
|
||||
.use(VueAxios, axios)
|
||||
.use(router)
|
||||
.use(Notifications)
|
||||
.mount("#app");
|
||||
.use(Notifications);
|
||||
|
||||
setupGlobalErrorHandler(app);
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { register } from "register-service-worker";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
|
||||
if (import.meta.env.NODE_ENV === "production") {
|
||||
register("/sw_scripts-combined.js", {
|
||||
ready() {
|
||||
console.log(
|
||||
|
||||
@@ -31,221 +31,192 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/account",
|
||||
name: "account",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
|
||||
component: () => import("../views/AccountViewView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/claim/:id?",
|
||||
name: "claim",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"),
|
||||
component: () => import("../views/ClaimView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/claim-add-raw/:id?",
|
||||
name: "claim-add-raw",
|
||||
component: () => import("../views/ClaimAddRawView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/confirm-contact",
|
||||
name: "confirm-contact",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "confirm-contact" */ "../views/ConfirmContactView.vue"
|
||||
),
|
||||
component: () => import("../views/ConfirmContactView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/confirm-gift/:id?",
|
||||
name: "confirm-gift",
|
||||
component: () => import("@/views/ConfirmGiftView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-amounts",
|
||||
name: "contact-amounts",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
|
||||
),
|
||||
component: () => import("../views/ContactAmountsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-gives",
|
||||
name: "contact-gives",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
|
||||
),
|
||||
path: "/contact-gift",
|
||||
name: "contact-gift",
|
||||
component: () => import("../views/ContactGiftingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-import",
|
||||
name: "contact-import",
|
||||
component: () => import("../views/ContactImportView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-qr",
|
||||
name: "contact-qr",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-qr" */ "../views/ContactQRScanShowView.vue"
|
||||
),
|
||||
component: () => import("../views/ContactQRScanShowView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contacts",
|
||||
name: "contacts",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
|
||||
component: () => import("../views/ContactsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/did/:did?",
|
||||
name: "did",
|
||||
component: () => import("../views/DIDView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/discover",
|
||||
name: "discover",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
|
||||
component: () => import("../views/DiscoverView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/gifted-details",
|
||||
name: "gifted-details",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "gifted-details" */ "../views/GiftedDetails.vue"
|
||||
),
|
||||
component: () => import("@/views/GiftedDetailsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help",
|
||||
name: "help",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
||||
component: () => import("../views/HelpView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help-notifications",
|
||||
name: "help-notifications",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue"
|
||||
),
|
||||
component: () => import("../views/HelpNotificationsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help-onboarding",
|
||||
name: "help-onboarding",
|
||||
component: () => import("../views/HelpOnboardingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: () => import("../views/HomeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/identity-switcher",
|
||||
name: "identity-switcher",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
||||
),
|
||||
component: () => import("../views/IdentitySwitcherView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/import-account",
|
||||
name: "import-account",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
|
||||
),
|
||||
component: () => import("../views/ImportAccountView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/import-derive",
|
||||
name: "import-derive",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
|
||||
),
|
||||
component: () => import("../views/ImportDerivedAccountView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-account",
|
||||
name: "new-edit-account",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-edit-account" */ "../views/NewEditAccountView.vue"
|
||||
),
|
||||
component: () => import("../views/NewEditAccountView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-project",
|
||||
name: "new-edit-project",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue"
|
||||
),
|
||||
component: () => import("../views/NewEditProjectView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/new-identifier",
|
||||
name: "new-identifier",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
|
||||
),
|
||||
component: () => import("../views/NewIdentifierView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/offer-details/:id?",
|
||||
name: "offer-details",
|
||||
component: () => import("../views/OfferDetailsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/project/:id?",
|
||||
name: "project",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
|
||||
component: () => import("../views/ProjectViewView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/projects",
|
||||
name: "projects",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
|
||||
component: () => import("../views/ProjectsView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/quick-action-bvc",
|
||||
name: "quick-action-bvc",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "quick-action-bvc" */ "../views/QuickActionBvcView.vue"
|
||||
),
|
||||
component: () => import("../views/QuickActionBvcView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/quick-action-bvc-begin",
|
||||
name: "quick-action-bvc-begin",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "quick-action-bvc-begin" */ "../views/QuickActionBvcBeginView.vue"
|
||||
),
|
||||
component: () => import("../views/QuickActionBvcBeginView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/quick-action-bvc-end",
|
||||
name: "quick-action-bvc-end",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "quick-action-bvc-end" */ "../views/QuickActionBvcEndView.vue"
|
||||
),
|
||||
component: () => import("../views/QuickActionBvcEndView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/scan-contact",
|
||||
name: "scan-contact",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
||||
),
|
||||
component: () => import("../views/ContactScanView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/search-area",
|
||||
name: "search-area",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
|
||||
),
|
||||
component: () => import("../views/SearchAreaView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/seed-backup",
|
||||
name: "seed-backup",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "seed-backup" */ "../views/SeedBackupView.vue"
|
||||
),
|
||||
component: () => import("../views/SeedBackupView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/shared-photo",
|
||||
name: "shared-photo",
|
||||
component: () => import("@/views/SharedPhotoView.vue"),
|
||||
},
|
||||
|
||||
// /share-target is also an endpoint in the service worker
|
||||
|
||||
{
|
||||
path: "/start",
|
||||
name: "start",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
|
||||
component: () => import("../views/StartView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/statistics",
|
||||
name: "statistics",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
|
||||
),
|
||||
component: () => import("../views/StatisticsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
name: "test",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
|
||||
component: () => import("../views/TestView.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {*} */
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { SERVICE_ID } from "../libs/endorserServer";
|
||||
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
/**
|
||||
* Get User #0 to sign & submit a RegisterAction for the user's activeDid.
|
||||
*/
|
||||
export async function testServerRegisterUser() {
|
||||
const testUser0Mnem =
|
||||
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";
|
||||
|
||||
1
src/util.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
// from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/util.d.ts
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* The `node:util` module supports the needs of Node.js internal APIs. Many of the
|
||||
* utilities are useful for application and module developers as well. To access
|
||||
|
||||
99
src/views/ClaimAddRawView.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw" />
|
||||
</button>
|
||||
Raw Claim
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea>
|
||||
</div>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
@click="submitClaim()"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import * as serverUtil from "@/libs/endorserServer";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({
|
||||
components: { QuickNav },
|
||||
})
|
||||
export default class ClaimAddRawView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
accountIdentityStr: string = "null";
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
claimStr = "";
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
this.claimStr = (this.$route as Router).query["claim"];
|
||||
try {
|
||||
this.veriClaim = JSON.parse(this.claimStr);
|
||||
this.claimStr = JSON.stringify(this.veriClaim, null, 2);
|
||||
} catch (e) {
|
||||
// ignore a parse
|
||||
}
|
||||
}
|
||||
|
||||
async submitClaim() {
|
||||
const fullClaim = JSON.parse(this.claimStr);
|
||||
const result = await serverUtil.createAndSubmitClaim(
|
||||
fullClaim,
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "Claim submitted.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
console.error("Got error submitting the claim:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
<fa icon="chevron-left" class="fa-fw" />
|
||||
</button>
|
||||
Verifiable Claim Details
|
||||
</h1>
|
||||
@@ -22,6 +22,18 @@
|
||||
<div class="overflow-hidden">
|
||||
<h2 class="text-md font-bold">
|
||||
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
|
||||
<button
|
||||
v-if="
|
||||
['GiveAction', 'Offer'].includes(
|
||||
veriClaim.claimType as string,
|
||||
) && veriClaim.issuer === activeDid
|
||||
"
|
||||
@click="onClickEditClaim"
|
||||
title="Edit"
|
||||
data-testId="editClaimButton"
|
||||
>
|
||||
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
</h2>
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
@@ -35,16 +47,19 @@
|
||||
"
|
||||
class="ml-2 mr-2"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||
</button>
|
||||
<span v-show="showIdCopy">Copied ID</span>
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="message" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.claim?.description }}
|
||||
<div data-testId="description">
|
||||
<fa icon="message" class="fa-fw text-slate-400" />
|
||||
{{
|
||||
veriClaim.claim?.itemOffered?.description ||
|
||||
veriClaim.claim?.description
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
<fa icon="user" class="fa-fw text-slate-400" />
|
||||
{{ veriClaim.issuer }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
|
||||
<button
|
||||
@@ -56,15 +71,20 @@
|
||||
"
|
||||
class="ml-2 mr-2"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||
</button>
|
||||
<span v-show="showDidCopy">Copied DID</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
||||
</div>
|
||||
<div v-if="veriClaim.claim.image" class="flex justify-center">
|
||||
<a :href="veriClaim.claim.image" target="_blank">
|
||||
<img :src="veriClaim.claim.image" class="h-24 rounded-xl" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Fullfills Links -->
|
||||
|
||||
@@ -121,22 +141,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns-3">
|
||||
<button
|
||||
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
v-if="
|
||||
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||
veriClaim,
|
||||
activeDid,
|
||||
confirmerIdList,
|
||||
)
|
||||
"
|
||||
@click="confirmClaim()"
|
||||
>
|
||||
Confirm
|
||||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||
</button>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
v-if="libsUtil.canFulfillOffer(veriClaim)"
|
||||
@click="openFulfillGiftDialog()"
|
||||
@@ -146,10 +151,38 @@
|
||||
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
<GiftedDialog ref="customGiveDialog" message="Offer fulfilled by" />
|
||||
|
||||
<div v-if="libsUtil.giveIsConfirmable(veriClaim)">
|
||||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
|
||||
<div v-if="libsUtil.isGiveAction(veriClaim)">
|
||||
<div class="flex columns-3">
|
||||
<button
|
||||
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-4 py-2 rounded-md"
|
||||
v-if="
|
||||
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||
isRegistered,
|
||||
veriClaim,
|
||||
activeDid,
|
||||
confirmerIdList,
|
||||
)
|
||||
"
|
||||
@click="confirmConfirmClaim()"
|
||||
>
|
||||
Confirm
|
||||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||
</button>
|
||||
<h2 v-else class="font-bold uppercase text-xl mt-2">Confirmations</h2>
|
||||
|
||||
<span class="mt-0.5 px-4 py-2">
|
||||
<router-link
|
||||
v-if="libsUtil.isGiveAction(veriClaim)"
|
||||
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
|
||||
class="col-span-1 text-blue-500"
|
||||
data-testId="confirmGiftLink"
|
||||
>
|
||||
Details...
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<GiftedDialog ref="customGiveDialog" />
|
||||
|
||||
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
|
||||
<span v-else-if="totalConfirmers() === 1">
|
||||
@@ -279,13 +312,15 @@
|
||||
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||
>click to send them this info</a
|
||||
>
|
||||
and see if they are willing to make an introduction.
|
||||
and see if they are willing to make an introduction. They are surely
|
||||
connected to someone; if you don't know who to ask, you might try the
|
||||
person who registered you.
|
||||
</span>
|
||||
<span v-else>
|
||||
If you'd like to ask any of your contacts to take a look and see if
|
||||
their contacts can see more details,
|
||||
<a
|
||||
@click="copyToClipboard('Location', windowLocation)"
|
||||
@click="copyToClipboard('This page location', windowLocation)"
|
||||
class="text-blue-500"
|
||||
>share this page with them</a
|
||||
>
|
||||
@@ -319,7 +354,7 @@
|
||||
class="list-disc p-4"
|
||||
>
|
||||
<div class="text-sm">
|
||||
<fa icon="minus" class="fa-fw"></fa>
|
||||
<fa icon="minus" class="fa-fw" />
|
||||
The {{ visibleDidPath }} is visible to:
|
||||
</div>
|
||||
<div class="ml-12 p-1">
|
||||
@@ -341,8 +376,7 @@
|
||||
</span>
|
||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||
>, found at
|
||||
<fa icon="globe" class="fa-fw text-slate-400"></fa
|
||||
> <a
|
||||
<fa icon="globe" class="fa-fw text-slate-400" /> <a
|
||||
:href="veriClaim.publicUrls?.[visDid]"
|
||||
class="text-blue-500"
|
||||
>{{
|
||||
@@ -359,9 +393,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="isEditedGlobalId" class="mt-2">
|
||||
This record is an edited version. The latest version is here.
|
||||
</span>
|
||||
<br />
|
||||
<button @click="showVeriClaimDump = !showVeriClaimDump" class="ml-2">
|
||||
Details
|
||||
<fa v-if="showVeriClaimDump" icon="chevron-up" class="text-blue-400" />
|
||||
<fa v-else icon="chevron-down" class="text-blue-400" />
|
||||
</button>
|
||||
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
||||
<pre
|
||||
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
|
||||
v-if="showVeriClaimDump"
|
||||
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
|
||||
>{{ veriClaimDump }}</pre
|
||||
>
|
||||
</div>
|
||||
@@ -384,7 +428,10 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<pre>{{ fullClaimDump }}</pre>
|
||||
<pre
|
||||
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
|
||||
>{{ fullClaimDump }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<a
|
||||
@@ -398,34 +445,34 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||
import { AxiosError } from "axios";
|
||||
import * as yaml from "js-yaml";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import OfferDialog from "@/components/OfferDialog.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import * as serverUtil from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { GiverInputInfo } from "@/libs/endorserServer";
|
||||
import {
|
||||
GenericCredWrapper,
|
||||
GiverReceiverInputInfo,
|
||||
OfferVerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
|
||||
components: { GiftedDialog, QuickNav },
|
||||
})
|
||||
export default class ClaimView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
accountIdentityStr: string = "null";
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
@@ -440,9 +487,12 @@ export default class ClaimView extends Vue {
|
||||
fullClaim = null;
|
||||
fullClaimDump = "";
|
||||
fullClaimMessage = "";
|
||||
isEditedGlobalId = false;
|
||||
isRegistered = false;
|
||||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||
showDidCopy = false;
|
||||
showIdCopy = false;
|
||||
showVeriClaimDump = false;
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible = {};
|
||||
@@ -462,6 +512,8 @@ export default class ClaimView extends Vue {
|
||||
this.fullClaim = null;
|
||||
this.fullClaimDump = "";
|
||||
this.fullClaimMessage = "";
|
||||
this.isEditedGlobalId = false;
|
||||
this.isRegistered = false;
|
||||
this.numConfsNotVisible = 0;
|
||||
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
this.veriClaimDump = "";
|
||||
@@ -473,20 +525,18 @@ export default class ClaimView extends Vue {
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.isRegistered = settings?.isRegistered || false;
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr = await accounts?.toArray();
|
||||
const accountsArr: Array<Account> = await accounts?.toArray();
|
||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||
this.accountIdentityStr = account?.identity || "null";
|
||||
const identity = JSON.parse(this.accountIdentityStr);
|
||||
|
||||
const pathParam = window.location.pathname.substring("/claim/".length);
|
||||
let claimId;
|
||||
if (pathParam) {
|
||||
claimId = decodeURIComponent(pathParam);
|
||||
await this.loadClaim(claimId, identity);
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -520,33 +570,6 @@ export default class ClaimView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string): Promise<IIdentifier> {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identifier available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
didInfo(did: string) {
|
||||
return serverUtil.didInfo(
|
||||
@@ -557,12 +580,12 @@ export default class ClaimView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
async loadClaim(claimId: string, identity: IIdentifier) {
|
||||
async loadClaim(claimId: string, userDid: string) {
|
||||
const urlPath = libsUtil.isGlobalUri(claimId)
|
||||
? "/api/claim/byHandle/"
|
||||
: "/api/claim/";
|
||||
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
|
||||
const headers = await this.getHeaders(identity);
|
||||
const headers = await serverUtil.getHeaders(userDid);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
@@ -588,13 +611,15 @@ export default class ClaimView extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditedGlobalId = !this.veriClaim.handleId.endsWith(claimId);
|
||||
|
||||
// retrieve more details on Give, Offer, or Plan
|
||||
if (this.veriClaim.claimType === "GiveAction") {
|
||||
const giveUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?handleId=" +
|
||||
encodeURIComponent(this.veriClaim.handleId as string);
|
||||
const giveHeaders = await this.getHeaders(identity);
|
||||
const giveHeaders = await serverUtil.getHeaders(userDid);
|
||||
const giveResp = await this.axios.get(giveUrl, {
|
||||
headers: giveHeaders,
|
||||
});
|
||||
@@ -608,7 +633,7 @@ export default class ClaimView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/v2/report/offers?handleId=" +
|
||||
encodeURIComponent(this.veriClaim.handleId as string);
|
||||
const offerHeaders = await this.getHeaders(identity);
|
||||
const offerHeaders = await serverUtil.getHeaders(userDid);
|
||||
const offerResp = await this.axios.get(offerUrl, {
|
||||
headers: offerHeaders,
|
||||
});
|
||||
@@ -624,12 +649,14 @@ export default class ClaimView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
||||
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
||||
const confirmHeaders = await this.getHeaders(identity);
|
||||
const confirmHeaders = await serverUtil.getHeaders(userDid);
|
||||
const response = await this.axios.get(confirmUrl, {
|
||||
headers: confirmHeaders,
|
||||
});
|
||||
if (response.status === 200) {
|
||||
const resultList1 = response.data.result || [];
|
||||
//const publicUrls = resultList.publicUrls || [];
|
||||
delete resultList1.publicUrls;
|
||||
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
||||
const resultList3 = R.reject(
|
||||
(did: string) => did === this.veriClaim.issuer,
|
||||
@@ -664,15 +691,9 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
|
||||
async showFullClaim(claimId: string) {
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr: Account[] = await accounts?.toArray();
|
||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
const url =
|
||||
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
||||
const headers = await this.getHeaders(identity);
|
||||
const headers = await serverUtil.getHeaders(this.activeDid);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
@@ -717,52 +738,65 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
confirmConfirmClaim() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Confirm",
|
||||
text: "Do you personally confirm that this is true?",
|
||||
onYes: async () => {
|
||||
await this.confirmClaim();
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
// similar code is found in ProjectViewView
|
||||
async confirmClaim() {
|
||||
if (confirm("Do you personally confirm that this is true?")) {
|
||||
// similar logic is found in endorser-mobile
|
||||
const goodClaim = serverUtil.removeSchemaContext(
|
||||
serverUtil.removeVisibleToDids(
|
||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||
this.veriClaim.claim,
|
||||
this.veriClaim.id,
|
||||
this.veriClaim.handleId,
|
||||
),
|
||||
// similar logic is found in endorser-mobile
|
||||
const goodClaim = serverUtil.removeSchemaContext(
|
||||
serverUtil.removeVisibleToDids(
|
||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||
this.veriClaim.claim,
|
||||
this.veriClaim.id,
|
||||
this.veriClaim.handleId,
|
||||
),
|
||||
),
|
||||
);
|
||||
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AgreeAction",
|
||||
object: goodClaim,
|
||||
};
|
||||
const result = await serverUtil.createAndSubmitClaim(
|
||||
confirmationClaim,
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "Confirmation submitted.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AgreeAction",
|
||||
object: goodClaim,
|
||||
};
|
||||
const result = await serverUtil.createAndSubmitClaim(
|
||||
confirmationClaim,
|
||||
await this.getIdentity(this.activeDid),
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
} else {
|
||||
console.error("Got error submitting the confirmation:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "Confirmation submitted.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
console.error("Got error submitting the confirmation:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,19 +804,23 @@ export default class ClaimView extends Vue {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(claimId),
|
||||
};
|
||||
this.$router.push(route).then(async () => {
|
||||
(this.$router as Router).push(route).then(async () => {
|
||||
this.resetThisValues();
|
||||
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
});
|
||||
}
|
||||
|
||||
openFulfillGiftDialog() {
|
||||
const giver: GiverInputInfo = {
|
||||
did: libsUtil.offerGiverDid(this.veriClaim),
|
||||
const giver: GiverReceiverInputInfo = {
|
||||
did: libsUtil.offerGiverDid(
|
||||
this.veriClaim as GenericCredWrapper<OfferVerifiableCredential>,
|
||||
),
|
||||
};
|
||||
(this.$refs.customGiveDialog as GiftedDialog).open(
|
||||
giver,
|
||||
undefined,
|
||||
this.veriClaim.handleId,
|
||||
"Offer fulfilled by " + (giver?.name || "someone not named"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -809,5 +847,43 @@ export default class ClaimView extends Vue {
|
||||
url: this.windowLocation,
|
||||
});
|
||||
}
|
||||
|
||||
onClickEditClaim() {
|
||||
if (this.veriClaim.claimType === "GiveAction") {
|
||||
const route = {
|
||||
name: "gifted-details",
|
||||
query: {
|
||||
prevCredToEdit: JSON.stringify(this.veriClaim),
|
||||
destinationPathAfter:
|
||||
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
|
||||
},
|
||||
};
|
||||
(this.$router as Router).push(route);
|
||||
} else if (this.veriClaim.claimType === "Offer") {
|
||||
const route = {
|
||||
name: "offer-details",
|
||||
query: {
|
||||
prevCredToEdit: JSON.stringify(this.veriClaim),
|
||||
destinationPathAfter:
|
||||
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
|
||||
},
|
||||
};
|
||||
(this.$router as Router).push(route);
|
||||
} else {
|
||||
console.error(
|
||||
"Unrecognized claim type for edit:",
|
||||
this.veriClaim.claimType,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "This is an unrecognized claim type.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,17 +30,19 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Add Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
value="Add Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
872
src/views/ConfirmGiftView.vue
Normal file
@@ -0,0 +1,872 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<TopMessage />
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw" />
|
||||
</button>
|
||||
<span
|
||||
v-if="
|
||||
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||
isRegistered,
|
||||
veriClaim,
|
||||
activeDid,
|
||||
confirmerIdList,
|
||||
)
|
||||
"
|
||||
>
|
||||
Do you agree?
|
||||
</span>
|
||||
<span v-else> Details </span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="giveDetails && !isLoading">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
v-if="
|
||||
libsUtil.isGiveRecordTheUserCanConfirm(
|
||||
isRegistered,
|
||||
veriClaim,
|
||||
activeDid,
|
||||
confirmerIdList,
|
||||
)
|
||||
"
|
||||
@click="confirmConfirmClaim()"
|
||||
>
|
||||
Confirm
|
||||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="notifyWhyCannotConfirm()"
|
||||
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
Confirm
|
||||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
|
||||
</button>
|
||||
<a
|
||||
v-if="isRegistered"
|
||||
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md"
|
||||
:href="urlForNewGive"
|
||||
>
|
||||
Record a Similar One
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
|
||||
<div class="block flex gap-4 overflow-hidden">
|
||||
<div class="overflow-hidden">
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
<fa icon="arrow-down" class="fa-fw text-slate-400" />
|
||||
{{ giverName }}
|
||||
</div>
|
||||
<div class="ml-6">gave</div>
|
||||
<div v-if="giveDetails.amount">
|
||||
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" />
|
||||
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
|
||||
</div>
|
||||
<div v-if="giveDetails.description">
|
||||
<fa icon="message" class="fa-fw text-slate-400" />
|
||||
{{ giveDetails.amount ? "and:" : "" }}
|
||||
{{ giveDetails.description }}
|
||||
</div>
|
||||
<div class="ml-6">to</div>
|
||||
<div>
|
||||
<fa icon="arrow-up" class="fa-fw text-slate-400" />
|
||||
{{ recipientName }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||
on
|
||||
{{ giveDetails.issuedAt.substring(0, 10) }}
|
||||
</div>
|
||||
|
||||
<!-- Fullfills Links -->
|
||||
|
||||
<!-- fullfills links for a give -->
|
||||
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId">
|
||||
<router-link
|
||||
:to="
|
||||
'/project/' +
|
||||
encodeURIComponent(giveDetails?.fulfillsPlanHandleId)
|
||||
"
|
||||
class="text-blue-500 mt-2 cursor-pointer"
|
||||
target="_blank"
|
||||
>
|
||||
This fulfills a bigger plan
|
||||
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- if there's another, it's probably fulfilling an offer, too -->
|
||||
<div
|
||||
v-if="
|
||||
giveDetails?.fulfillsType &&
|
||||
giveDetails?.fulfillsType !== 'PlanAction' &&
|
||||
giveDetails?.fulfillsHandleId
|
||||
"
|
||||
>
|
||||
<!-- router-link to /claim/ only changes URL path -->
|
||||
<router-link
|
||||
:to="
|
||||
'/claim/' +
|
||||
encodeURIComponent(giveDetails?.fulfillsHandleId)
|
||||
"
|
||||
class="text-blue-500 mt-2 cursor-pointer"
|
||||
target="_blank"
|
||||
>
|
||||
This fulfills
|
||||
{{
|
||||
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
|
||||
giveDetails.fulfillsType,
|
||||
)
|
||||
}}
|
||||
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<fa icon="comment" class="text-slate-400" />
|
||||
{{ issuerName }} posted that.
|
||||
</div>
|
||||
|
||||
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4">
|
||||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
|
||||
|
||||
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
|
||||
<span v-else-if="totalConfirmers() === 1">
|
||||
One person confirmed this.
|
||||
</span>
|
||||
<span v-else> {{ totalConfirmers() }} people confirmed this. </span>
|
||||
|
||||
<div v-if="totalConfirmers() > 0">
|
||||
<div
|
||||
v-if="
|
||||
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
|
||||
"
|
||||
>
|
||||
Nobody that you know confirmed this claim, nor do they have any
|
||||
confirmers in their network.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0
|
||||
"
|
||||
>
|
||||
<!-- Only show if this person has links to confirmers (below). -->
|
||||
Nobody that you know issued or confirmed this claim.
|
||||
</div>
|
||||
<div v-if="confirmerIdList.length > 0">
|
||||
The following people issued or confirmed this claim.
|
||||
<ul class="ml-4">
|
||||
<li
|
||||
v-for="confirmerId in confirmerIdList"
|
||||
:key="confirmerId"
|
||||
class="list-disc ml-4"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confirmerId) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
||||
<button
|
||||
@click="
|
||||
copyToClipboard(
|
||||
'The DID of ' + confirmerId,
|
||||
confirmerId,
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Never need to show this message:
|
||||
"Nobody that you know can see someone who confirmed this claim."
|
||||
|
||||
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
|
||||
If there is somebody in the confirmerIdList then that's all they need to show.
|
||||
-->
|
||||
|
||||
<!-- Now show anyone linked to confirmers. -->
|
||||
<div v-if="confsVisibleToIdList.length > 0">
|
||||
The following people can connect you with people who issued or
|
||||
confirmed this claim.
|
||||
<ul class="ml-4">
|
||||
<li
|
||||
v-for="confsVisibleTo in confsVisibleToIdList"
|
||||
:key="confsVisibleTo"
|
||||
class="list-disc ml-4"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confsVisibleTo) }}
|
||||
<span
|
||||
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"
|
||||
>
|
||||
<button
|
||||
@click="
|
||||
copyToClipboard(
|
||||
'The DID of ' + confsVisibleTo,
|
||||
confsVisibleTo,
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- explain if user cannot confirm -->
|
||||
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
|
||||
<div v-if="!isRegistered">
|
||||
You cannot confirm this because you are not registered. Find someone
|
||||
to register you, maybe on the Help page.
|
||||
</div>
|
||||
<div v-else-if="giveDetails.issuerDid == activeDid">
|
||||
You cannot confirm this because you issued this claim, so you already
|
||||
count as confirming it.
|
||||
</div>
|
||||
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
|
||||
You cannot confirm this because it contains hidden identifiers.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
|
||||
@click="showDetails = !showDetails"
|
||||
>
|
||||
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
|
||||
<span v-if="!showDetails"><fa icon="chevron-down" /></span>
|
||||
<span v-else><fa icon="chevron-up" /></span>
|
||||
</h2>
|
||||
<div v-if="showDetails">
|
||||
<div
|
||||
v-if="
|
||||
serverUtil.containsHiddenDid(veriClaim) &&
|
||||
R.isEmpty(veriClaimDidsVisible)
|
||||
"
|
||||
class="mb-2"
|
||||
>
|
||||
Some of the details are not visible to you; they show as "HIDDEN".
|
||||
They are not visible to any of your direct contacts, either.
|
||||
<span v-if="canShare">
|
||||
If you'd like to ask any of your contacts to take a look and see if
|
||||
their contacts can see more details,
|
||||
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||
>click to send them this info</a
|
||||
>
|
||||
and see if they are willing to make an introduction.
|
||||
</span>
|
||||
<span v-else>
|
||||
If you'd like to ask any of your contacts to take a look and see if
|
||||
their contacts can see more details,
|
||||
<a
|
||||
@click="copyToClipboard('Location', windowLocation.href)"
|
||||
class="text-blue-500"
|
||||
>share this page with them</a
|
||||
>
|
||||
and see if they are willing to make an introduction.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
|
||||
Some of the details are not visible to you but they are visible to
|
||||
some of your contacts.
|
||||
<span v-if="canShare">
|
||||
If you'd like an introduction,
|
||||
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||
>click to share the information with them and ask if they'll tell
|
||||
you more about the participants.</a
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
@click="copyToClipboard('Location', windowLocation.href)"
|
||||
class="text-blue-500"
|
||||
>share this page with them and ask if they'll tell you more about
|
||||
about the participants.</a
|
||||
>
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
|
||||
:key="index"
|
||||
class="list-disc p-4"
|
||||
>
|
||||
<div class="text-sm">
|
||||
<fa icon="minus" class="fa-fw" />
|
||||
The {{ visibleDidPath }} is visible to:
|
||||
</div>
|
||||
<div class="ml-12 p-1">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
|
||||
:key="idx2"
|
||||
class="list-disc"
|
||||
>
|
||||
<div class="text-sm mt-2">
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<button
|
||||
@click="
|
||||
copyToClipboard('The DID of ' + visDid, visDid)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||
>, found at
|
||||
<fa icon="globe" class="fa-fw text-slate-400" />
|
||||
<a
|
||||
:href="veriClaim.publicUrls?.[visDid]"
|
||||
class="text-blue-500"
|
||||
>{{
|
||||
veriClaim.publicUrls[visDid].substring(
|
||||
veriClaim.publicUrls[visDid].indexOf("//") + 2,
|
||||
)
|
||||
}}
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
||||
<pre
|
||||
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
|
||||
>{{ veriClaimDump }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isLoading">This does not have details to confirm.</div>
|
||||
|
||||
<div class="mt-4" v-if="!isLoading">
|
||||
<a
|
||||
@click="showClaimPage(veriClaim.id)"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
<fa icon="file-lines" class="pl-2" />
|
||||
All Generic Info
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||
v-if="isLoading"
|
||||
>
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import * as yaml from "js-yaml";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import * as serverUtil from "@/libs/endorserServer";
|
||||
import { displayAmount, GiveSummaryRecord } from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { isGiveAction } from "@/libs/util";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
|
||||
@Component({
|
||||
methods: { displayAmount },
|
||||
components: { TopMessage, QuickNav },
|
||||
})
|
||||
export default class ClaimView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
|
||||
canShare = false;
|
||||
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
|
||||
confsVisibleErrorMessage = "";
|
||||
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
|
||||
giveDetails?: GiveSummaryRecord;
|
||||
giverName = "";
|
||||
issuerName = "";
|
||||
isLoading = false;
|
||||
isRegistered = false;
|
||||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||
recipientName = "";
|
||||
showDetails = false;
|
||||
urlForNewGive = "";
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible = {};
|
||||
windowLocation = window.location;
|
||||
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
libsUtil = libsUtil;
|
||||
serverUtil = serverUtil;
|
||||
|
||||
resetThisValues() {
|
||||
this.confirmerIdList = [];
|
||||
this.confsVisibleErrorMessage = "";
|
||||
this.confsVisibleToIdList = [];
|
||||
this.giveDetails = undefined;
|
||||
this.isRegistered = false;
|
||||
this.numConfsNotVisible = 0;
|
||||
this.urlForNewGive = "";
|
||||
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
this.veriClaimDump = "";
|
||||
}
|
||||
|
||||
async mounted() {
|
||||
this.isLoading = true;
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.isRegistered = settings?.isRegistered || false;
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr: Array<Account> = await accounts?.toArray();
|
||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||
|
||||
const pathParam = window.location.pathname.substring(
|
||||
"/confirm-gift/".length,
|
||||
);
|
||||
let claimId;
|
||||
if (pathParam) {
|
||||
claimId = decodeURIComponent(pathParam);
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "No claim ID was provided.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||
// then use this truer check: navigator.canShare && navigator.canShare()
|
||||
this.canShare = !!navigator.share;
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
// insert a space before any capital letters except the initial letter
|
||||
// (and capitalize initial letter, just in case)
|
||||
capitalizeAndInsertSpacesBeforeCaps(text: string) {
|
||||
return !text
|
||||
? ""
|
||||
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
|
||||
}
|
||||
|
||||
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) {
|
||||
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
|
||||
if (word) {
|
||||
// if the word starts with a vowel, use "an" instead of "a"
|
||||
const firstLetter = word[0].toLowerCase();
|
||||
const vowels = ["a", "e", "i", "o", "u"];
|
||||
const particle = vowels.includes(firstLetter) ? "an" : "a";
|
||||
return particle + " " + word;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
totalConfirmers() {
|
||||
return (
|
||||
this.numConfsNotVisible +
|
||||
this.confirmerIdList.length +
|
||||
this.confsVisibleToIdList.length
|
||||
);
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
didInfo(did: string | undefined) {
|
||||
return serverUtil.didInfo(
|
||||
did,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
async loadClaim(claimId: string, userDid: string) {
|
||||
const urlPath = libsUtil.isGlobalUri(claimId)
|
||||
? "/api/claim/byHandle/"
|
||||
: "/api/claim/";
|
||||
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
|
||||
|
||||
try {
|
||||
const headers = await serverUtil.getHeaders(userDid);
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
// resp.data is:
|
||||
// - a Jwt from https://api.endorser.ch/api-docs/
|
||||
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3
|
||||
if (resp.status === 200) {
|
||||
this.veriClaim = resp.data;
|
||||
this.veriClaimDump = yaml.dump(this.veriClaim);
|
||||
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
|
||||
this.veriClaim,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
// actually, axios typically throws an error so we never get here
|
||||
console.error("Error getting claim:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem retrieving that claim.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// retrieve more details on Give, Offer, or Plan
|
||||
if (this.veriClaim.claimType !== "GiveAction") {
|
||||
// no need to go further... this page is for gifts
|
||||
return;
|
||||
}
|
||||
|
||||
this.issuerName = this.didInfo(this.veriClaim.issuer);
|
||||
|
||||
// use give record when possible since it may include edits
|
||||
const giveUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?handleId=" +
|
||||
encodeURIComponent(this.veriClaim.handleId as string);
|
||||
const giveHeaders = await serverUtil.getHeaders(userDid);
|
||||
const giveResp = await this.axios.get(giveUrl, {
|
||||
headers: giveHeaders,
|
||||
});
|
||||
// giveResp.data is a Give from https://api.endorser.ch/api-docs/
|
||||
if (giveResp.status === 200) {
|
||||
this.giveDetails = giveResp.data.data[0];
|
||||
} else {
|
||||
console.error("Error getting detailed give info:", giveResp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving gift data.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// the logic already stops earlier if the claim doesn't exist but this helps with typechecking
|
||||
if (!this.giveDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.urlForNewGive = "/gifted-details?";
|
||||
if (this.giveDetails.amount) {
|
||||
this.urlForNewGive +=
|
||||
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount));
|
||||
}
|
||||
if (this.giveDetails.unit) {
|
||||
this.urlForNewGive +=
|
||||
"&unitCode=" + encodeURIComponent(this.giveDetails.unit);
|
||||
}
|
||||
if (this.giveDetails.description) {
|
||||
this.urlForNewGive +=
|
||||
"&description=" + encodeURIComponent(this.giveDetails.description);
|
||||
}
|
||||
this.giverName = this.didInfo(this.giveDetails.agentDid);
|
||||
if (this.giveDetails.agentDid) {
|
||||
this.urlForNewGive +=
|
||||
"&giverDid=" +
|
||||
encodeURIComponent(this.giveDetails.agentDid) +
|
||||
"&giverName=" +
|
||||
encodeURIComponent(this.giverName);
|
||||
}
|
||||
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
|
||||
if (this.giveDetails.recipientDid) {
|
||||
this.urlForNewGive +=
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(this.giveDetails.recipientDid) +
|
||||
"&recipientName=" +
|
||||
encodeURIComponent(this.recipientName);
|
||||
}
|
||||
if (this.giveDetails.fullClaim.image) {
|
||||
this.urlForNewGive +=
|
||||
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image);
|
||||
}
|
||||
if (
|
||||
this.giveDetails.type == "Offer" &&
|
||||
this.giveDetails.fulfillsHandleId
|
||||
) {
|
||||
this.urlForNewGive +=
|
||||
"&offerId=" +
|
||||
encodeURIComponent(this.giveDetails?.fulfillsHandleId as string);
|
||||
}
|
||||
if (this.giveDetails.fulfillsPlanHandleId) {
|
||||
this.urlForNewGive +=
|
||||
"&projectId=" +
|
||||
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
|
||||
}
|
||||
|
||||
// retrieve the list of confirmers
|
||||
const confirmUrl =
|
||||
this.apiServer +
|
||||
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
||||
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
||||
const confirmHeaders = await serverUtil.getHeaders(userDid);
|
||||
const response = await this.axios.get(confirmUrl, {
|
||||
headers: confirmHeaders,
|
||||
});
|
||||
if (response.status === 200) {
|
||||
const resultList1 = response.data.result || [];
|
||||
//const publicUrls = resultList.publicUrls || [];
|
||||
delete resultList1.publicUrls;
|
||||
// remove any hidden DIDs
|
||||
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
||||
// remove confirmations by this user
|
||||
const resultList3 = R.reject(
|
||||
(did: string) => did === this.giveDetails?.issuerDid,
|
||||
resultList2,
|
||||
);
|
||||
this.confirmerIdList = resultList3;
|
||||
this.numConfsNotVisible = resultList1.length - resultList2.length;
|
||||
if (resultList3.length === resultList2.length) {
|
||||
// the issuer was not in the "visible" list so they must be hidden
|
||||
// so subtract them from the non-visible confirmers count
|
||||
this.numConfsNotVisible = this.numConfsNotVisible - 1;
|
||||
}
|
||||
this.confsVisibleToIdList =
|
||||
response.data.result.resultVisibleToDids || [];
|
||||
} else {
|
||||
this.confsVisibleErrorMessage =
|
||||
"Had problems retrieving confirmations.";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
console.error("Error retrieving claim:", serverError);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving claim data.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
confirmConfirmClaim() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Confirm",
|
||||
text: "Do you personally confirm that this is true?",
|
||||
onYes: async () => {
|
||||
await this.confirmClaim();
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
// similar code is found in ProjectViewView
|
||||
async confirmClaim() {
|
||||
// similar logic is found in endorser-mobile
|
||||
const goodClaim = serverUtil.removeSchemaContext(
|
||||
serverUtil.removeVisibleToDids(
|
||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||
this.veriClaim.claim,
|
||||
this.veriClaim.id,
|
||||
this.veriClaim.handleId,
|
||||
),
|
||||
),
|
||||
);
|
||||
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AgreeAction",
|
||||
object: goodClaim,
|
||||
};
|
||||
const result = await serverUtil.createAndSubmitClaim(
|
||||
confirmationClaim,
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "Confirmation submitted.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
console.error("Got error submitting the confirmation:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showClaimPage(claimId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(claimId),
|
||||
};
|
||||
(this.$router as Router).push(route).then(async () => {
|
||||
this.resetThisValues();
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
});
|
||||
}
|
||||
|
||||
copyToClipboard(name: string, text: string) {
|
||||
useClipboard()
|
||||
.copy(text)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: (name || "That") + " was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
notifyWhyCannotConfirm() {
|
||||
if (!this.isRegistered) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Not Registered",
|
||||
text: "Someone needs to register you before you can contribute.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else if (!isGiveAction(this.veriClaim)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Not A Give",
|
||||
text: "This is not a giving action to confirm.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else if (this.confirmerIdList.includes(this.activeDid)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Already Confirmed",
|
||||
text: "You already confirmed this claim.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else if (this.giveDetails?.issuerDid == this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Cannot Confirm",
|
||||
text: "You cannot confirm this because you issued this claim.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else if (serverUtil.containsHiddenDid(this.giveDetails?.fullClaim)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Cannot Confirm",
|
||||
text: "You cannot confirm this because it contains hidden identifiers.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Cannot Confirm",
|
||||
text: "You cannot confirm this claim.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the full details of this claim. Can you help me?",
|
||||
url: this.windowLocation.href,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -55,9 +55,9 @@
|
||||
{{ new Date(record.issuedAt).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<span v-if="record.agentDid == contact?.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
{{ displayAmount(record.unit, record.amount) }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
@@ -71,7 +71,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<span v-if="record.agentDid == contact?.did">
|
||||
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
<span v-else>
|
||||
@@ -79,9 +79,9 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid != contact.did">
|
||||
<span v-if="record.agentDid != contact?.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
{{ displayAmount(record.unit, record.amount) }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
@@ -105,21 +105,22 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AxiosError, AxiosRequestHeaders } from "axios";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import {
|
||||
AgreeVerifiableCredential,
|
||||
GiveServerRecord,
|
||||
createEndorserJwtVcFromClaim,
|
||||
displayAmount,
|
||||
getHeaders,
|
||||
GiveSummaryRecord,
|
||||
GiveVerifiableCredential,
|
||||
SCHEMA_ORG_CONTEXT,
|
||||
} from "@/libs/endorserServer";
|
||||
@@ -131,48 +132,25 @@ export default class ContactAmountssView extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contact: Contact | null = null;
|
||||
giveRecords: Array<GiveServerRecord> = [];
|
||||
giveRecords: Array<GiveSummaryRecord> = [];
|
||||
numAccounts = 0;
|
||||
|
||||
displayAmount = displayAmount;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identifier available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const contactDid = this.$route.query.contactDid as string;
|
||||
const contactDid = (this.$route as Router).query["contactDid"] as string;
|
||||
this.contact = (await db.contacts.get(contactDid)) || null;
|
||||
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = (settings?.activeDid as string) || "";
|
||||
this.apiServer = (settings?.apiServer as string) || "";
|
||||
|
||||
if (this.activeDid && this.contact) {
|
||||
this.loadGives(this.activeDid, this.contact);
|
||||
@@ -196,15 +174,14 @@ export default class ContactAmountssView extends Vue {
|
||||
|
||||
async loadGives(activeDid: string, contact: Contact) {
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
let result: Array<GiveServerRecord> = [];
|
||||
let result: Array<GiveSummaryRecord> = [];
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
encodeURIComponent(identity.did) +
|
||||
encodeURIComponent(this.activeDid) +
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(contact.did);
|
||||
const headers = await this.getHeaders(identity);
|
||||
const headers = await getHeaders(activeDid);
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
result = resp.data.data;
|
||||
@@ -230,8 +207,8 @@ export default class ContactAmountssView extends Vue {
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
encodeURIComponent(contact.did) +
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(identity.did);
|
||||
const headers2 = await this.getHeaders(identity);
|
||||
encodeURIComponent(this.activeDid);
|
||||
const headers2 = await getHeaders(activeDid);
|
||||
const resp2 = await this.axios.get(url2, { headers: headers2 });
|
||||
if (resp2.status === 200) {
|
||||
result = R.concat(result, resp2.data.data);
|
||||
@@ -252,7 +229,7 @@ export default class ContactAmountssView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
const sortedResult: Array<GiveServerRecord> = R.sort(
|
||||
const sortedResult: Array<GiveSummaryRecord> = R.sort(
|
||||
(a, b) =>
|
||||
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
|
||||
result,
|
||||
@@ -271,7 +248,7 @@ export default class ContactAmountssView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async confirm(record: GiveServerRecord) {
|
||||
async confirm(record: GiveSummaryRecord) {
|
||||
// Make claim
|
||||
// I use clone here because otherwise it gets a Proxy object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -286,66 +263,44 @@ export default class ContactAmountssView extends Vue {
|
||||
object: origClaim,
|
||||
};
|
||||
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
const vcJwt: string = await createEndorserJwtVcFromClaim(
|
||||
this.activeDid,
|
||||
vcClaim,
|
||||
);
|
||||
|
||||
// Create a signature using private key of identity
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (identity.keys[0].privateKeyHex !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
const alg = undefined;
|
||||
// Create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
alg: alg,
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
||||
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
if (resp.data?.success) {
|
||||
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
if (resp.data?.success) {
|
||||
record.amountConfirmed =
|
||||
(origClaim.object?.amountOfThisGood as number) || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
width="32"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
/>
|
||||
Anonymous/Unnamed
|
||||
Unnamed/Unknown
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
@@ -47,7 +47,7 @@
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow font-semibold">
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:contact="contact"
|
||||
:iconSize="32"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
/>
|
||||
@@ -66,28 +66,21 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
message="Received from"
|
||||
:projectId="projectId"
|
||||
/>
|
||||
<GiftedDialog ref="customDialog" :projectId="projectId" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { Account, AccountsSchema } from "@/db/tables/accounts";
|
||||
import { db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { GiverInputInfo } from "@/libs/endorserServer";
|
||||
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
@@ -98,22 +91,21 @@ export default class ContactGiftingView extends Vue {
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
accounts: typeof AccountsSchema;
|
||||
numAccounts = 0;
|
||||
projectId = localStorage.getItem("projectId") || "";
|
||||
|
||||
async beforeCreate() {
|
||||
accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.allContacts = await db.contacts.orderBy("name").toArray();
|
||||
|
||||
// .orderBy("name") wouldn't retrieve any entries with a blank name
|
||||
// .toCollection.sortBy("name") didn't sort in an order I understood
|
||||
const baseContacts = await db.contacts.toArray();
|
||||
this.allContacts = baseContacts.sort((a, b) =>
|
||||
(a.name || "").localeCompare(b.name || ""),
|
||||
);
|
||||
|
||||
localStorage.removeItem("projectId");
|
||||
|
||||
@@ -134,33 +126,16 @@ export default class ContactGiftingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identifier available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
openDialog(giver?: GiverReceiverInputInfo) {
|
||||
const recipient = this.projectId
|
||||
? undefined
|
||||
: { did: this.activeDid, name: "you" };
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
recipient,
|
||||
undefined,
|
||||
"Given by " + (giver?.name || "someone not named"),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
194
src/views/ContactImportView.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<QuickNav selected="Contacts"></QuickNav>
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Contact Import
|
||||
</h1>
|
||||
|
||||
<span>
|
||||
Note that you will have to make them visible one-by-one in the list of
|
||||
Contacts.
|
||||
</span>
|
||||
<div v-if="sameCount > 0">
|
||||
<span v-if="sameCount == 1"
|
||||
>One contact is the same as an existing contact</span
|
||||
>
|
||||
<span v-else
|
||||
>{{ sameCount }} contacts are the same as existing contacts</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<ul v-if="contactsImporting.length > 0" class="border-t border-slate-300">
|
||||
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
|
||||
<div
|
||||
v-if="
|
||||
!contactsExisting[contact.did] ||
|
||||
!R.isEmpty(contactDifferences[contact.did])
|
||||
"
|
||||
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
|
||||
>
|
||||
<h2 class="text-base font-semibold">
|
||||
<input type="checkbox" v-model="contactsSelected[index]" />
|
||||
{{ contact.name || AppString.NO_CONTACT_NAME }}
|
||||
-
|
||||
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
|
||||
>Existing</span
|
||||
>
|
||||
<span v-else class="text-green-500">New</span>
|
||||
</h2>
|
||||
<div class="text-sm truncate">
|
||||
{{ contact.did }}
|
||||
</div>
|
||||
<div v-if="contactDifferences[contact.did]">
|
||||
<div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="font-bold">Field</div>
|
||||
<div class="font-bold">Old Value</div>
|
||||
<div class="font-bold">New Value</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(value, contactField) in contactDifferences[contact.did]"
|
||||
:key="contactField"
|
||||
class="grid grid-cols-3 border"
|
||||
>
|
||||
<div class="border p-1">{{ contactField }}</div>
|
||||
<div class="border p-1">{{ value.old }}</div>
|
||||
<div class="border p-1">{{ value.new }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<fa icon="spinner" v-if="importing" class="animate-spin" />
|
||||
<button
|
||||
v-else
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
|
||||
@click="importContacts"
|
||||
>
|
||||
Import Selected Contacts
|
||||
</button>
|
||||
</ul>
|
||||
<p v-else>There are no contacts to import.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import OfferDialog from "@/components/OfferDialog.vue";
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, OfferDialog, QuickNav },
|
||||
})
|
||||
export default class ContactImportView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
AppString = AppString;
|
||||
libsUtil = libsUtil;
|
||||
R = R;
|
||||
|
||||
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
|
||||
contactsImporting: Array<Contact> = []; // contacts from the import
|
||||
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
|
||||
contactDifferences: Record<
|
||||
string,
|
||||
Record<string, { new: string; old: string }>
|
||||
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
|
||||
importing = false;
|
||||
sameCount = 0;
|
||||
|
||||
async created() {
|
||||
// Retrieve the imported contacts from the query parameter
|
||||
const importedContacts =
|
||||
((this.$route as Router).query["contacts"] as string) || "[]";
|
||||
this.contactsImporting = JSON.parse(importedContacts);
|
||||
this.contactsSelected = new Array(this.contactsImporting.length).fill(
|
||||
false,
|
||||
);
|
||||
|
||||
await db.open();
|
||||
const baseContacts = await db.contacts.toArray();
|
||||
// set the existing contacts, keyed by DID, if they exist in contactsImporting
|
||||
for (let i = 0; i < this.contactsImporting.length; i++) {
|
||||
const contactIn = this.contactsImporting[i];
|
||||
const existingContact = baseContacts.find(
|
||||
(contact) => contact.did === contactIn.did,
|
||||
);
|
||||
if (existingContact) {
|
||||
this.contactsExisting[contactIn.did] = existingContact;
|
||||
|
||||
const differences: Record<string, { new: string; old: string }> = {};
|
||||
Object.keys(contactIn).forEach((key) => {
|
||||
if (contactIn[key] !== existingContact[key]) {
|
||||
differences[key] = {
|
||||
old: existingContact[key],
|
||||
new: contactIn[key],
|
||||
};
|
||||
}
|
||||
});
|
||||
this.contactDifferences[contactIn.did] = differences;
|
||||
if (R.isEmpty(differences)) {
|
||||
this.sameCount++;
|
||||
}
|
||||
} else {
|
||||
// automatically import new data
|
||||
this.contactsSelected[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async importContacts() {
|
||||
this.importing = true;
|
||||
let importedCount = 0,
|
||||
updatedCount = 0;
|
||||
for (let i = 0; i < this.contactsImporting.length; i++) {
|
||||
if (this.contactsSelected[i]) {
|
||||
const contact = this.contactsImporting[i];
|
||||
const existingContact = this.contactsExisting[contact.did];
|
||||
if (existingContact) {
|
||||
await db.contacts.update(contact.did, contact);
|
||||
updatedCount++;
|
||||
} else {
|
||||
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
|
||||
await db.contacts.add(R.clone(contact));
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.importing = false;
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Import Success",
|
||||
text:
|
||||
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
|
||||
(updatedCount ? ` ${updatedCount} updated.` : ""),
|
||||
},
|
||||
3000,
|
||||
);
|
||||
(this.$router as Router).push({ name: "contacts" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -24,6 +24,7 @@
|
||||
>
|
||||
<span class="text-red">Beware!</span>
|
||||
You aren't sharing your name, so quickly
|
||||
<br />
|
||||
<router-link
|
||||
:to="{ name: 'new-edit-account' }"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md"
|
||||
@@ -33,7 +34,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div @click="onCopyToClipboard()" v-if="activeDid" class="text-center">
|
||||
<div
|
||||
@click="onCopyUrlToClipboard()"
|
||||
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
|
||||
class="text-center"
|
||||
>
|
||||
<!--
|
||||
Play with display options: https://qr-code-styling.com/
|
||||
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
|
||||
@@ -44,7 +49,18 @@
|
||||
:dotsOptions="{ type: 'square' }"
|
||||
class="flex justify-center"
|
||||
/>
|
||||
<span> Click QR to copy your contact URL to your clipboard. </span>
|
||||
<span>
|
||||
Click this or QR code to copy your contact URL to your clipboard.
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="activeDid" class="text-center">
|
||||
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
|
||||
<span @click="onCopyDidToClipboard()" class="text-blue-500">
|
||||
Click here to copy your DID to your clipboard.
|
||||
</span>
|
||||
<span>
|
||||
Then give it to them so they can paste it in their list of People.
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-center" v-else>
|
||||
You have no identitifiers yet, so
|
||||
@@ -70,7 +86,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import { sha256 } from "ethereum-cryptography/sha256.js";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import * as R from "ramda";
|
||||
@@ -78,19 +95,25 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { QrcodeStream } from "vue-qrcode-reader";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { deriveAddress, nextDerivationPath, SimpleSigner } from "@/libs/crypto";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import {
|
||||
deriveAddress,
|
||||
getContactPayloadFromJwtUrl,
|
||||
nextDerivationPath,
|
||||
} from "@/libs/crypto";
|
||||
import {
|
||||
CONTACT_URL_PREFIX,
|
||||
createEndorserJwtForDid,
|
||||
ENDORSER_JWT_URL_LOCATION,
|
||||
isDid,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "@/libs/endorserServer";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -105,47 +128,29 @@ export default class ContactQRScanShow extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
hideRegisterPromptOnNewContact = false;
|
||||
isRegistered = false;
|
||||
qrValue = "";
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account: Account | undefined = R.find(
|
||||
(acc) => acc.did === activeDid,
|
||||
accounts,
|
||||
);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to show contact info with no identifier available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.givenName = settings?.firstName || "";
|
||||
this.activeDid = (settings?.activeDid as string) || "";
|
||||
this.apiServer = (settings?.apiServer as string) || "";
|
||||
this.givenName = (settings?.firstName as string) || "";
|
||||
this.hideRegisterPromptOnNewContact =
|
||||
!!settings?.hideRegisterPromptOnNewContact;
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
if (account) {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const publicKeyHex = identity.keys[0].publicKeyHex;
|
||||
const publicKeyHex = account.publicKeyHex;
|
||||
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
|
||||
|
||||
const newDerivPath = nextDerivationPath(account.derivationPath);
|
||||
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
|
||||
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
|
||||
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
|
||||
const nextPublicEncKeyHashBase64 =
|
||||
Buffer.from(nextPublicEncKeyHash).toString("base64");
|
||||
|
||||
const contactInfo = {
|
||||
iat: Date.now(),
|
||||
iss: this.activeDid,
|
||||
@@ -154,43 +159,259 @@ export default class ContactQRScanShow extends Vue {
|
||||
(settings?.firstName || "") +
|
||||
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
|
||||
publicEncKey,
|
||||
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
|
||||
profileImageUrl: settings?.profileImageUrl,
|
||||
registered: settings?.isRegistered,
|
||||
},
|
||||
};
|
||||
|
||||
const alg = undefined;
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex;
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
// create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(contactInfo, {
|
||||
alg: alg,
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
if (account?.mnemonic && account?.derivationPath) {
|
||||
const newDerivPath = nextDerivationPath(
|
||||
account.derivationPath as string,
|
||||
);
|
||||
const nextPublicHex = deriveAddress(
|
||||
account.mnemonic as string,
|
||||
newDerivPath,
|
||||
)[2];
|
||||
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
|
||||
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
|
||||
const nextPublicEncKeyHashBase64 =
|
||||
Buffer.from(nextPublicEncKeyHash).toString("base64");
|
||||
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
|
||||
}
|
||||
|
||||
const vcJwt = await createEndorserJwtForDid(this.activeDid, contactInfo);
|
||||
|
||||
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
|
||||
this.qrValue = viewPrefix + vcJwt;
|
||||
}
|
||||
}
|
||||
|
||||
danger(message: string, title: string = "Error", timeout = 5000) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: title,
|
||||
text: message,
|
||||
},
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param content is the result of a QR scan, an array with one item with a rawValue property
|
||||
*/
|
||||
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScanDetect(content: any) {
|
||||
if (content[0]?.rawValue) {
|
||||
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
|
||||
this.$router.push({ name: "contacts" });
|
||||
async onScanDetect(content: any) {
|
||||
const url = content[0]?.rawValue;
|
||||
if (url) {
|
||||
let newContact: Contact;
|
||||
try {
|
||||
const payload = getContactPayloadFromJwtUrl(url);
|
||||
if (!payload) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "No Contact Info",
|
||||
text: "The contact info could not be parsed.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
newContact = {
|
||||
did: payload.iss as string,
|
||||
name: payload.own.name,
|
||||
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
|
||||
profileImageUrl: payload.own.profileImageUrl,
|
||||
publicKeyBase64: payload.own.publicEncKey,
|
||||
registered: payload.own.registered,
|
||||
};
|
||||
if (!newContact.did) {
|
||||
this.danger("There is no DID.", "Incomplete Contact");
|
||||
return;
|
||||
}
|
||||
if (!isDid(newContact.did)) {
|
||||
this.danger("The DID must begin with 'did:'", "Invalid DID");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing QR info:", e);
|
||||
this.danger("Could not parse the QR info.", "Read Error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
await db.contacts.add(newContact);
|
||||
|
||||
let addedMessage;
|
||||
if (this.activeDid) {
|
||||
await this.setVisibility(newContact, true);
|
||||
newContact.seesMe = true; // didn't work inside setVisibility
|
||||
addedMessage =
|
||||
"They were added, and your activity is visible to them.";
|
||||
} else {
|
||||
addedMessage = "They were added.";
|
||||
}
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Contact Added",
|
||||
text: addedMessage,
|
||||
},
|
||||
3000,
|
||||
);
|
||||
|
||||
if (this.isRegistered) {
|
||||
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
|
||||
setTimeout(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Register",
|
||||
text: "Do you want to register them?",
|
||||
onCancel: async (stopAsking: boolean) => {
|
||||
if (stopAsking) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
hideRegisterPromptOnNewContact: stopAsking,
|
||||
});
|
||||
this.hideRegisterPromptOnNewContact = stopAsking;
|
||||
}
|
||||
},
|
||||
onNo: async (stopAsking: boolean) => {
|
||||
if (stopAsking) {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
hideRegisterPromptOnNewContact: stopAsking,
|
||||
});
|
||||
this.hideRegisterPromptOnNewContact = stopAsking;
|
||||
}
|
||||
},
|
||||
onYes: async () => {
|
||||
await this.register(newContact);
|
||||
},
|
||||
promptToStopAsking: true,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error saving contact info:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Contact Error",
|
||||
text: "Could not save contact info. Check if it already exists.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
type: "danger",
|
||||
title: "Invalid Contact QR Code",
|
||||
text: "No QR code detected with contact information.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setVisibility(contact: Contact, visibility: boolean) {
|
||||
const result = await setVisibilityUtil(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
db,
|
||||
contact,
|
||||
visibility,
|
||||
);
|
||||
if (result.error) {
|
||||
this.danger(result.error as string, "Error Setting Visibility");
|
||||
} else if (!result.success) {
|
||||
console.error("Got strange result from setting visibility:", result);
|
||||
}
|
||||
}
|
||||
|
||||
async register(contact: Contact) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "",
|
||||
title: "Registration submitted...",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
|
||||
try {
|
||||
const regResult = await register(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contact,
|
||||
);
|
||||
if (regResult.success) {
|
||||
contact.registered = true;
|
||||
db.contacts.update(contact.did, { registered: true });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Registration Success",
|
||||
text:
|
||||
(contact.name || "That unnamed person") + " has been registered.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Error",
|
||||
text:
|
||||
(regResult.error as string) ||
|
||||
"Something went wrong during registration.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when registering:", error);
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.response?.data?.error?.message) {
|
||||
userMessage = serverError.response.data.error.message;
|
||||
} else if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -201,15 +422,16 @@ export default class ContactQRScanShow extends Vue {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
type: "danger",
|
||||
title: "Invalid Scan",
|
||||
text: "The scan was invalid.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
onCopyToClipboard() {
|
||||
onCopyUrlToClipboard() {
|
||||
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.then(() => {
|
||||
@@ -225,5 +447,22 @@ export default class ContactQRScanShow extends Vue {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onCopyDidToClipboard() {
|
||||
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||
useClipboard()
|
||||
.copy(this.activeDid)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Copied",
|
||||
text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -65,17 +65,19 @@
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Look Up Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
value="Look Up Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
729
src/views/DIDView.vue
Normal file
@@ -0,0 +1,729 @@
|
||||
<template>
|
||||
<QuickNav selected="Contacts" />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
Identifier Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Identity Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{ contact?.name || "(no name)" }}
|
||||
<button
|
||||
@click="
|
||||
contactEdit = true;
|
||||
contactNewName = contact.name || '';
|
||||
"
|
||||
title="Edit"
|
||||
>
|
||||
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
</h2>
|
||||
<button
|
||||
@click="showDidDetails = !showDidDetails"
|
||||
class="ml-2 mr-2 mt-4"
|
||||
>
|
||||
Details
|
||||
<fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
|
||||
<fa v-else icon="chevron-down" class="text-blue-400" />
|
||||
</button>
|
||||
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
||||
<pre
|
||||
v-if="showDidDetails"
|
||||
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
|
||||
>{{ contactYaml }}</pre
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-center mt-4">
|
||||
<span v-if="contact?.profileImageUrl" class="flex justify-between">
|
||||
<EntityIcon
|
||||
:icon-size="96"
|
||||
:profileImageUrl="contact?.profileImageUrl"
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded"
|
||||
@click="showLargeIdenticonUrl = contact?.profileImageUrl"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between mt-4">
|
||||
<div class="flex items-center">
|
||||
<div v-if="activeDid" class="flex justify-between">
|
||||
<div>
|
||||
<button
|
||||
v-if="contact?.seesMe && contact.did !== activeDid"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="confirmSetVisibility(contact, false)"
|
||||
title="They can see you"
|
||||
>
|
||||
<fa icon="eye" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!contact?.seesMe && contact?.did !== activeDid"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="confirmSetVisibility(contact, true)"
|
||||
title="They cannot see you"
|
||||
>
|
||||
<fa icon="eye-slash" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="eye" class="text-white mx-2.5" />
|
||||
|
||||
<button
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
@click="checkVisibility(contact)"
|
||||
title="Check Visibility"
|
||||
v-if="contact?.did !== activeDid"
|
||||
>
|
||||
<fa icon="rotate" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="rotate" class="text-white mx-2.5" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="confirmRegister(contact)"
|
||||
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
v-if="contact?.did !== activeDid"
|
||||
title="Registration"
|
||||
>
|
||||
<fa
|
||||
v-if="contact?.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
/>
|
||||
<fa v-else icon="person-circle-question" class="fa-fw" />
|
||||
</button>
|
||||
<!-- otherwise it's this user so hide it -->
|
||||
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="confirmDeleteContact(contact)"
|
||||
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
|
||||
title="Delete"
|
||||
>
|
||||
<fa icon="trash-can" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!contact?.profileImageUrl">
|
||||
<div>Auto-Generated Icon</div>
|
||||
<div class="flex justify-center">
|
||||
<EntityIcon
|
||||
:entityId="viewingDid"
|
||||
:iconSize="64"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
@click="showLargeIdenticonId = viewingDid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
|
||||
class="fixed z-[100] top-0 inset-x-0 w-full"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="showLargeIdenticonId"
|
||||
:iconSize="512"
|
||||
:profileImageUrl="showLargeIdenticonUrl"
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
@click="
|
||||
showLargeIdenticonId = undefined;
|
||||
showLargeIdenticonUrl = undefined;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="contactEdit" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Name"
|
||||
v-model="contactNewName"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickSaveName(contactNewName)"
|
||||
>
|
||||
<fa icon="save" />
|
||||
</button>
|
||||
<span class="inline-block w-2" />
|
||||
<button
|
||||
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
|
||||
@click="onClickCancelName()"
|
||||
>
|
||||
<fa icon="ban" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div
|
||||
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||
v-if="isLoading"
|
||||
>
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||
</div>
|
||||
<!-- Results List -->
|
||||
<div v-if="claims.length > 0" class="mt-4">
|
||||
<div class="text-l font-bold text-center">Claims That Involve Them</div>
|
||||
</div>
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul>
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="claim in claims"
|
||||
:key="claim.handleId"
|
||||
>
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<span class="col-span-2">
|
||||
{{ claim.issuedAt.substring(0, 10) }}
|
||||
</span>
|
||||
<span class="col-span-2">
|
||||
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }}
|
||||
</span>
|
||||
<span class="col-span-2">
|
||||
{{ claimAmount(claim) }}
|
||||
</span>
|
||||
<span class="col-span-5">
|
||||
{{ claimDescription(claim) }}
|
||||
</span>
|
||||
<span class="col-span-1">
|
||||
<a @click="onClickLoadClaim(claim.id)" class="cursor-pointer">
|
||||
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
|
||||
<div
|
||||
v-if="!isLoading && claims.length === 0"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<span>They are in no claims visible to you.</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import * as yaml from "js-yaml";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import {
|
||||
capitalizeAndInsertSpacesBeforeCaps,
|
||||
didInfoForContact,
|
||||
displayAmount,
|
||||
getHeaders,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
GiveVerifiableCredential,
|
||||
OfferVerifiableCredential,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
InfiniteScroll,
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class DIDView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
yaml = yaml;
|
||||
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
|
||||
contact: Contact;
|
||||
contactEdit = false;
|
||||
contactNewName?: string;
|
||||
contactYaml = "";
|
||||
hitEnd = false;
|
||||
isLoading = false;
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
showDidDetails = false;
|
||||
showLargeIdenticonId?: string;
|
||||
showLargeIdenticonUrl?: string;
|
||||
viewingDid?: string;
|
||||
|
||||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||
didInfoForContact = didInfoForContact;
|
||||
displayAmount = displayAmount;
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = (settings?.activeDid as string) || "";
|
||||
this.apiServer = (settings?.apiServer as string) || "";
|
||||
|
||||
const pathParam = window.location.pathname.substring("/did/".length);
|
||||
let theContact: Contact | undefined;
|
||||
if (pathParam) {
|
||||
this.viewingDid = decodeURIComponent(pathParam);
|
||||
theContact = await db.contacts.get(this.viewingDid);
|
||||
}
|
||||
if (theContact) {
|
||||
this.contact = theContact;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "No valid claim ID was provided.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.contactYaml = yaml.dump(this.contact);
|
||||
await this.loadClaimsAbout();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
async loadMoreData(payload: boolean) {
|
||||
if (this.claims.length > 0 && !this.hitEnd && payload) {
|
||||
this.loadClaimsAbout();
|
||||
}
|
||||
}
|
||||
|
||||
// prompt with confirmation if they want to delete a contact
|
||||
confirmDeleteContact(contact: Contact) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Delete",
|
||||
text:
|
||||
"Are you sure you want to remove " +
|
||||
libsUtil.nameForContact(contact, false) +
|
||||
" from your contact list?",
|
||||
onYes: async () => {
|
||||
await this.deleteContact(contact);
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteContact(contact: Contact) {
|
||||
await db.open();
|
||||
await db.contacts.delete(contact.did);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Deleted",
|
||||
text: "Contact has been removed.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
(this.$router as Router).push({ name: "contacts" });
|
||||
}
|
||||
|
||||
// confirm to register a new contact
|
||||
async confirmRegister(contact: Contact) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Register",
|
||||
text:
|
||||
"Are you sure you want to register " +
|
||||
libsUtil.nameForContact(this.contact, false) +
|
||||
(contact.registered
|
||||
? " -- especially since they are already marked as registered"
|
||||
: "") +
|
||||
"?",
|
||||
onYes: async () => {
|
||||
await this.register(contact);
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async register(contact: Contact) {
|
||||
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||
|
||||
try {
|
||||
const regResult = await register(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contact,
|
||||
);
|
||||
if (regResult.success) {
|
||||
contact.registered = true;
|
||||
await db.contacts.update(contact.did, { registered: true });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Registration Success",
|
||||
text:
|
||||
(contact.name || "That unnamed person") + " has been registered.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Error",
|
||||
text:
|
||||
(regResult.error as string) ||
|
||||
"Something went wrong during registration.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when registering:", error);
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.response?.data?.error?.message) {
|
||||
userMessage = serverError.response.data.error.message;
|
||||
} else if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async loadClaimsAbout() {
|
||||
if (!this.viewingDid) {
|
||||
console.error("This should never be called without a DID.");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid);
|
||||
let postfix = "";
|
||||
if (this.claims.length > 0) {
|
||||
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await fetch(
|
||||
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await getHeaders(this.activeDid),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
console.error("Problem with full search:", details);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `There was a problem accessing the server. Try again later.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
this.claims = this.claims.concat(results.data);
|
||||
this.hitEnd = !results.hitLimit;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.error("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: e.userMessage || "There was a problem retrieving claims.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
public claimAmount(claim: GenericVerifiableCredential) {
|
||||
if (claim.claimType === "GiveAction") {
|
||||
const giveClaim = claim.claim as GiveVerifiableCredential;
|
||||
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
|
||||
return displayAmount(
|
||||
giveClaim.object.unitCode,
|
||||
giveClaim.object.amountOfThisGood,
|
||||
);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
} else if (claim.claimType === "Offer") {
|
||||
const offerClaim = claim.claim as OfferVerifiableCredential;
|
||||
if (
|
||||
offerClaim.includesObject?.unitCode &&
|
||||
offerClaim.includesObject?.amountOfThisGood
|
||||
) {
|
||||
return displayAmount(
|
||||
offerClaim.includesObject.unitCode,
|
||||
offerClaim.includesObject.amountOfThisGood,
|
||||
);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
claimDescription(claim: GenericVerifiableCredential) {
|
||||
return claim.claim.name || claim.claim.description || "";
|
||||
}
|
||||
|
||||
private async onClickCancelName() {
|
||||
this.contactEdit = false;
|
||||
}
|
||||
|
||||
private async onClickSaveName(newName: string) {
|
||||
this.contact.name = newName;
|
||||
return db.contacts
|
||||
.update(this.contact.did, { name: newName })
|
||||
.then(() => (this.contactEdit = false));
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async confirmSetVisibility(contact: Contact, visibility: boolean) {
|
||||
const visibilityPrompt = visibility
|
||||
? "Are you sure you want to make your activity visible to them?"
|
||||
: "Are you sure you want to hide all your activity from them?";
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Set Visibility",
|
||||
text: visibilityPrompt,
|
||||
onYes: async () => {
|
||||
const success = await this.setVisibility(contact, visibility, true);
|
||||
if (success) {
|
||||
contact.seesMe = visibility; // didn't work inside setVisibility
|
||||
}
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async setVisibility(
|
||||
contact: Contact,
|
||||
visibility: boolean,
|
||||
showSuccessAlert: boolean,
|
||||
) {
|
||||
const result = await setVisibilityUtil(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
db,
|
||||
contact,
|
||||
visibility,
|
||||
);
|
||||
if (result.success) {
|
||||
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
|
||||
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
|
||||
if (showSuccessAlert) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Visibility Set",
|
||||
text:
|
||||
(contact.name || "That user") +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
console.error("Got strange result from setting visibility:", result);
|
||||
const message =
|
||||
(result.error as string) || "Could not set visibility on the server.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Visibility",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// note that this is also in ContactView.vue
|
||||
async checkVisibility(contact: Contact) {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/report/canDidExplicitlySeeMe?did=" +
|
||||
encodeURIComponent(contact.did);
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
if (!headers["Authorization"]) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "No Identity",
|
||||
text: "There is no identity to use to check visibility.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
const visibility = resp.data;
|
||||
contact.seesMe = visibility;
|
||||
//console.log("Visi check:", visibility, contact.seesMe, contact.did);
|
||||
await db.contacts.update(contact.did, { seesMe: visibility });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Visibility Refreshed",
|
||||
text:
|
||||
libsUtil.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
console.error("Got bad server response checking visibility:", resp);
|
||||
const message = resp.data.error?.message || "Got bad server response.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Checking Visibility",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Caught error from request to check visibility:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Checking Visibility",
|
||||
text: "Check connectivity and try again.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Discover
|
||||
Discover Projects
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
@@ -37,7 +37,7 @@
|
||||
isRemoteActive = false;
|
||||
searchLocal();
|
||||
"
|
||||
v-bind:class="computedLocalTabClassNames()"
|
||||
v-bind:class="computedLocalTabStyleClassNames()"
|
||||
>
|
||||
Nearby
|
||||
<span
|
||||
@@ -57,7 +57,7 @@
|
||||
isLocalActive = false;
|
||||
searchAll();
|
||||
"
|
||||
v-bind:class="computedRemoteTabClassNames()"
|
||||
v-bind:class="computedRemoteTabStyleClassNames()"
|
||||
>
|
||||
Anywhere
|
||||
<span
|
||||
@@ -74,7 +74,7 @@
|
||||
<div v-if="isLocalActive">
|
||||
<div>
|
||||
<button
|
||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="$router.push({ name: 'search-area' })"
|
||||
>
|
||||
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul>
|
||||
<ul id="listDiscoverResults">
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="project in projects"
|
||||
@@ -100,14 +100,15 @@
|
||||
>
|
||||
<a
|
||||
@click="onClickLoadProject(project.handleId)"
|
||||
class="block py-4 flex gap-4"
|
||||
class="block py-4 flex gap-4 cursor-pointer"
|
||||
>
|
||||
<div class="w-12">
|
||||
<div>
|
||||
<ProjectIcon
|
||||
:entityId="project.handleId"
|
||||
:iconSize="48"
|
||||
class="block border border-slate-300 rounded-md"
|
||||
></ProjectIcon>
|
||||
:imageUrl="project.image"
|
||||
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
@@ -128,22 +129,20 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { didInfo, PlanData } from "@/libs/endorserServer";
|
||||
import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
InfiniteScroll,
|
||||
ProjectIcon,
|
||||
QuickNav,
|
||||
@@ -204,30 +203,6 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
public async buildHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async searchAll(beforeId?: string) {
|
||||
this.resetCounts();
|
||||
|
||||
@@ -248,7 +223,7 @@ export default class DiscoverView extends Vue {
|
||||
this.apiServer + "/api/v2/report/plans?" + queryParams,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
headers: await getHeaders(this.activeDid),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -273,8 +248,15 @@ export default class DiscoverView extends Vue {
|
||||
const plans: PlanData[] = results.data;
|
||||
if (plans) {
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
||||
const { name, description, handleId, image, issuerDid, rowid } = plan;
|
||||
this.projects.push({
|
||||
name,
|
||||
description,
|
||||
handleId,
|
||||
image,
|
||||
issuerDid,
|
||||
rowid,
|
||||
});
|
||||
}
|
||||
this.remoteCount = this.projects.length;
|
||||
} else {
|
||||
@@ -283,6 +265,8 @@ export default class DiscoverView extends Vue {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.error("Error with feed load:", e);
|
||||
// this sometimes gives different information
|
||||
console.error("Error with feed load (error added): " + e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -331,7 +315,7 @@ export default class DiscoverView extends Vue {
|
||||
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
headers: await getHeaders(this.activeDid),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -413,10 +397,10 @@ export default class DiscoverView extends Vue {
|
||||
const route = {
|
||||
path: "/project/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
public computedLocalTabClassNames() {
|
||||
public computedLocalTabStyleClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
"py-3": true,
|
||||
@@ -434,7 +418,7 @@ export default class DiscoverView extends Vue {
|
||||
};
|
||||
}
|
||||
|
||||
public computedRemoteTabClassNames() {
|
||||
public computedRemoteTabStyleClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
"py-3": true,
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="cancel()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
|
||||
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ message }} {{ giverName || "somebody not named" }}
|
||||
</h1>
|
||||
<textarea
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What was received"
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row justify-center">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="amountInput === '0' ? null : decrement()"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||
v-model="amountInput"
|
||||
/>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="increment()"
|
||||
>
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<span v-if="imageUrl" class="flex justify-between">
|
||||
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
|
||||
<img :src="imageUrl" class="h-24 rounded-xl" />
|
||||
</a>
|
||||
<fa
|
||||
icon="trash-can"
|
||||
@click="confirmDeleteImage"
|
||||
class="text-red-500 fa-fw ml-8 mt-10"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>
|
||||
<fa
|
||||
icon="camera"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||
@click="openPhotoDialog"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<GiftedPhotoDialog ref="photoDialog" />
|
||||
|
||||
<div v-if="projectId" class="mt-4">
|
||||
<fa
|
||||
icon="check"
|
||||
class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
/>
|
||||
<label class="text-sm">This is given to a project</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!projectId" class="mt-4">
|
||||
<input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" />
|
||||
<label class="text-sm">Given to you</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
|
||||
<label class="text-sm">Trade (not a gift)</label>
|
||||
</div>
|
||||
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { createAndSubmitGive } from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
GiftedDialog,
|
||||
GiftedPhotoDialog,
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class GiftedDetails extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
description = "";
|
||||
givenToUser = false;
|
||||
giverDid: string | undefined;
|
||||
giverName = "";
|
||||
imageUrl = "";
|
||||
isTrade = false;
|
||||
message = "";
|
||||
offerId = "";
|
||||
projectId = "";
|
||||
unitCode = "HUR";
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async mounted() {
|
||||
this.amountInput = this.$route.query.amountInput as string;
|
||||
this.description = this.$route.query.description as string;
|
||||
this.giverDid = this.$route.query.giverDid as string;
|
||||
this.giverName = this.$route.query.giverName as string;
|
||||
this.message = this.$route.query.message as string;
|
||||
this.offerId = this.$route.query.offerId as string;
|
||||
this.projectId = this.$route.query.projectId as string;
|
||||
this.unitCode = this.$route.query.unitCode as string;
|
||||
|
||||
this.imageUrl = localStorage.getItem("imageUrl") || "";
|
||||
|
||||
this.givenToUser = !this.projectId;
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.error("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||
const index = units.indexOf(this.unitCode);
|
||||
this.unitCode = units[(index + 1) % units.length];
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
openPhotoDialog() {
|
||||
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
|
||||
this.imageUrl = imgUrl;
|
||||
});
|
||||
}
|
||||
|
||||
confirmDeleteImage() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Are you sure you want to delete the image?",
|
||||
text: "",
|
||||
onYes: this.deleteImage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteImage() {
|
||||
if (!this.imageUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||
const token = await accessToken(identity);
|
||||
const response = await this.axios.delete(
|
||||
DEFAULT_IMAGE_API_SERVER +
|
||||
"/image/" +
|
||||
encodeURIComponent(this.imageUrl),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.status === 204) {
|
||||
// don't bother with a notification
|
||||
// (either they'll simply continue or they're canceling and going back)
|
||||
} else {
|
||||
console.error("Non-success deleting image:", response);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem deleting the image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
// keep the imageUrl in localStorage so the user can try again if they want
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.imageUrl = "";
|
||||
} catch (error) {
|
||||
console.error("Error deleting image:", error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).response.status === 404) {
|
||||
console.log("The image was already deleted:", error);
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.imageUrl = "";
|
||||
|
||||
// it already doesn't exist so we won't say anything to the user
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error deleting the image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the give...",
|
||||
title: "",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordGive();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
* @param unitCode may be omitted, defaults to "HUR"
|
||||
*/
|
||||
public async recordGive() {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.description && !this.amountInput) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${
|
||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
this.giverDid,
|
||||
this.givenToUser ? this.activeDid : undefined,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.projectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
this.imageUrl,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.$router.back();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Error with give recordation caught:", error);
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
752
src/views/GiftedDetailsView.vue
Normal file
@@ -0,0 +1,752 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back -->
|
||||
<div
|
||||
v-if="!hideBackButton"
|
||||
class="text-lg text-center font-light relative px-7"
|
||||
>
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="cancelBack()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
|
||||
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
<span>From {{ giverName }}</span>
|
||||
<span>
|
||||
to
|
||||
{{
|
||||
givenToProject
|
||||
? projectName
|
||||
: givenToRecipient
|
||||
? recipientName
|
||||
: "someone unidentified"
|
||||
}}</span
|
||||
>
|
||||
</h1>
|
||||
<textarea
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What was received"
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row justify-center">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="amountInput === '0' ? null : decrement()"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||
v-model="amountInput"
|
||||
/>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="increment()"
|
||||
>
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4" data-testid="imagery">
|
||||
<span v-if="imageUrl" class="flex justify-between">
|
||||
<a :href="imageUrl" target="_blank">
|
||||
<img :src="imageUrl" class="h-24 rounded-xl" />
|
||||
</a>
|
||||
<fa
|
||||
icon="trash-can"
|
||||
@click="confirmDeleteImage"
|
||||
class="text-red-500 fa-fw ml-8 mt-10"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>
|
||||
<fa
|
||||
icon="camera"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||
@click="openImageDialog"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
|
||||
<div class="h-7 mt-4 flex">
|
||||
<input
|
||||
v-if="projectId && !givenToRecipient"
|
||||
type="checkbox"
|
||||
class="h-6 w-6 mr-2"
|
||||
v-model="givenToProject"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="square"
|
||||
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
@click="notifyUserOfProject()"
|
||||
/>
|
||||
<label class="text-sm mt-1">
|
||||
{{
|
||||
projectId
|
||||
? "This was given to " + projectName
|
||||
: "No project was chosen"
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="h-7 mt-4 flex">
|
||||
<input
|
||||
v-if="recipientDid && !givenToProject"
|
||||
type="checkbox"
|
||||
class="h-6 w-6 mr-2"
|
||||
v-model="givenToRecipient"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="square"
|
||||
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
@click="notifyUserOfRecipient()"
|
||||
/>
|
||||
<label class="text-sm mt-1">
|
||||
{{
|
||||
recipientDid
|
||||
? "This was given to " + recipientName
|
||||
: "No recipient was chosen."
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex">
|
||||
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
|
||||
<label class="text-sm mt-1">This was a trade (not a gift)</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'claim-add-raw',
|
||||
query: {
|
||||
claim: constructGiveParam(),
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Edit & Submit Raw
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
<fa
|
||||
icon="circle-info"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
@click="explainData()"
|
||||
/>
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
editAndSubmitGive,
|
||||
GenericCredWrapper,
|
||||
getHeaders,
|
||||
getPlanFromCache,
|
||||
GiveVerifiableCredential,
|
||||
hydrateGive,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
ImageMethodDialog,
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class GiftedDetails extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
description = "";
|
||||
destinationPathAfter = "";
|
||||
givenToProject = false;
|
||||
givenToRecipient = false;
|
||||
giverDid: string | undefined;
|
||||
giverName = "";
|
||||
hideBackButton = false;
|
||||
imageUrl = "";
|
||||
isTrade = false;
|
||||
message = "";
|
||||
offerId = "";
|
||||
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
|
||||
projectId = "";
|
||||
projectName = "a project";
|
||||
recipientDid = "";
|
||||
recipientName = "";
|
||||
unitCode = "HUR";
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
|
||||
? (JSON.parse(
|
||||
(this.$route as Router).query["prevCredToEdit"],
|
||||
) as GenericCredWrapper<GiveVerifiableCredential>)
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Retrieval Error",
|
||||
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
|
||||
},
|
||||
6000,
|
||||
);
|
||||
}
|
||||
|
||||
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood;
|
||||
this.amountInput =
|
||||
(this.$route as Router).query["amountInput"] ||
|
||||
(prevAmount ? String(prevAmount) : "") ||
|
||||
this.amountInput;
|
||||
this.description =
|
||||
(this.$route as Router).query["description"] ||
|
||||
this.prevCredToEdit?.claim?.description ||
|
||||
this.description;
|
||||
this.destinationPathAfter = (this.$route as Router).query[
|
||||
"destinationPathAfter"
|
||||
];
|
||||
this.giverDid = ((this.$route as Router).query["giverDid"] ||
|
||||
this.prevCredToEdit?.claim?.agent?.identifier ||
|
||||
this.giverDid) as string;
|
||||
this.giverName =
|
||||
((this.$route as Router).query["giverName"] as string) || "";
|
||||
this.hideBackButton =
|
||||
(this.$route as Router).query["hideBackButton"] === "true";
|
||||
this.message = ((this.$route as Router).query["message"] as string) || "";
|
||||
|
||||
// find any offer ID
|
||||
const fulfills = this.prevCredToEdit?.claim?.fulfills;
|
||||
const fulfillsArray = Array.isArray(fulfills)
|
||||
? fulfills
|
||||
: fulfills
|
||||
? [fulfills]
|
||||
: [];
|
||||
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer");
|
||||
this.offerId = ((this.$route as Router).query["offerId"] ||
|
||||
offer?.identifier ||
|
||||
this.offerId) as string;
|
||||
|
||||
// find any project ID
|
||||
const project = fulfillsArray.find((rec) => rec["@type"] === "PlanAction");
|
||||
this.projectId = ((this.$route as Router).query["projectId"] ||
|
||||
project?.identifier ||
|
||||
this.projectId) as string;
|
||||
|
||||
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
|
||||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
|
||||
this.recipientName =
|
||||
((this.$route as Router).query["recipientName"] as string) || "";
|
||||
this.unitCode = ((this.$route as Router).query["unitCode"] ||
|
||||
this.prevCredToEdit?.claim?.object?.unitCode ||
|
||||
this.unitCode) as string;
|
||||
|
||||
this.imageUrl =
|
||||
((this.$route as Router).query["imageUrl"] as string) ||
|
||||
this.prevCredToEdit?.claim?.image ||
|
||||
localStorage.getItem("imageUrl") ||
|
||||
this.imageUrl;
|
||||
|
||||
// this is an endpoint for sharing project info to highlight something given
|
||||
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
|
||||
if ((this.$route as Router).query["shareTitle"]) {
|
||||
this.description =
|
||||
((this.$route as Router).query["shareTitle"] as string) +
|
||||
(this.description ? "\n" + this.description : "");
|
||||
}
|
||||
if ((this.$route as Router).query["shareText"]) {
|
||||
this.description =
|
||||
(this.description ? this.description + "\n" : "") +
|
||||
((this.$route as Router).query["shareText"] as string);
|
||||
}
|
||||
if ((this.$route as Router).query["shareUrl"]) {
|
||||
this.imageUrl = (this.$route as Router).query["shareUrl"] as string;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
|
||||
let allContacts: Contact[] = [];
|
||||
let allMyDids: string[] = [];
|
||||
if (
|
||||
(this.giverDid && !this.giverName) ||
|
||||
(this.recipientDid && !this.recipientName)
|
||||
) {
|
||||
allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
allMyDids = allAccounts.map((acc) => acc.did);
|
||||
if (this.giverDid && !this.giverName) {
|
||||
this.giverName = didInfo(
|
||||
this.giverDid,
|
||||
this.activeDid,
|
||||
allMyDids,
|
||||
allContacts,
|
||||
);
|
||||
}
|
||||
if (this.recipientDid && !this.recipientName) {
|
||||
this.recipientName = didInfo(
|
||||
this.recipientDid,
|
||||
this.activeDid,
|
||||
allMyDids,
|
||||
allContacts,
|
||||
);
|
||||
}
|
||||
}
|
||||
// these should be functions but something's wrong with the syntax in the <> conditional
|
||||
this.givenToProject = !!this.projectId;
|
||||
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.error("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.projectId) {
|
||||
// console.log("Getting project name from cache", this.projectId);
|
||||
const project = await getPlanFromCache(
|
||||
this.projectId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
this.projectName = project?.name
|
||||
? "the project: " + project.name
|
||||
: "a project";
|
||||
}
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||
const index = units.indexOf(this.unitCode);
|
||||
this.unitCode = units[(index + 1) % units.length];
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
|
||||
cancelBack() {
|
||||
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
openImageDialog() {
|
||||
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
|
||||
this.imageUrl = imgUrl;
|
||||
}, "GiveAction");
|
||||
}
|
||||
|
||||
confirmDeleteImage() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Are you sure you want to delete the image?",
|
||||
text: "",
|
||||
onYes: this.deleteImage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteImage() {
|
||||
if (!this.imageUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const response = await this.axios.delete(
|
||||
DEFAULT_IMAGE_API_SERVER +
|
||||
"/image/" +
|
||||
encodeURIComponent(this.imageUrl),
|
||||
{ headers },
|
||||
);
|
||||
if (response.status === 204) {
|
||||
// don't bother with a notification
|
||||
// (either they'll simply continue or they're canceling and going back)
|
||||
} else {
|
||||
console.error("Problem deleting image:", response);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem deleting the image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
// keep the imageUrl in localStorage so the user can try again if they want
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.imageUrl = "";
|
||||
} catch (error) {
|
||||
console.error("Error deleting image:", error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).response.status === 404) {
|
||||
console.log("The image was already deleted:", error);
|
||||
|
||||
localStorage.removeItem("imageUrl");
|
||||
this.imageUrl = "";
|
||||
|
||||
// it already doesn't exist so we won't say anything to the user
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error deleting the image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a give.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parseFloat(this.amountInput) < 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
text: "You may not send a negative number.",
|
||||
title: "",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.description && !parseFloat(this.amountInput)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${
|
||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the give...",
|
||||
title: "",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordGive();
|
||||
}
|
||||
|
||||
notifyUserOfProject() {
|
||||
if (!this.projectId) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a project, you must open this page through a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
// must be because givenToRecipient is true
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "You cannot assign both to a project and to a recipient.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyUserOfRecipient() {
|
||||
if (!this.recipientDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a recipient, you must open this page from a contact.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
// must be because givenToProject is true
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "You cannot assign both to a recipient and to a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
* @param unitCode may be omitted, defaults to "HUR"
|
||||
*/
|
||||
public async recordGive() {
|
||||
try {
|
||||
const recipientDid = this.givenToRecipient
|
||||
? this.recipientDid
|
||||
: undefined;
|
||||
const projectId = this.givenToProject ? this.projectId : undefined;
|
||||
let result;
|
||||
if (this.prevCredToEdit) {
|
||||
// don't create from a blank one in case some properties were set from a different interface
|
||||
result = await editAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.prevCredToEdit,
|
||||
this.activeDid,
|
||||
this.giverDid,
|
||||
recipientDid,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
projectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
this.imageUrl,
|
||||
);
|
||||
} else {
|
||||
result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
this.giverDid,
|
||||
recipientDid,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
projectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
this.imageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
localStorage.removeItem("imageUrl");
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Error with give recordation caught:", error);
|
||||
const errorMessage =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
constructGiveParam() {
|
||||
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
|
||||
const projectId = this.givenToProject ? this.projectId : undefined;
|
||||
const giveClaim = hydrateGive(
|
||||
this.prevCredToEdit?.claim as GiveVerifiableCredential,
|
||||
this.giverDid,
|
||||
recipientDid,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
projectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
this.imageUrl,
|
||||
this.prevCredToEdit?.id as string,
|
||||
);
|
||||
const claimStr = JSON.stringify(giveClaim);
|
||||
return claimStr;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Data Sharing",
|
||||
text: libsUtil.PRIVACY_MESSAGE,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -301,12 +301,13 @@ import { sendTestThroughPushServer } from "@/libs/util";
|
||||
export default class HelpNotificationsView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
subscription: PushSubscription | null = null;
|
||||
subscriptionJSON?: PushSubscriptionJSON;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
this.subscription = await registration.pushManager.getSubscription();
|
||||
const fullSub = await registration.pushManager.getSubscription();
|
||||
this.subscriptionJSON = fullSub?.toJSON();
|
||||
} catch (error) {
|
||||
console.error("Mount error:", error);
|
||||
}
|
||||
@@ -315,13 +316,13 @@ export default class HelpNotificationsView extends Vue {
|
||||
alertWebPushSubscription() {
|
||||
console.log(
|
||||
"Web push subscription:",
|
||||
JSON.parse(JSON.stringify(this.subscription)), // gives more info than plain console logging
|
||||
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
|
||||
);
|
||||
alert(JSON.stringify(this.subscription));
|
||||
alert(JSON.stringify(this.subscriptionJSON));
|
||||
}
|
||||
|
||||
async sendTestWebPushMessage(skipFilter: boolean = false) {
|
||||
if (!this.subscription) {
|
||||
if (!this.subscriptionJSON) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -336,7 +337,7 @@ export default class HelpNotificationsView extends Vue {
|
||||
}
|
||||
|
||||
try {
|
||||
await sendTestThroughPushServer(this.subscription, skipFilter);
|
||||
await sendTestThroughPushServer(this.subscriptionJSON, skipFilter);
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
69
src/views/HelpOnboardingView.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<!-- Don't include nav buttons since this is shown in a different window. -->
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Don't include 'back' button since this is shown in a different window. -->
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Time Safari Onboarding Instructions
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable prettier/prettier -->
|
||||
<div class="ml-4">
|
||||
<h1 class="font-bold text-xl">Install</h1>
|
||||
<div>
|
||||
<p>
|
||||
1) Have them visit TimeSafari.app in a browser, preferably Chrome or Safari.
|
||||
</p>
|
||||
<p>
|
||||
2) Have them "Install" the site to their desktop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1 class="font-bold text-xl">Add Contact & Register</h1>
|
||||
<div>
|
||||
<p>
|
||||
3) Have them follow their yellow prompts.
|
||||
</p>
|
||||
<p>
|
||||
4) Add them to your contacts <fa icon="users" />
|
||||
</p>
|
||||
<p>
|
||||
5) Register them <fa icon="person-circle-question" />
|
||||
</p>
|
||||
<p>
|
||||
6) Add yourself to their contacts <fa icon="users" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1 class="font-bold text-xl">Enable Notifications</h1>
|
||||
<div>
|
||||
<p>
|
||||
7) Enable notifications from <fa icon="circle-user" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1 class="font-bold text-xl">Discuss Backups</h1>
|
||||
<div>
|
||||
<p>
|
||||
8) Exporting backups <fa icon="circle-user" /> are important if they lose their phone --- especially for the Identifier Seed!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- eslint enable -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {}
|
||||
</script>
|
||||
@@ -24,49 +24,63 @@
|
||||
<!-- eslint-disable prettier/prettier -->
|
||||
<div>
|
||||
<p>
|
||||
This app is a window into data that you and your friends own, focused on
|
||||
gifts and collaboration.
|
||||
This app focuses on gifts & gratitude, using them to build cool things with your network.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What is the idea here?</h2>
|
||||
<p>
|
||||
We are building networks of people who want to grow a giving society.
|
||||
First of all, you can see what people have given, and also recognize
|
||||
gifts you've seen, in a way that leaves a permanent record -- one that
|
||||
came from you, and the recipient can prove it was for them. This is
|
||||
First of all, let's build gratitude: see what people have given, and recognize
|
||||
gifts you've seen. This is done in a way that leaves a permanent record -- one that
|
||||
came from you, and one that the recipient can prove it was for them. This is
|
||||
personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and selectively show off their contributions
|
||||
and network.
|
||||
</p>
|
||||
<p>
|
||||
You can show giving and also offer help to ideas, based on others'
|
||||
willingness to help out, too. You can record your own ideas and invite
|
||||
others to collaborate.
|
||||
With this, you highlight giving and also offer help --
|
||||
which could be conditional on others' willingness to help, too.
|
||||
You can record your own ideas and invite others to collaborate.
|
||||
</p>
|
||||
<p>
|
||||
This app uses the power of cryptography to build a reputation, recording
|
||||
activity that you can share at your discretion. You put some activity
|
||||
public, but your sensitive information is not shared with anyone,
|
||||
including our services. This is in contrast to Meta and Google, who hold
|
||||
your data and allow you use it. Those services are useful, but they have
|
||||
the control; this app gives you the control.
|
||||
public, but these services don't share your ID with others without explicit consent.
|
||||
This is in contrast to Meta and Google, who hold
|
||||
your data and allow you use it while they manage sharing...
|
||||
those services are useful but they have the control, whereas this app gives you the control.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I get started?</h2>
|
||||
<p>
|
||||
You need someone to register you -- usually the person who told you
|
||||
about this app, on the Contacts
|
||||
<fa icon="users" class="fa-fw" /> page. After they register you, you can
|
||||
You need someone to register you, like the person who told you
|
||||
about this app, on the Contacts <fa icon="users" class="fa-fw" /> page.
|
||||
If you heard about this from our outreach, feel free to contact us (below) for a chat.
|
||||
After someone registers you, you can
|
||||
select any contact on the home page (or "anonymous") and record your
|
||||
appreciation for... whatever. The main goal is to record what people
|
||||
have given you, to grow giving economies. Each claim is recorded on a
|
||||
have given you, to grow giving economies. You can also record your own
|
||||
ideas for projects. Each claim is recorded on a
|
||||
custom ledger. The day after being registered, you'll be able to able to
|
||||
register others; later, you can create projects, too.
|
||||
register others, too.
|
||||
</p>
|
||||
<p>
|
||||
Note that there are rate limits to how many others you can register,
|
||||
so it may take some time to register everyone you want. Take your time...
|
||||
make it an opportunity to get to know their projects, and show your own.
|
||||
Note that there are limits to how many others you can register.
|
||||
Take your time to bring people on... make it an opportunity to get to
|
||||
know their projects, and to show your own.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
|
||||
<p>
|
||||
<a href="/help-onboarding" target="_blank" class="text-blue-500">
|
||||
Use these instructions.
|
||||
</a>
|
||||
To start scanning, go to the
|
||||
<router-link class="text-blue-500" to="/contact-qr">contact-scanning page.</router-link>
|
||||
</p>
|
||||
<p>
|
||||
If they are not nearby to scan QR codes, you each can tap on the QR code
|
||||
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
@@ -76,32 +90,13 @@
|
||||
<p>
|
||||
Go
|
||||
<router-link class="text-blue-500" to="/import-account">import your identifier</router-link>.
|
||||
If you don't want the old one, click "Advanced" and check the box to erase it.
|
||||
(The erase option only shows if you have exactly one identifier.
|
||||
For more in-depth surgery, you'll have to erase data from the browser or reinstall.)
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
|
||||
<p>
|
||||
<button class="text-blue-500" @click="showOnboardInfo">
|
||||
Click here to show an alert with the steps.
|
||||
</button>
|
||||
To start scanning, go
|
||||
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
|
||||
</p>
|
||||
<p>
|
||||
If they are not nearby to scan QR codes, tell them to copy their ID from
|
||||
their Identity <fa icon="circle-user" class="fa-fw" /> page, which
|
||||
typically starts with "did:ethr:...", and send it to you. Go to the
|
||||
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
|
||||
top form. To add a name, put a comma and then their name; to add their
|
||||
public key, put another comma followed by the key.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
|
||||
<p>
|
||||
There are two sets of data to backup: the identifier secrets and the
|
||||
other data that isn't quite a secret such as settings, contacts, etc.
|
||||
There are four sets of data to backup: the identifier secrets;
|
||||
the private text data that isn't quite as secret such as settings and contacts;
|
||||
the private image for yourself; and the data that you have sent to the public.
|
||||
</p>
|
||||
|
||||
<div class="px-4">
|
||||
@@ -122,7 +117,7 @@
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I backup my other (non-identifier-secret) data?
|
||||
How do I backup my other private text data like settings & contacts?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
@@ -134,6 +129,27 @@
|
||||
won't lose it.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I backup my profile image?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
|
||||
tap on your image, and save it.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I backup other data I've posted?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
This requires use of the API, so investigate the endpoints
|
||||
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
|
||||
(particularly the "claim" endpoints).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
|
||||
@@ -162,6 +178,7 @@
|
||||
<li>
|
||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
|
||||
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
|
||||
Beware that this will erase your existing contact & settings.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -178,13 +195,15 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
|
||||
<p>
|
||||
Before doing this, note the two kinds of data to backup: identity data,
|
||||
and other data for contacts and settings (see instructions above).
|
||||
Before doing this, you may want to back up your data with the instructions above.
|
||||
</p>
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Mobile
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Home Screen: hold down on the icon, and choose to delete it
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
|
||||
</li>
|
||||
@@ -198,13 +217,11 @@
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Chrome:
|
||||
<a href="chrome://settings/content/all" class="text-blue-500"
|
||||
>clear here</a
|
||||
>
|
||||
Clear at "chrome://settings/content/all" and
|
||||
also clear under dev tools Application
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Firefox: <a href="about:preferences">go here</a>, Manage Data,
|
||||
Firefox: Navigate to "about:preferences", Manage Data,
|
||||
find timesafari.app and select, hit Remove Selected, then Save
|
||||
Changes
|
||||
</li>
|
||||
@@ -234,7 +251,7 @@
|
||||
<p>
|
||||
There is a even more functionality in a mobile app (and more
|
||||
documentation) at
|
||||
<a href="https://endorser.ch" class="text-blue-500">
|
||||
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
|
||||
EndorserSearch.com
|
||||
</a>
|
||||
</p>
|
||||
@@ -287,6 +304,11 @@
|
||||
and you may have to close all your tabs. In addition, it may be running as an
|
||||
installed app, so look for any Time Safari app that may be running outside a browser.
|
||||
</li>
|
||||
<li>
|
||||
There may be a problem with your identity. Go to the Identity
|
||||
<fa icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
|
||||
and you may see helpful info there. If it shows a problem, try adding your identifier again.
|
||||
</li>
|
||||
<li>
|
||||
It can help to reregister the service worker:
|
||||
<ul>
|
||||
@@ -300,7 +322,7 @@
|
||||
find "timesafari.app", and click "Unregister".
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://duckduckgo.com/?q=unregister+service+worker" class="text-blue-500">Search</a>
|
||||
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
|
||||
for instructions for other browsers.</li>
|
||||
</ul>
|
||||
Then reload Time Safari.
|
||||
@@ -320,7 +342,7 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
||||
<p style="display:inline; align-items: center">
|
||||
This work is public domain, governed by
|
||||
This work is public domain. If you like rules, reference
|
||||
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||
<img
|
||||
@@ -341,15 +363,35 @@
|
||||
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
|
||||
<br />
|
||||
For all other claim data,
|
||||
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
|
||||
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
|
||||
the Endorser Service has this Privacy Policy.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How can I contribute?</h2>
|
||||
<p>
|
||||
If you have skills, contact us below.
|
||||
If you have Bitcoin, donate to
|
||||
<button
|
||||
@click="
|
||||
doCopyTwoSecRedo(
|
||||
'bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma',
|
||||
() => (showDidCopy = !showDidCopy)
|
||||
)
|
||||
"
|
||||
class="text-blue-500 ml-2"
|
||||
>
|
||||
bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma
|
||||
<fa v-show="!showDidCopy" icon="copy" class="text-slate-400 fa-fw" />
|
||||
</button>
|
||||
<span v-show="showDidCopy" class="ml-2 text-sm text-green-500">Copied</span>
|
||||
For other donations, contact us.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">Where can I read more?</h2>
|
||||
<p>
|
||||
This is part of the
|
||||
<a href="https://livesofgiving.org" class="text-blue-500">
|
||||
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
|
||||
Lives of Giving
|
||||
</a>
|
||||
initiative.
|
||||
@@ -359,7 +401,7 @@
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
For any other questions, including removing your data:
|
||||
For any other questions, like getting a new account or removing all your data from the public ledger:
|
||||
</h2>
|
||||
<p>
|
||||
Contact us at
|
||||
@@ -374,29 +416,26 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import * as Package from "../../package.json";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { ONBOARD_MESSAGE } from "@/libs/util";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
package = Package;
|
||||
commitHash = process.env.VUE_APP_GIT_HASH;
|
||||
commitHash = import.meta.env.VITE_GIT_HASH;
|
||||
showDidCopy = false;
|
||||
|
||||
showOnboardInfo() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Onboard Someone",
|
||||
text: ONBOARD_MESSAGE,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||||
doCopyTwoSecRedo(text: string, fn: () => void) {
|
||||
fn();
|
||||
useClipboard()
|
||||
.copy(text)
|
||||
.then(() => setTimeout(fn, 2000));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-2 pb-24 max-w-3xl mx-auto">
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-8">
|
||||
Time Safari
|
||||
{{ AppString.APP_NAME }}
|
||||
</h1>
|
||||
|
||||
<!-- prompt to install notifications -->
|
||||
@@ -62,166 +62,218 @@
|
||||
<div v-if="showShortcutBvc" class="mb-4">
|
||||
<router-link
|
||||
:to="{ name: 'quick-action-bvc' }"
|
||||
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Bountiful Voluntaryist Community Actions</router-link
|
||||
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Bountiful Voluntaryist Community Actions
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- show the actions for recognizing a give -->
|
||||
<div class="mb-8">
|
||||
<div v-if="isCreatingIdentifier">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
||||
<fa icon="spinner" class="fa-spin-pulse" /> Loading…
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="!activeDid && !isCreatingIdentifier"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
<p class="text-lg mb-3">
|
||||
Want to connect with your contacts, or share contributions or
|
||||
projects?
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Create An Identifier</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isRegistered"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
Someone must register your identifier before you can record anyone's
|
||||
giving.
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Show Them Your Identifier Info</router-link
|
||||
>
|
||||
<br />
|
||||
To double-check that you're registered,
|
||||
<br />
|
||||
<router-link :to="{ name: 'account' }" class="text-blue-500">
|
||||
see your Usage Limits on the Account
|
||||
<fa icon="circle-user" /> page.</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- activeDid && isRegistered -->
|
||||
<!-- !isCreatingIdentifier -->
|
||||
<!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold">Record Something Given By:</h2>
|
||||
</div>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<img
|
||||
src="../assets/blank-square.svg"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
<div
|
||||
v-if="!isRegistered"
|
||||
id="noticeSomeoneMustRegisterYou"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
<!-- activeDid && !isRegistered -->
|
||||
To share, someone must register you.
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Anonymous/Unnamed
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts.slice(0, 7)"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
Show Them {{ PASSKEYS_ENABLED ? "Default" : "Your" }} Identifier
|
||||
Info
|
||||
</router-link>
|
||||
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
See all your options first
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<router-link
|
||||
v-if="allContacts.length >= 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Choose From All Contacts
|
||||
</router-link>
|
||||
<button
|
||||
@click="openGiftedPrompts()"
|
||||
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Ideas...
|
||||
</button>
|
||||
<div v-else id="sectionRecordSomethingGiven">
|
||||
<!-- activeDid && isRegistered -->
|
||||
|
||||
<!-- show the actions for recognizing a give -->
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold">Record Something Given By:</h2>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
|
||||
>
|
||||
<li @click="openDialog()">
|
||||
<img
|
||||
src="../assets/blank-square.svg"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Unnamed/Unknown
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts.slice(0, 7)"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<router-link
|
||||
v-if="allContacts.length >= 7"
|
||||
:to="{ name: 'contact-gift' }"
|
||||
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Choose From All Contacts
|
||||
</router-link>
|
||||
<button
|
||||
@click="openGiftedPrompts()"
|
||||
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
Ideas...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
message="Received from"
|
||||
showGivenToUser="true"
|
||||
/>
|
||||
<GiftedDialog ref="customDialog" />
|
||||
<GiftedPrompts ref="giftedPrompts" />
|
||||
<FeedFilters ref="feedFilters" />
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
|
||||
<div class="flex items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Latest Activity</h2>
|
||||
<button @click="openFeedFilters()" class="block text-center ml-auto">
|
||||
<span class="text-sm text-white">
|
||||
<span
|
||||
v-if="resultsAreFiltered()"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
|
||||
>
|
||||
Filtered
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
|
||||
>
|
||||
Unfiltered
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||
<ul class="border-t border-slate-300">
|
||||
<ul id="listLatestActivity" class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300 py-2"
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm"
|
||||
v-if="record.jwtId == feedLastViewedClaimId"
|
||||
>
|
||||
You've already seen all the following
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12">
|
||||
<span class="col-span-11 justify-self-start">
|
||||
<span class="pt-1 col-span-1 justify-self-start">
|
||||
<span>
|
||||
<fa
|
||||
v-if="record.giver.known || record.receiver.known"
|
||||
icon="circle-user"
|
||||
class="col-span-1 pt-1 pl-0 pr-3 text-slate-500"
|
||||
:class="
|
||||
computeKnownPersonIconStyleClassNames(
|
||||
record.giver.known || record.receiver.known,
|
||||
)
|
||||
"
|
||||
@click="toastUser('This involves your contacts.')"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="gift"
|
||||
class="col-span-1 pt-1 pl-3 pr-0 text-slate-500"
|
||||
class="pl-3 text-slate-500"
|
||||
@click="toastUser('This is a gift.')"
|
||||
/>
|
||||
</span>
|
||||
{{ giveDescription(record) }}
|
||||
</span>
|
||||
<span class="col-span-10 justify-self-stretch">
|
||||
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
|
||||
<span
|
||||
v-if="
|
||||
record.giver.profileImageUrl ||
|
||||
record.receiver.profileImageUrl
|
||||
"
|
||||
>
|
||||
<EntityIcon
|
||||
v-if="record.agentDid !== activeDid"
|
||||
:icon-size="32"
|
||||
:profile-image-url="record.giver.profileImageUrl"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
/>
|
||||
<fa
|
||||
v-if="
|
||||
record.agentDid !== activeDid &&
|
||||
record.recipientDid !== activeDid &&
|
||||
!record.fulfillsPlanHandleId
|
||||
"
|
||||
icon="ellipsis"
|
||||
class="text-slate"
|
||||
/>
|
||||
<EntityIcon
|
||||
v-if="
|
||||
record.recipientDid !== activeDid &&
|
||||
!record.fulfillsPlanHandleId
|
||||
"
|
||||
:iconSize="32"
|
||||
:profile-image-url="record.receiver.profileImageUrl"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
|
||||
/>
|
||||
</span>
|
||||
-->
|
||||
<span class="pl-2">
|
||||
{{ giveDescription(record) }}
|
||||
</span>
|
||||
<a @click="onClickLoadClaim(record.jwtId)">
|
||||
<fa
|
||||
icon="circle-info"
|
||||
icon="file-lines"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
></fa>
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
<span class="col-span-1 justify-self-end shrink">
|
||||
<span class="col-span-1 justify-self-end">
|
||||
<router-link
|
||||
v-if="record.fulfillsPlanHandleId"
|
||||
:to="
|
||||
'/project/' +
|
||||
encodeURIComponent(record.fulfillsPlanHandleId)
|
||||
"
|
||||
class="justify-end"
|
||||
>
|
||||
<fa icon="hammer" class="ml-4 pl-2 text-blue-500"></fa>
|
||||
<fa icon="hammer" class="text-blue-500" />
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
@@ -235,7 +287,12 @@
|
||||
</InfiniteScroll>
|
||||
<div v-if="isFeedLoading">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
||||
<fa icon="spinner" class="fa-spin-pulse" /> Loading…
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!isFeedLoading && feedData.length === 0">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
No claims match your filters.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,45 +301,70 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import App from "../App.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import GiftedPrompts from "@/components/GiftedPrompts.vue";
|
||||
import FeedFilters from "@/components/FeedFilters.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import {
|
||||
AppString,
|
||||
NotificationIface,
|
||||
PASSKEYS_ENABLED,
|
||||
} from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import {
|
||||
BoundingBox,
|
||||
isAnyFeedFilterOn,
|
||||
MASTER_SETTINGS_KEY,
|
||||
Settings,
|
||||
} from "@/db/tables/settings";
|
||||
import {
|
||||
contactForDid,
|
||||
containsNonHiddenDid,
|
||||
didInfoForContact,
|
||||
GiverInputInfo,
|
||||
GiveServerRecord,
|
||||
fetchEndorserRateLimits,
|
||||
getHeaders,
|
||||
getPlanFromCache,
|
||||
GiverReceiverInputInfo,
|
||||
GiveSummaryRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import { generateSaveAndActivateIdentity } from "@/libs/util";
|
||||
import {
|
||||
generateSaveAndActivateIdentity,
|
||||
registerSaveAndActivatePasskey,
|
||||
} from "@/libs/util";
|
||||
|
||||
interface GiveRecordWithContactInfo extends GiveServerRecord {
|
||||
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
|
||||
giver: {
|
||||
displayName: string;
|
||||
known: boolean;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
image: string;
|
||||
image?: string;
|
||||
recipientProjectName?: string;
|
||||
receiver: {
|
||||
displayName: string;
|
||||
known: boolean;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
computed: {
|
||||
App() {
|
||||
return App;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
GiftedDialog,
|
||||
GiftedPrompts,
|
||||
FeedFilters,
|
||||
QuickNav,
|
||||
EntityIcon,
|
||||
InfiniteScroll,
|
||||
@@ -292,6 +374,9 @@ interface GiveRecordWithContactInfo extends GiveServerRecord {
|
||||
export default class HomeView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
AppString = AppString;
|
||||
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
@@ -299,36 +384,32 @@ export default class HomeView extends Vue {
|
||||
feedData: GiveRecordWithContactInfo[] = [];
|
||||
feedPreviousOldestId?: string;
|
||||
feedLastViewedClaimId?: string;
|
||||
givenName = "";
|
||||
isAnyFeedFilterOn: boolean;
|
||||
isCreatingIdentifier = false;
|
||||
isFeedFilteredByVisible = false;
|
||||
isFeedFilteredByNearby = false;
|
||||
isFeedLoading = true;
|
||||
isRegistered = false;
|
||||
searchBoxes: Array<{
|
||||
name: string;
|
||||
bbox: BoundingBox;
|
||||
}> = [];
|
||||
showShortcutBvc = false;
|
||||
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
return identity; // may be null
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
async mounted() {
|
||||
try {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
if (allAccounts.length > 0) {
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
} else {
|
||||
this.isCreatingIdentifier = true;
|
||||
const newDid = await generateSaveAndActivateIdentity();
|
||||
this.isCreatingIdentifier = false;
|
||||
this.allMyDids = [newDid];
|
||||
}
|
||||
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
@@ -336,18 +417,37 @@ export default class HomeView extends Vue {
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
|
||||
this.givenName = settings?.firstName || "";
|
||||
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
|
||||
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
this.searchBoxes = settings?.searchBoxes || [];
|
||||
this.showShortcutBvc = !!settings?.showShortcutBvc;
|
||||
|
||||
if (this.allMyDids.length === 0) {
|
||||
this.isCreatingIdentifier = true;
|
||||
this.activeDid = await generateSaveAndActivateIdentity();
|
||||
this.allMyDids = [this.activeDid];
|
||||
this.isCreatingIdentifier = false;
|
||||
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
|
||||
|
||||
// someone may have have registered after sharing contact info, so recheck
|
||||
if (!this.isRegistered && this.activeDid) {
|
||||
try {
|
||||
const resp = await fetchEndorserRateLimits(
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
this.activeDid,
|
||||
);
|
||||
if (resp.status === 200) {
|
||||
// we just needed to know that they're registered
|
||||
await db.open();
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
isRegistered: true,
|
||||
});
|
||||
this.isRegistered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore the error... just keep us unregistered
|
||||
}
|
||||
}
|
||||
|
||||
// this returns a Promise but we don't need to wait for it
|
||||
|
||||
await this.updateAllFeed();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -367,83 +467,130 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async generatePasskeyIdentifier() {
|
||||
this.isCreatingIdentifier = true;
|
||||
const account = await registerSaveAndActivatePasskey(
|
||||
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""),
|
||||
);
|
||||
this.activeDid = account.did;
|
||||
this.allMyDids = this.allMyDids.concat(this.activeDid);
|
||||
this.isCreatingIdentifier = false;
|
||||
}
|
||||
resultsAreFiltered() {
|
||||
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
|
||||
}
|
||||
|
||||
notificationsSupported() {
|
||||
return "Notification" in window;
|
||||
}
|
||||
|
||||
public async buildHeaders() {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// only called when a setting was changed
|
||||
async reloadFeedOnChange() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
|
||||
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
|
||||
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find(
|
||||
(acc) => acc.did === this.activeDid,
|
||||
) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
this.feedData = [];
|
||||
this.feedPreviousOldestId = undefined;
|
||||
this.updateAllFeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
public async loadMoreGives(payload: boolean) {
|
||||
if (payload) {
|
||||
async loadMoreGives(payload: boolean) {
|
||||
// Since feed now loads projects along the way, it takes longer
|
||||
// and the InfiniteScroll component triggers a load before finished.
|
||||
// One alternative is to totally separate the project link loading.
|
||||
if (payload && !this.isFeedLoading) {
|
||||
this.updateAllFeed();
|
||||
}
|
||||
}
|
||||
|
||||
public async updateAllFeed() {
|
||||
latLongInAnySearchBox(lat: number, long: number) {
|
||||
for (const boxInfo of this.searchBoxes) {
|
||||
if (
|
||||
boxInfo.bbox.westLong <= long &&
|
||||
long <= boxInfo.bbox.eastLong &&
|
||||
boxInfo.bbox.minLat <= lat &&
|
||||
lat <= boxInfo.bbox.maxLat
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateAllFeed() {
|
||||
this.isFeedLoading = true;
|
||||
let endOfResults = true;
|
||||
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
||||
.then(async (results) => {
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
// include the descriptions of the giver and receiver
|
||||
const newFeedData: GiveRecordWithContactInfo = results.data.map(
|
||||
(record: GiveServerRecord) => {
|
||||
// similar code is in endorser-mobile utility.ts
|
||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const claim = (record.fullClaim as any).claim || record.fullClaim;
|
||||
// agent.did is for legacy data, before March 2023
|
||||
const giverDid =
|
||||
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const recipientDid =
|
||||
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
return {
|
||||
...record,
|
||||
giver: didInfoForContact(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
contactForDid(giverDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
image: claim.image,
|
||||
receiver: didInfoForContact(
|
||||
recipientDid,
|
||||
this.activeDid,
|
||||
contactForDid(recipientDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
this.feedData = this.feedData.concat(newFeedData);
|
||||
for (const record: GiveSummaryRecord of results.data) {
|
||||
// similar code is in endorser-mobile utility.ts
|
||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const claim = (record.fullClaim as any).claim || record.fullClaim;
|
||||
// agent.did is for legacy data, before March 2023
|
||||
const giverDid =
|
||||
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const recipientDid =
|
||||
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
// This has indeed proven problematic. See loadMoreGives
|
||||
// We should display it immediately and then get the plan later.
|
||||
const plan = await getPlanFromCache(
|
||||
record.fulfillsPlanHandleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
|
||||
// check if the record should be filtered out
|
||||
let anyMatch = false;
|
||||
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
||||
// has a visible DID so it's a keeper
|
||||
anyMatch = true;
|
||||
}
|
||||
if (!anyMatch && this.isFeedFilteredByNearby) {
|
||||
// check if the associated project has a location inside user's search box
|
||||
if (record.fulfillsPlanHandleId) {
|
||||
if (plan?.locLat && plan?.locLon) {
|
||||
if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) {
|
||||
anyMatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.isAnyFeedFilterOn && !anyMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newRecord: GiveRecordWithContactInfo = {
|
||||
...record,
|
||||
giver: didInfoForContact(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
contactForDid(giverDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
image: claim.image,
|
||||
recipientProjectName: plan?.name as string,
|
||||
receiver: didInfoForContact(
|
||||
recipientDid,
|
||||
this.activeDid,
|
||||
contactForDid(recipientDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
};
|
||||
this.feedData.push(newRecord);
|
||||
}
|
||||
this.feedPreviousOldestId =
|
||||
results.data[results.data.length - 1].jwtId;
|
||||
// The following update is only done on the first load.
|
||||
@@ -452,7 +599,7 @@ export default class HomeView extends Vue {
|
||||
this.feedLastViewedClaimId < results.data[0].jwtId
|
||||
) {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
lastViewedClaimId: results.data[0].jwtId,
|
||||
});
|
||||
}
|
||||
@@ -470,6 +617,10 @@ export default class HomeView extends Vue {
|
||||
-1,
|
||||
);
|
||||
});
|
||||
if (this.feedData.length === 0 && !endOfResults) {
|
||||
// repeat until there's at least some data
|
||||
this.updateAllFeed();
|
||||
}
|
||||
this.isFeedLoading = false;
|
||||
}
|
||||
|
||||
@@ -479,15 +630,15 @@ export default class HomeView extends Vue {
|
||||
* @param beforeId the earliest ID (of previous searches) to search earlier
|
||||
* @return claims in reverse chronological order
|
||||
*/
|
||||
public async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
||||
async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||
const response = await fetch(
|
||||
endorserApiServer +
|
||||
"/api/v2/report/gives?giftNotTrade=true&" +
|
||||
"/api/v2/report/gives?giftNotTrade=true" +
|
||||
beforeQuery,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
headers: await getHeaders(this.activeDid),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -536,12 +687,28 @@ export default class HomeView extends Vue {
|
||||
return `${giverInfo.displayName} gave to ${recipientInfo.displayName}: ${gaveAmount}`;
|
||||
} else if (giverInfo.known) {
|
||||
// giver is named but recipient is not
|
||||
|
||||
// show the project name if to one
|
||||
if (giveRecord.recipientProjectName) {
|
||||
// retrieve the project name
|
||||
return `${giverInfo.displayName} gave: ${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
|
||||
}
|
||||
|
||||
// it's not to a project
|
||||
return `${giverInfo.displayName} gave: ${gaveAmount} (to ${recipientInfo.displayName})`;
|
||||
} else if (recipientInfo.known) {
|
||||
// recipient is named but giver is not
|
||||
return `${recipientInfo.displayName} received: ${gaveAmount} (from ${giverInfo.displayName})`;
|
||||
} else {
|
||||
// neither giver nor recipient are named
|
||||
|
||||
// show the project name if to one
|
||||
if (giveRecord.recipientProjectName) {
|
||||
// retrieve the project name
|
||||
return `${gaveAmount} (to the project ${giveRecord.recipientProjectName})`;
|
||||
}
|
||||
|
||||
// it's not to a project
|
||||
let peopleInfo;
|
||||
if (giverInfo.displayName === recipientInfo.displayName) {
|
||||
peopleInfo = `between two who are ${giverInfo.displayName}`;
|
||||
@@ -556,7 +723,7 @@ export default class HomeView extends Vue {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
displayAmount(code: string, amt: number) {
|
||||
@@ -567,12 +734,40 @@ export default class HomeView extends Vue {
|
||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||
}
|
||||
|
||||
openDialog(giver?: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
openDialog(giver?: GiverReceiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(
|
||||
giver,
|
||||
{
|
||||
did: this.activeDid,
|
||||
name: "you",
|
||||
},
|
||||
undefined,
|
||||
"Given by " + (giver?.name || "someone not named"),
|
||||
);
|
||||
}
|
||||
|
||||
openGiftedPrompts() {
|
||||
(this.$refs.giftedPrompts as GiftedPrompts).open();
|
||||
}
|
||||
|
||||
openFeedFilters() {
|
||||
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
||||
}
|
||||
|
||||
toastUser(message) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "FYI",
|
||||
text: message,
|
||||
},
|
||||
2000,
|
||||
);
|
||||
}
|
||||
|
||||
computeKnownPersonIconStyleClassNames(known: boolean) {
|
||||
return known ? "text-slate-500" : "text-slate-100";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,24 +39,43 @@
|
||||
|
||||
<!-- Other Identity/ies -->
|
||||
<ul class="mb-4">
|
||||
<li
|
||||
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
|
||||
v-for="ident in otherIdentities"
|
||||
:key="ident.did"
|
||||
@click="switchAccount(ident.did)"
|
||||
>
|
||||
<fa
|
||||
v-if="ident.did === activeDid"
|
||||
icon="circle-check"
|
||||
class="fa-fw text-blue-600 text-xl mr-3"
|
||||
/>
|
||||
<fa v-else icon="circle" class="fa-fw text-slate-400 text-xl mr-3" />
|
||||
<span class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold mb-0"></h2>
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ ident.did }}</code>
|
||||
<li v-for="ident in otherIdentities" :key="ident.did">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div
|
||||
class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer"
|
||||
@click="switchAccount(ident.did)"
|
||||
>
|
||||
<fa
|
||||
v-if="ident.did === activeDid"
|
||||
icon="circle-check"
|
||||
class="fa-fw text-blue-600 text-xl mr-3"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="circle"
|
||||
class="fa-fw text-slate-400 text-xl mr-3"
|
||||
/>
|
||||
<span class="flex-grow overflow-hidden">
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ ident.did }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div>
|
||||
<fa
|
||||
v-if="ident.did === activeDid"
|
||||
icon="trash-can"
|
||||
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
|
||||
@click="notifyCannotDelete()"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="trash-can"
|
||||
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
|
||||
@click="deleteAccount(ident.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -80,10 +99,10 @@
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface } from "@/constants/app";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@@ -91,14 +110,11 @@ import QuickNav from "@/components/QuickNav.vue";
|
||||
export default class IdentitySwitcherView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
Constants = AppString;
|
||||
public accounts: typeof AccountsSchema;
|
||||
public activeDid = "";
|
||||
public activeDidInIdentities = false;
|
||||
public apiServer = "";
|
||||
public apiServerInput = "";
|
||||
public otherIdentities: Array<{ did: string }> = [];
|
||||
public showContactGives = false;
|
||||
public otherIdentities: Array<{ id: string; did: string }> = [];
|
||||
|
||||
async created() {
|
||||
try {
|
||||
@@ -107,14 +123,13 @@ export default class IdentitySwitcherView extends Vue {
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.apiServerInput = settings?.apiServer || "";
|
||||
this.showContactGives = !!settings?.showContactGivesInline;
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
for (let n = 0; n < accounts.length; n++) {
|
||||
const did = JSON.parse(accounts[n].identity)["did"];
|
||||
this.otherIdentities.push({ did: did });
|
||||
if (did && this.activeDid === did) {
|
||||
const acct = accounts[n];
|
||||
this.otherIdentities.push({ id: acct.id as string, did: acct.did });
|
||||
if (acct.did && this.activeDid === acct.did) {
|
||||
this.activeDidInIdentities = true;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +156,38 @@ export default class IdentitySwitcherView extends Vue {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
(this.$router as Router).push({ name: "account" });
|
||||
}
|
||||
|
||||
async deleteAccount(id: string) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Delete Identity?",
|
||||
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
|
||||
onYes: async () => {
|
||||
await accountsDB.open();
|
||||
await accountsDB.accounts.delete(id);
|
||||
this.otherIdentities = this.otherIdentities.filter(
|
||||
(ident) => ident.id !== id,
|
||||
);
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
notifyCannotDelete() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Cannot Delete",
|
||||
text: "You cannot delete the active identity.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Enter your seed phrase below to import your identifier on this device.
|
||||
</p>
|
||||
<!-- id used by puppeteer test script -->
|
||||
<input
|
||||
<textarea
|
||||
id="seed-input"
|
||||
type="text"
|
||||
placeholder="Seed Phrase"
|
||||
@@ -56,25 +56,28 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="fromMnemonic()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="fromMnemonic()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
@@ -108,7 +111,7 @@ export default class ImportAccountView extends Vue {
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
public async fromMnemonic() {
|
||||
@@ -141,10 +144,10 @@ export default class ImportAccountView extends Vue {
|
||||
|
||||
// record that as the active DID
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
(this.$router as Router).push({ name: "account" });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.error("Error saving mnemonic & updating settings:", err);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<div>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Will increment the maximum derivation path from the existing seed.
|
||||
Will increment the maximum known derivation path from the existing seed.
|
||||
</p>
|
||||
|
||||
<p v-if="didArrays.length > 1">
|
||||
@@ -49,31 +49,35 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="incrementDerivation()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Increment and Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="incrementDerivation()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Increment and Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
nextDerivationPath,
|
||||
} from "../libs/crypto";
|
||||
} from "@/libs/crypto";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@@ -98,7 +102,7 @@ export default class ImportAccountView extends Vue {
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
public switchAccount(did: string) {
|
||||
@@ -122,9 +126,11 @@ export default class ImportAccountView extends Vue {
|
||||
}
|
||||
});
|
||||
// increment the last number in that max derivation path
|
||||
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath);
|
||||
const newDerivPath = nextDerivationPath(
|
||||
accountWithMaxDeriv.derivationPath as string,
|
||||
);
|
||||
|
||||
const mne: string = accountWithMaxDeriv.mnemonic;
|
||||
const mne: string = accountWithMaxDeriv.mnemonic as string;
|
||||
|
||||
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
|
||||
|
||||
@@ -142,10 +148,10 @@ export default class ImportAccountView extends Vue {
|
||||
|
||||
// record that as the active DID
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
(this.$router as Router).push({ name: "account" });
|
||||
} catch (err) {
|
||||
console.error("Error saving mnemonic & updating settings:", err);
|
||||
}
|
||||
|
||||
@@ -22,27 +22,31 @@
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onClickSaveChanges()"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onClickSaveChanges()"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
|
||||
@@ -61,18 +65,16 @@ export default class NewEditAccountView extends Vue {
|
||||
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
|
||||
}
|
||||
|
||||
onClickSaveChanges() {
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
async onClickSaveChanges() {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.givenName,
|
||||
lastName: "", // deprecated, pre v 0.1.3
|
||||
});
|
||||
localStorage.setItem("firstName", this.givenName as string);
|
||||
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
|
||||
this.$router.back();
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.$router.back();
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,10 +29,31 @@
|
||||
v-model="fullClaim.name"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<span v-if="imageUrl" class="flex justify-between">
|
||||
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
|
||||
<img :src="imageUrl" class="h-24 rounded-xl" />
|
||||
</a>
|
||||
<fa
|
||||
icon="trash-can"
|
||||
@click="confirmDeleteImage"
|
||||
class="text-red-500 fa-fw ml-8 mt-10"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>
|
||||
<fa
|
||||
icon="camera"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||
@click="openImageDialog"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Other Authorized Representative"
|
||||
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||||
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
|
||||
v-model="agentDid"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
@@ -53,6 +74,9 @@
|
||||
v-model="fullClaim.description"
|
||||
maxlength="5000"
|
||||
></textarea>
|
||||
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
||||
If you want to be contacted, be sure to include your contact information.
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
||||
{{ fullClaim.description?.length }}/5000 max. characters
|
||||
</div>
|
||||
@@ -64,6 +88,23 @@
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div class="flex mb-4 columns-3 w-full">
|
||||
<input
|
||||
v-model="startDateInput"
|
||||
placeholder="Start Date"
|
||||
type="date"
|
||||
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
:disabled="!startDateInput"
|
||||
placeholder="Start Time"
|
||||
v-model="startTimeInput"
|
||||
type="time"
|
||||
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
|
||||
/>
|
||||
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -73,16 +114,17 @@
|
||||
/>
|
||||
<label for="includeLocation">Include Location</label>
|
||||
</div>
|
||||
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
||||
<div class="px-2 py-2">
|
||||
<div v-if="includeLocation" class="mb-4 aspect-video">
|
||||
<p class="text-sm mb-2 text-slate-500">
|
||||
For your security, choose a location nearby but not exactly at the
|
||||
place.
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<l-map
|
||||
ref="map"
|
||||
v-model:zoom="zoom"
|
||||
:center="[0, 0]"
|
||||
class="!z-40 rounded-md"
|
||||
@click="
|
||||
(event) => {
|
||||
latitude = event.latlng.lat;
|
||||
@@ -98,59 +140,71 @@
|
||||
<l-marker
|
||||
v-if="latitude && longitude"
|
||||
:lat-lng="[latitude, longitude]"
|
||||
@click="maybeEraseLatLong()"
|
||||
@click="confirmEraseLatLong()"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
:disabled="isHiddenSave"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onSaveProjectClick()"
|
||||
>
|
||||
<!-- SHOW if in idle state -->
|
||||
<span :class="{ hidden: isHiddenSave }">Save Project</span>
|
||||
|
||||
<!-- SHOW if in saving state; DISABLE button while in saving state -->
|
||||
<span :class="{ hidden: isHiddenSpinner }">
|
||||
<!-- icon no worky? -->
|
||||
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
||||
Saving...</span
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
:disabled="isHiddenSave"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onSaveProjectClick()"
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="onCancelClick()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<!-- SHOW if in idle state -->
|
||||
<span :class="{ hidden: isHiddenSave }">Save Project</span>
|
||||
|
||||
<!-- SHOW if in saving state; DISABLE button while in saving state -->
|
||||
<span :class="{ hidden: isHiddenSpinner }">
|
||||
<!-- icon no worky? -->
|
||||
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
||||
Saving...</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="onCancelClick()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AxiosError, AxiosRequestHeaders } from "axios";
|
||||
import { DateTime } from "luxon";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import {
|
||||
createEndorserJwtVcFromClaim,
|
||||
getHeaders,
|
||||
PlanVerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
import { useAppStore } from "@/store/app";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
||||
|
||||
@Component({
|
||||
components: { LMap, LMarker, LTileLayer, QuickNav },
|
||||
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
|
||||
})
|
||||
export default class NewEditProjectView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
errNote(message) {
|
||||
this.$notify(
|
||||
{ group: "alert", type: "danger", title: "Error", text: message },
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
activeDid = "";
|
||||
agentDid = "";
|
||||
@@ -162,6 +216,7 @@ export default class NewEditProjectView extends Vue {
|
||||
name: "",
|
||||
description: "",
|
||||
}; // this default is only to avoid errors before plan is loaded
|
||||
imageUrl = "";
|
||||
includeLocation = false;
|
||||
isHiddenSave = false;
|
||||
isHiddenSpinner = true;
|
||||
@@ -171,39 +226,15 @@ export default class NewEditProjectView extends Vue {
|
||||
numAccounts = 0;
|
||||
projectId = localStorage.getItem("projectId") || "";
|
||||
projectIssuerDid = "";
|
||||
startDateInput?: string;
|
||||
startTimeInput?: string;
|
||||
zoneName = DateTime.local().zoneName;
|
||||
zoom = 2;
|
||||
|
||||
async beforeCreate() {
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse((account?.identity as string) || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identifier available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = (settings?.activeDid as string) || "";
|
||||
@@ -211,35 +242,26 @@ export default class NewEditProjectView extends Vue {
|
||||
|
||||
if (this.projectId) {
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("Error: no account was found.");
|
||||
this.errNote("There was a problem loading your account info.");
|
||||
} else {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
|
||||
);
|
||||
}
|
||||
this.loadProject(identity);
|
||||
this.loadProject(this.activeDid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadProject(identity: IIdentifier) {
|
||||
async loadProject(userDid: string) {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/claim/byHandle/" +
|
||||
encodeURIComponent(this.projectId);
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const headers = await getHeaders(userDid);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.projectIssuerDid = resp.data.issuer;
|
||||
this.fullClaim = resp.data.claim;
|
||||
this.imageUrl = resp.data.claim.image || "";
|
||||
this.lastClaimJwtId = resp.data.id;
|
||||
if (this.fullClaim?.location) {
|
||||
this.includeLocation = true;
|
||||
@@ -249,13 +271,93 @@ export default class NewEditProjectView extends Vue {
|
||||
if (this.fullClaim?.agent?.identifier) {
|
||||
this.agentDid = this.fullClaim.agent.identifier;
|
||||
}
|
||||
if (this.fullClaim.startTime) {
|
||||
const localDateTime = DateTime.fromISO(
|
||||
this.fullClaim.startTime as string,
|
||||
).toLocal();
|
||||
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
|
||||
this.startTimeInput = localDateTime.toFormat("HH:mm");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Got error retrieving that project", error);
|
||||
this.errNote("There was an error retrieving that project.");
|
||||
}
|
||||
}
|
||||
|
||||
private async saveProject(identity: IIdentifier) {
|
||||
openImageDialog() {
|
||||
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
|
||||
this.imageUrl = imgUrl;
|
||||
}, "PlanAction");
|
||||
}
|
||||
|
||||
confirmDeleteImage() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Are you sure you want to delete the image?",
|
||||
text: "",
|
||||
onYes: this.deleteImage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteImage() {
|
||||
if (!this.imageUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
||||
const response = await this.axios.delete(
|
||||
DEFAULT_IMAGE_API_SERVER +
|
||||
"/image/" +
|
||||
encodeURIComponent(this.imageUrl),
|
||||
{ headers },
|
||||
);
|
||||
if (response.status === 204) {
|
||||
// don't bother with a notification
|
||||
// (either they'll simply continue or they're canceling and going back)
|
||||
} else {
|
||||
console.error("Problem deleting image:", response);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem deleting the image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageUrl = "";
|
||||
} catch (error) {
|
||||
console.error("Error deleting image:", error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).response.status === 404) {
|
||||
console.log("The image was already deleted:", error);
|
||||
|
||||
this.imageUrl = "";
|
||||
|
||||
// it already doesn't exist so we won't say anything to the user
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error deleting the image.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveProject(issuerDid: string) {
|
||||
// Make a claim
|
||||
const vcClaim: PlanVerifiableCredential = this.fullClaim;
|
||||
if (this.projectId) {
|
||||
@@ -265,6 +367,13 @@ export default class NewEditProjectView extends Vue {
|
||||
vcClaim.agent = {
|
||||
identifier: this.agentDid,
|
||||
};
|
||||
} else {
|
||||
delete vcClaim.agent;
|
||||
}
|
||||
if (this.imageUrl) {
|
||||
vcClaim.image = this.imageUrl;
|
||||
} else {
|
||||
delete vcClaim.image;
|
||||
}
|
||||
if (this.includeLocation) {
|
||||
vcClaim.location = {
|
||||
@@ -274,111 +383,109 @@ export default class NewEditProjectView extends Vue {
|
||||
longitude: this.longitude,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
delete vcClaim.location;
|
||||
}
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
// create a signature using private key of identity
|
||||
if (identity.keys[0].privateKeyHex != null) {
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex;
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
const alg = undefined;
|
||||
// create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
alg: alg,
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
if (this.startDateInput) {
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
if (resp.data?.success?.handleId) {
|
||||
this.errorMessage = "";
|
||||
|
||||
useAppStore()
|
||||
.setProjectId(resp.data.success.handleId)
|
||||
.then(() => {
|
||||
this.$router.push({ name: "project" });
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
"Got unexpected 'data' inside response from server",
|
||||
resp,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Saving Idea",
|
||||
text: "Server did not save the idea. Try again.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error saving the project.";
|
||||
const serverError = error as AxiosError<{
|
||||
error?: { message?: string };
|
||||
}>;
|
||||
if (serverError) {
|
||||
console.error("Got error from server", serverError);
|
||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||
userMessage =
|
||||
(serverError.response?.data?.error?.message as string) ||
|
||||
userMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "User Message",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Server Message",
|
||||
text: JSON.stringify(serverError.toJSON()),
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Here's the full error trying to save the claim:",
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Claim Error",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.errorMessage = userMessage;
|
||||
const startTimeFull = this.startTimeInput || "00:00:00";
|
||||
const fullTimeString = this.startDateInput + " " + startTimeFull;
|
||||
// throw an error on an invalid date or time string
|
||||
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
|
||||
} catch {
|
||||
// it's not a valid date so erase it and tell the user
|
||||
delete vcClaim.startTime;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "The date was invalid so it was not set.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
delete vcClaim.startTime;
|
||||
}
|
||||
const vcJwt = await createEndorserJwtVcFromClaim(issuerDid, vcClaim);
|
||||
|
||||
// Make the xhr request payload
|
||||
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const headers = await getHeaders(issuerDid);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
if (resp.data?.success?.handleId) {
|
||||
this.errorMessage = "";
|
||||
|
||||
useAppStore()
|
||||
.setProjectId(resp.data.success.handleId)
|
||||
.then(() => {
|
||||
(this.$router as Router).push({ name: "project" });
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
"Got unexpected 'data' inside response from server",
|
||||
resp,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Saving Idea",
|
||||
text: "Server did not save the idea. Try again.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error saving the project.";
|
||||
const serverError = error as AxiosError<{
|
||||
error?: { message?: string };
|
||||
}>;
|
||||
if (serverError) {
|
||||
console.error("Got error from server", serverError);
|
||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||
userMessage =
|
||||
(serverError.response?.data?.error?.message as string) ||
|
||||
userMessage;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "User Message",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Server Message",
|
||||
text: JSON.stringify(serverError.toJSON()),
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("Here's the full error trying to save the claim:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Claim Error",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.errorMessage = userMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,21 +496,33 @@ export default class NewEditProjectView extends Vue {
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("Error: there is no account.");
|
||||
} else {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
this.saveProject(identity);
|
||||
this.saveProject(this.activeDid);
|
||||
}
|
||||
}
|
||||
|
||||
public maybeEraseLatLong() {
|
||||
if (window.confirm("Are you sure you don't want to mark a location?")) {
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.includeLocation = false;
|
||||
}
|
||||
confirmEraseLatLong() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Erase Marker",
|
||||
text: "Are you sure you don't want to mark a location? This will erase the current location.",
|
||||
onYes: async () => {
|
||||
this.eraseLatLong();
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
public eraseLatLong() {
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.includeLocation = false;
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
<script lang="ts">
|
||||
import "dexie-export-import";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { generateSaveAndActivateIdentity } from "@/libs/util";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@@ -65,7 +67,7 @@ export default class NewIdentifierView extends Vue {
|
||||
await generateSaveAndActivateIdentity();
|
||||
this.loading = false;
|
||||
setTimeout(() => {
|
||||
this.$router.push({ name: "home" });
|
||||
(this.$router as Router).push({ name: "home" });
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
633
src/views/OfferDetailsView.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back -->
|
||||
<div
|
||||
v-if="!hideBackButton"
|
||||
class="text-lg text-center font-light relative px-7"
|
||||
>
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="cancelBack()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 class="text-4xl text-center font-light px-4 mb-4">What Is Offered</h1>
|
||||
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
<span>
|
||||
Offer to
|
||||
{{
|
||||
offeredToProject
|
||||
? projectName
|
||||
: offeredToRecipient
|
||||
? recipientName
|
||||
: "someone unidentified"
|
||||
}}</span
|
||||
>
|
||||
</h1>
|
||||
<textarea
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What is offered"
|
||||
v-model="itemDescription"
|
||||
data-testId="itemDescription"
|
||||
/>
|
||||
<div class="flex flex-row justify-center">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="amountInput === '0' ? null : decrement()"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
||||
v-model="amountInput"
|
||||
data-testId="inputOfferAmount"
|
||||
/>
|
||||
<div
|
||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="increment()"
|
||||
>
|
||||
<fa icon="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row mt-2">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
Conditions
|
||||
</span>
|
||||
<textarea
|
||||
class="w-full border border-slate-400 px-3 py-2 rounded-r"
|
||||
placeholder="Prerequisites, other people to include, etc."
|
||||
v-model="conditionDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row mt-2">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
{{ validThroughDateInput ? "" : "No" }} Expiration
|
||||
</span>
|
||||
<input
|
||||
v-model="validThroughDateInput"
|
||||
type="date"
|
||||
class="w-full rounded border border-slate-400 px-3 py-2 rounded-r"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-7 mt-4 flex">
|
||||
<input
|
||||
v-if="projectId && !offeredToRecipient"
|
||||
type="checkbox"
|
||||
class="h-6 w-6 mr-2"
|
||||
v-model="offeredToProject"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="square"
|
||||
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
@click="notifyUserOfProject()"
|
||||
/>
|
||||
<label class="text-sm mt-1">
|
||||
{{
|
||||
projectId
|
||||
? "This is offered to " + projectName
|
||||
: "No project was chosen"
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="h-7 mt-4 flex">
|
||||
<input
|
||||
v-if="recipientDid && !offeredToProject"
|
||||
type="checkbox"
|
||||
class="h-6 w-6 mr-2"
|
||||
v-model="offeredToRecipient"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="square"
|
||||
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
|
||||
@click="notifyUserOfRecipient()"
|
||||
/>
|
||||
<label class="text-sm mt-1">
|
||||
{{
|
||||
recipientDid
|
||||
? "This is offered to " + recipientName
|
||||
: "No recipient was chosen."
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'claim-add-raw',
|
||||
query: {
|
||||
claim: constructOfferParam(),
|
||||
},
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
Edit & Submit Raw
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
<fa
|
||||
icon="circle-info"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
@click="explainData()"
|
||||
/>
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
didInfo,
|
||||
editAndSubmitOffer,
|
||||
GenericCredWrapper,
|
||||
getPlanFromCache,
|
||||
hydrateOffer,
|
||||
OfferVerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class OfferDetailsView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
conditionDescription = "";
|
||||
itemDescription = "";
|
||||
destinationPathAfter = "";
|
||||
offeredToProject = false;
|
||||
offeredToRecipient = false;
|
||||
offererDid: string | undefined;
|
||||
hideBackButton = false;
|
||||
message = "";
|
||||
offerId = "";
|
||||
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
|
||||
projectId = "";
|
||||
projectName = "a project";
|
||||
recipientDid = "";
|
||||
recipientName = "";
|
||||
unitCode = "HUR";
|
||||
validThroughDateInput = "";
|
||||
|
||||
libsUtil = libsUtil;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
|
||||
? (JSON.parse(
|
||||
(this.$route as Router).query["prevCredToEdit"],
|
||||
) as GenericCredWrapper<OfferVerifiableCredential>)
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Retrieval Error",
|
||||
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
|
||||
},
|
||||
6000,
|
||||
);
|
||||
}
|
||||
|
||||
const prevAmount =
|
||||
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
|
||||
this.amountInput =
|
||||
(this.$route as Router).query["amountInput"] ||
|
||||
(prevAmount ? String(prevAmount) : "") ||
|
||||
this.amountInput;
|
||||
this.unitCode = ((this.$route as Router).query["unitCode"] ||
|
||||
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
|
||||
this.unitCode) as string;
|
||||
|
||||
this.conditionDescription =
|
||||
this.prevCredToEdit?.claim?.description || this.conditionDescription;
|
||||
this.itemDescription =
|
||||
(this.$route as Router).query["description"] ||
|
||||
this.prevCredToEdit?.claim?.itemOffered?.description ||
|
||||
this.itemDescription;
|
||||
this.destinationPathAfter = (this.$route as Router).query[
|
||||
"destinationPathAfter"
|
||||
];
|
||||
this.offererDid = ((this.$route as Router).query["offererDid"] ||
|
||||
this.prevCredToEdit?.claim?.agent?.identifier ||
|
||||
this.offererDid) as string;
|
||||
this.hideBackButton =
|
||||
(this.$route as Router).query["hideBackButton"] === "true";
|
||||
this.message = ((this.$route as Router).query["message"] as string) || "";
|
||||
|
||||
// find any project ID
|
||||
let project;
|
||||
if (
|
||||
this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] ===
|
||||
"PlanAction"
|
||||
) {
|
||||
project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf;
|
||||
}
|
||||
this.projectId = ((this.$route as Router).query["projectId"] ||
|
||||
project?.identifier ||
|
||||
this.projectId) as string;
|
||||
this.projectName = ((this.$route as Router).query["projectName"] ||
|
||||
project?.name ||
|
||||
this.projectName) as string;
|
||||
|
||||
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
|
||||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
|
||||
this.recipientName =
|
||||
((this.$route as Router).query["recipientName"] as string) || "";
|
||||
|
||||
this.validThroughDateInput =
|
||||
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
|
||||
let allContacts: Contact[] = [];
|
||||
let allMyDids: string[] = [];
|
||||
if (this.recipientDid && !this.recipientName) {
|
||||
allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
allMyDids = allAccounts.map((acc) => acc.did);
|
||||
this.recipientName = didInfo(
|
||||
this.recipientDid,
|
||||
this.activeDid,
|
||||
allMyDids,
|
||||
allContacts,
|
||||
);
|
||||
}
|
||||
// these should be functions but something's wrong with the syntax in the <> conditional
|
||||
this.offeredToProject = !!this.projectId;
|
||||
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.error("Error retrieving settings from database:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.projectId && !this.projectName) {
|
||||
// console.log("Getting project name from cache", this.projectId);
|
||||
const project = await getPlanFromCache(
|
||||
this.projectId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
this.projectName = project?.name
|
||||
? "the project: " + project.name
|
||||
: "a project";
|
||||
}
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||
const index = units.indexOf(this.unitCode);
|
||||
this.unitCode = units[(index + 1) % units.length];
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
|
||||
cancelBack() {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a offer.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (parseFloat(this.amountInput) < 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
text: "You may not send a negative number.",
|
||||
title: "",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.itemDescription && !parseFloat(this.amountInput)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${
|
||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the offer...",
|
||||
title: "",
|
||||
},
|
||||
1000,
|
||||
);
|
||||
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordOffer();
|
||||
}
|
||||
|
||||
notifyUserOfProject() {
|
||||
if (!this.projectId) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a project, you must open this page through a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
// must be because offeredToRecipient is true
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "You cannot assign both to a project and to a recipient.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyUserOfRecipient() {
|
||||
if (!this.recipientDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "To assign to a recipient, you must open this page from a contact.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
// must be because offeredToProject is true
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Error",
|
||||
text: "You cannot assign both to a recipient and to a project.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param offererDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
* @param unitCode may be omitted, defaults to "HUR"
|
||||
*/
|
||||
public async recordOffer() {
|
||||
try {
|
||||
const recipientDid = this.offeredToRecipient
|
||||
? this.recipientDid
|
||||
: undefined;
|
||||
const projectId = this.offeredToProject ? this.projectId : undefined;
|
||||
let result;
|
||||
if (this.prevCredToEdit) {
|
||||
// don't create from a blank one in case some properties were set from a different interface
|
||||
result = await editAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.prevCredToEdit,
|
||||
this.activeDid,
|
||||
this.itemDescription,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.conditionDescription,
|
||||
this.validThroughDateInput,
|
||||
recipientDid,
|
||||
projectId,
|
||||
);
|
||||
} else {
|
||||
result = await createAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
this.itemDescription,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.conditionDescription,
|
||||
this.validThroughDateInput,
|
||||
recipientDid,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === "error" || this.isCreationError(result.response)) {
|
||||
const errorMessage = this.getCreationErrorMessage(result);
|
||||
console.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the offer.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That offer was recorded.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
localStorage.removeItem("imageUrl");
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Error with offer recordation caught:", error);
|
||||
const errorMessage =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the offer.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
constructOfferParam() {
|
||||
const recipientDid = this.offeredToRecipient
|
||||
? this.recipientDid
|
||||
: undefined;
|
||||
const projectId = this.offeredToProject ? this.projectId : undefined;
|
||||
const offerClaim = hydrateOffer(
|
||||
this.prevCredToEdit?.claim as OfferVerifiableCredential,
|
||||
this.activeDid,
|
||||
recipientDid,
|
||||
this.itemDescription,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.conditionDescription,
|
||||
projectId,
|
||||
this.validThroughDateInput,
|
||||
this.prevCredToEdit?.id as string,
|
||||
);
|
||||
const claimStr = JSON.stringify(offerClaim);
|
||||
return claimStr;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result response "data" from the server
|
||||
* @returns true if the result indicates an error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Data Sharing",
|
||||
text: libsUtil.PRIVACY_MESSAGE,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -62,8 +62,8 @@
|
||||
|
||||
<!-- New Project -->
|
||||
<button
|
||||
v-if="showProjects"
|
||||
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
|
||||
v-if="isRegistered && showProjects"
|
||||
class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
|
||||
@click="onClickNewProject()"
|
||||
>
|
||||
<fa icon="plus" class="fa-fw"></fa>
|
||||
@@ -79,29 +79,49 @@
|
||||
|
||||
<!-- Offer Results List -->
|
||||
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
|
||||
<ul class="border-t border-slate-300">
|
||||
<div v-if="offers.length === 0" class="text-center py-4">
|
||||
You have not offered anything.
|
||||
<br />
|
||||
<router-link to="/discover" class="text-blue-600">
|
||||
Look for projects worth some of your time.
|
||||
</router-link>
|
||||
</div>
|
||||
<ul id="listOffers" class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="offer in offers"
|
||||
:key="offer.handleId"
|
||||
>
|
||||
<div class="block py-4 flex gap-4">
|
||||
<div v-if="offer.fulfillsPlanHandleId" class="flex-none w-12">
|
||||
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
|
||||
<ProjectIcon
|
||||
:entityId="offer.fulfillsPlanHandleId"
|
||||
:iconSize="48"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
||||
></ProjectIcon>
|
||||
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="offer.recipientDid" class="flex-none w-12">
|
||||
<EntityIcon
|
||||
:entityId="offer.recipientDid"
|
||||
:iconSize="48"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
To
|
||||
{{
|
||||
offer.fulfillsPlanHandleId
|
||||
? projectNameFromHandleId[offer.fulfillsPlanHandleId]
|
||||
: didInfo(
|
||||
offer.recipientDid,
|
||||
activeDid,
|
||||
allMyDids,
|
||||
allContacts,
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
{{ offer.objectDescription }}
|
||||
</div>
|
||||
@@ -167,7 +187,7 @@
|
||||
|
||||
<a @click="onClickLoadClaim(offer.jwtId)">
|
||||
<fa
|
||||
icon="circle-info"
|
||||
icon="file-lines"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
></fa>
|
||||
</a>
|
||||
@@ -177,9 +197,21 @@
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
|
||||
<!-- Project Results List -->
|
||||
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
|
||||
<ul class="border-t border-slate-300">
|
||||
<div v-if="projects.length === 0" class="text-center py-4">
|
||||
You have not announced any projects.
|
||||
<div v-if="isRegistered">
|
||||
Hit the big
|
||||
<fa
|
||||
icon="plus"
|
||||
class="bg-blue-600 text-white px-1 py-1 rounded-full"
|
||||
/>
|
||||
button. You'll never know until you try.
|
||||
</div>
|
||||
</div>
|
||||
<ul id="listProjects" class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="project in projects"
|
||||
@@ -189,12 +221,13 @@
|
||||
@click="onClickLoadProject(project.handleId)"
|
||||
class="block py-4 flex gap-4"
|
||||
>
|
||||
<div class="flex-none w-12">
|
||||
<div class="flex-none">
|
||||
<ProjectIcon
|
||||
:entityId="project.handleId"
|
||||
:iconSize="48"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
||||
></ProjectIcon>
|
||||
:imageUrl="project.image"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-hidden">
|
||||
@@ -211,76 +244,78 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import ProjectIcon from "@/components/ProjectIcon.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { OfferServerRecord, PlanData } from "@/libs/endorserServer";
|
||||
import {
|
||||
didInfo,
|
||||
getHeaders,
|
||||
getPlanFromCache,
|
||||
OfferSummaryRecord,
|
||||
PlanData,
|
||||
} from "@/libs/endorserServer";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
|
||||
})
|
||||
export default class ProjectsView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
errNote(message) {
|
||||
this.$notify(
|
||||
{ group: "alert", type: "danger", title: "Error", text: message },
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
projects: PlanData[] = [];
|
||||
currentIid: IIdentifier;
|
||||
isLoading = false;
|
||||
numAccounts = 0;
|
||||
offers: OfferServerRecord[] = [];
|
||||
isRegistered = false;
|
||||
offers: OfferSummaryRecord[] = [];
|
||||
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
|
||||
showOffers = true;
|
||||
showProjects = false;
|
||||
|
||||
libsUtil = libsUtil;
|
||||
didInfo = didInfo;
|
||||
|
||||
/**
|
||||
* 'created' hook runs when the Vue instance is first created
|
||||
**/
|
||||
async created() {
|
||||
async mounted() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const activeDid: string = (settings?.activeDid as string) || "";
|
||||
this.activeDid = (settings?.activeDid as string) || "";
|
||||
this.apiServer = (settings?.apiServer as string) || "";
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
if (this.numAccounts === 0) {
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
console.error("No accounts found.");
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You need an identifier to load your projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.errNote("You need an identifier to load your projects.");
|
||||
} else {
|
||||
this.currentIid = await this.getIdentity(activeDid);
|
||||
await this.loadOffers();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error initializing:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong loading your projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.errNote("Something went wrong loading your projects.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,20 +324,23 @@ export default class ProjectsView extends Vue {
|
||||
* @param url the url used to fetch the data
|
||||
* @param token Authorization token
|
||||
**/
|
||||
async projectDataLoader(url: string, token: string) {
|
||||
const headers: { [key: string]: string } = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
async projectDataLoader(url: string) {
|
||||
try {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
this.isLoading = true;
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200 || !resp.data.data) {
|
||||
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
const plans: PlanData[] = resp.data.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
||||
const { name, description, handleId, image, issuerDid, rowid } = plan;
|
||||
this.projects.push({
|
||||
name,
|
||||
description,
|
||||
image,
|
||||
handleId,
|
||||
issuerDid,
|
||||
rowid,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
@@ -310,28 +348,12 @@ export default class ProjectsView extends Vue {
|
||||
resp.status,
|
||||
resp.data,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to get projects from the server. Try again later.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.errNote("Failed to get projects from the server.");
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Got error loading plans:", error.message || error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Got an error loading projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.errNote("Got an error loading projects.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
@@ -344,39 +366,18 @@ export default class ProjectsView extends Vue {
|
||||
async loadMoreProjectData(payload: boolean) {
|
||||
if (this.projects.length > 0 && payload) {
|
||||
const latestProject = this.projects[this.projects.length - 1];
|
||||
await this.loadProjects(
|
||||
this.currentIid,
|
||||
`beforeId=${latestProject.rowid}`,
|
||||
);
|
||||
await this.loadProjects(`beforeId=${latestProject.rowid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load projects initially
|
||||
* @param identifier of the user
|
||||
* @param issuerDid of the user
|
||||
* @param urlExtra additional url parameters in a string
|
||||
**/
|
||||
async loadProjects(identifier?: IIdentifier, urlExtra: string = "") {
|
||||
const identity = identifier || this.currentIid;
|
||||
async loadProjects(urlExtra: string = "") {
|
||||
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
|
||||
const token: string = await accessToken(identity);
|
||||
await this.projectDataLoader(url, token);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string): Promise<IIdentifier> {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse((account?.identity as string) || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identifier available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
await this.projectDataLoader(url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,7 +389,7 @@ export default class ProjectsView extends Vue {
|
||||
const route = {
|
||||
path: "/project/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,14 +400,14 @@ export default class ProjectsView extends Vue {
|
||||
const route = {
|
||||
name: "new-edit-project",
|
||||
};
|
||||
this.$router.push(route);
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,17 +415,38 @@ export default class ProjectsView extends Vue {
|
||||
* @param url the url used to fetch the data
|
||||
* @param token Authorization token
|
||||
**/
|
||||
async offerDataLoader(url: string, token: string) {
|
||||
const headers: { [key: string]: string } = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
async offerDataLoader(url: string) {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200 || !resp.data.data) {
|
||||
this.offers = this.offers.concat(resp.data.data);
|
||||
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
// add one-by-one as they retrieve project names, potentially from the server
|
||||
for (const offer of resp.data.data) {
|
||||
if (offer.fulfillsPlanHandleId) {
|
||||
const project = await getPlanFromCache(
|
||||
offer.fulfillsPlanHandleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
const projectName = project?.name as string;
|
||||
console.log(
|
||||
"now have name for",
|
||||
offer.fulfillsPlanHandleId,
|
||||
projectName,
|
||||
);
|
||||
this.projectNameFromHandleId[offer.fulfillsPlanHandleId] =
|
||||
projectName;
|
||||
console.log(
|
||||
"now have a real name for",
|
||||
offer.fulfillsPlanHandleId,
|
||||
this.projectNameFromHandleId[offer.fulfillsPlanHandleId],
|
||||
);
|
||||
}
|
||||
this.offers = this.offers.concat([offer]);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Bad server response & data for offers:",
|
||||
@@ -465,20 +487,18 @@ export default class ProjectsView extends Vue {
|
||||
async loadMoreOfferData(payload: boolean) {
|
||||
if (this.offers.length > 0 && payload) {
|
||||
const latestOffer = this.offers[this.offers.length - 1];
|
||||
await this.loadOffers(this.currentIid, `&beforeId=${latestOffer.jwtId}`);
|
||||
await this.loadOffers(`&beforeId=${latestOffer.jwtId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load offers initially
|
||||
* @param identifier of the user
|
||||
* @param issuerDid of the user
|
||||
* @param urlExtra additional url parameters in a string
|
||||
**/
|
||||
async loadOffers(identifier?: IIdentifier, urlExtra: string = "") {
|
||||
const identity = identifier || this.currentIid;
|
||||
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${identity.did}${urlExtra}`;
|
||||
const token: string = await accessToken(identity);
|
||||
await this.offerDataLoader(url, token);
|
||||
async loadOffers(urlExtra: string = "") {
|
||||
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`;
|
||||
await this.offerDataLoader(url);
|
||||
}
|
||||
|
||||
public computedOfferTabClassNames() {
|
||||
|
||||
@@ -97,6 +97,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
todayOrPreviousStartDate = "";
|
||||
|
||||
async mounted() {
|
||||
// use the time zone for Bountiful
|
||||
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
|
||||
if (currentOrPreviousSat.weekday < 6) {
|
||||
// it's not Saturday or Sunday,
|
||||
@@ -123,7 +124,8 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
|
||||
try {
|
||||
const hoursNum = libsUtil.numberOrZero(this.hoursStr);
|
||||
const identity = await libsUtil.getIdentity(activeDid);
|
||||
|
||||
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||
|
||||
// first send the claim for time given
|
||||
let timeSuccess = false;
|
||||
@@ -131,7 +133,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
const timeResult = await createAndSubmitGive(
|
||||
axios,
|
||||
apiServer,
|
||||
identity,
|
||||
activeDid,
|
||||
activeDid,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -152,7 +154,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
timeResult?.error?.userMessage ||
|
||||
"There was an error sending the time.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -162,7 +164,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
if (this.attended) {
|
||||
const attendResult = await createAndSubmitClaim(
|
||||
bvcMeetingJoinClaim(activeDid, this.todayOrPreviousStartDate),
|
||||
identity,
|
||||
activeDid,
|
||||
apiServer,
|
||||
axios,
|
||||
);
|
||||
@@ -179,7 +181,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
attendResult?.error?.userMessage ||
|
||||
"There was an error sending the attendance.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
title: "Success",
|
||||
text: actions,
|
||||
},
|
||||
-1,
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,9 +212,9 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: error.userMessage || "There was an error sending claims.",
|
||||
text: error.userMessage || "There was an error sending the claims.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
}}
|
||||
<a @click="onClickLoadClaim(record.id)">
|
||||
<fa
|
||||
icon="circle-info"
|
||||
icon="file-lines"
|
||||
class="pl-2 text-blue-500 cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
@@ -82,6 +82,16 @@
|
||||
page.
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
|
||||
<span>
|
||||
{{
|
||||
claimCountByUser === 1
|
||||
? "There is 1 other claim by you"
|
||||
: `There are ${claimCountByUser} other claims by you`
|
||||
}}
|
||||
which you don't need to confirm.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl m-2">Anything else?</h2>
|
||||
@@ -128,28 +138,26 @@
|
||||
import axios from "axios";
|
||||
import { DateTime } from "luxon";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import {
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
claimSpecialDescription,
|
||||
containsHiddenDid,
|
||||
createAndSubmitConfirmation,
|
||||
createAndSubmitGive,
|
||||
ErrorResult,
|
||||
GenericServerRecord,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
getHeaders,
|
||||
ErrorResult,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
|
||||
@Component({
|
||||
methods: { claimSpecialDescription },
|
||||
@@ -165,8 +173,9 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
claimCountByUser = 0;
|
||||
claimCountWithHidden = 0;
|
||||
claimsToConfirm: GenericServerRecord[] = [];
|
||||
claimsToConfirm: GenericCredWrapper<GenericVerifiableCredential>[] = [];
|
||||
claimsToConfirmSelected: string[] = [];
|
||||
description = "breakfast";
|
||||
loadingConfirms = true;
|
||||
@@ -202,16 +211,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
const account: Account | undefined = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(this.activeDid)
|
||||
.first();
|
||||
const identity: IIdentifier = JSON.parse(
|
||||
(account?.identity as string) || "null",
|
||||
);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + (await accessToken(identity)),
|
||||
};
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
try {
|
||||
const response = await fetch(
|
||||
this.apiServer +
|
||||
@@ -223,12 +223,13 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("Bad response", response);
|
||||
console.error("Bad response", response);
|
||||
throw new Error("Bad response when retrieving claims.");
|
||||
}
|
||||
await response.json().then((data) => {
|
||||
const dataByOthers = R.reject(
|
||||
(claim: GenericServerRecord) => claim.issuer === this.activeDid,
|
||||
(claim: GenericCredWrapper<GenericVerifiableCredential>) =>
|
||||
claim.issuer === this.activeDid,
|
||||
data,
|
||||
);
|
||||
const dataByOthersWithoutHidden = R.reject(
|
||||
@@ -236,6 +237,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
dataByOthers,
|
||||
);
|
||||
this.claimsToConfirm = dataByOthersWithoutHidden;
|
||||
this.claimCountByUser = data.length - dataByOthers.length;
|
||||
this.claimCountWithHidden =
|
||||
dataByOthers.length - dataByOthersWithoutHidden.length;
|
||||
});
|
||||
@@ -248,7 +250,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
title: "Error",
|
||||
text: "There was an error retrieving today's claims to confirm.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
this.loadingConfirms = false;
|
||||
@@ -258,12 +260,12 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
|
||||
async record() {
|
||||
try {
|
||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
|
||||
|
||||
// in parallel, make a confirmation for each selected claim and send them all to the server
|
||||
const confirmResults = await Promise.allSettled(
|
||||
@@ -274,9 +276,8 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
if (!record) {
|
||||
return { type: "error", error: "Record not found." };
|
||||
}
|
||||
const identity = await libsUtil.getIdentity(this.activeDid);
|
||||
return createAndSubmitConfirmation(
|
||||
identity,
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
@@ -300,7 +301,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
title: "Error",
|
||||
text: `There was an error sending ${howMany} of the confirmations.`,
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +311,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
const giveResult = await createAndSubmitGive(
|
||||
axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
this.activeDid,
|
||||
undefined,
|
||||
this.activeDid,
|
||||
this.description,
|
||||
@@ -330,7 +331,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
(giveResult as ErrorResult)?.error?.userMessage ||
|
||||
"There was an error sending that give.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +356,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
title: "Success",
|
||||
text: actions,
|
||||
},
|
||||
-1,
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -369,7 +370,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
title: "Error",
|
||||
text: error.userMessage || "There was an error sending claims.",
|
||||
},
|
||||
-1,
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-4">
|
||||
This location is only stored on your device. It is used to show you more
|
||||
appropriate projects but is not stored on any servers.
|
||||
This location is only stored on your device. It is sometimes sent from
|
||||
your device to run searches but it is not stored on our servers.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -64,10 +64,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 600px; width: 800px">
|
||||
<div class="mb-4 aspect-video">
|
||||
<l-map
|
||||
ref="map"
|
||||
:center="[localCenterLat, localCenterLong]"
|
||||
class="!z-40 rounded-md"
|
||||
v-model:zoom="localZoom"
|
||||
@click="setMapPoint"
|
||||
>
|
||||
@@ -104,6 +105,7 @@ import {
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
@@ -197,7 +199,7 @@ export default class DiscoverView extends Vue {
|
||||
},
|
||||
};
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [newSearchBox],
|
||||
});
|
||||
this.searchBox = newSearchBox;
|
||||
@@ -208,11 +210,11 @@ export default class DiscoverView extends Vue {
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Saved",
|
||||
text: "That has been saved in your preferences.",
|
||||
text: "That has been saved in your preferences. You can now filter by it on your home screen feed.",
|
||||
},
|
||||
-1,
|
||||
7000,
|
||||
);
|
||||
this.$router.back();
|
||||
(this.$router as Router).back();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -244,8 +246,9 @@ export default class DiscoverView extends Vue {
|
||||
public async forgetSearchBox() {
|
||||
try {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [],
|
||||
filterFeedByNearby: false,
|
||||
});
|
||||
this.searchBox = null;
|
||||
this.localCenterLat = 0;
|
||||
|
||||
@@ -33,30 +33,65 @@
|
||||
<p class="text-center mb-4">
|
||||
<b class="text-red-600">BEWARE!</b> Anyone who has this seed phrase will
|
||||
be able impersonate you and take over any digital holdings based on it.
|
||||
Reveal it when you are somewhere only you can see your screen, and
|
||||
record it somewhere only you have access.
|
||||
<i>Don't take a screenshot or send it to any online service.</i>
|
||||
Reveal it when you are somewhere private, when only you can see your
|
||||
screen, and record it somewhere only you have access. A password manager
|
||||
is a good idea, and so is a piece of paper in a vault.
|
||||
<i
|
||||
>We recommend you do NOT take a screenshot or send it to any online
|
||||
service.</i
|
||||
>
|
||||
</p>
|
||||
|
||||
<p v-if="numAccounts > 1">
|
||||
<b class="text-orange-600">Note:</b> You have more than one identifier
|
||||
stored in this browser. If they are all based on the same seed as the
|
||||
current identifier, this one backup is sufficient; however, if you have
|
||||
different seeds for other identifiers, you will have to back them up
|
||||
separately.
|
||||
current identifier, this one backup is sufficient, as long as you also
|
||||
record the derivation path. However, if you have different seeds for
|
||||
other identifiers, you will have to back them up separately.
|
||||
</p>
|
||||
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
|
||||
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
|
||||
{{ activeAccount.mnemonic }}
|
||||
<button
|
||||
v-show="!showCopiedSeed"
|
||||
@click="
|
||||
doCopyTwoSecRedo(
|
||||
activeAccount.mnemonic as string,
|
||||
() => (showCopiedSeed = !showCopiedSeed),
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
</button>
|
||||
<span v-show="showCopiedSeed" class="text-sm text-green-500">
|
||||
Copied
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
Derivation Path: {{ activeAccount.derivationPath }}
|
||||
<button
|
||||
v-show="!showCopiedDeri"
|
||||
@click="
|
||||
doCopyTwoSecRedo(
|
||||
activeAccount.derivationPath as string,
|
||||
() => (showCopiedDeri = !showCopiedDeri),
|
||||
)
|
||||
"
|
||||
>
|
||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
||||
</button>
|
||||
<span v-show="showCopiedDeri" class="text-sm text-green-500"
|
||||
>Copied</span
|
||||
>
|
||||
</p>
|
||||
<button
|
||||
v-else
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
||||
@click="showSeedPhrase"
|
||||
@click="showSeed = true"
|
||||
>
|
||||
Reveal my Seed Phrase
|
||||
</button>
|
||||
|
||||
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
|
||||
{{ activeAccount.mnemonic }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>You do not have an active identifier.</div>
|
||||
@@ -64,24 +99,24 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
interface Account {
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class SeedBackupView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeAccount: Account | null | undefined = null;
|
||||
numAccounts = 0;
|
||||
showCopiedDeri = false;
|
||||
showCopiedSeed = false;
|
||||
showSeed = false;
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
@@ -109,8 +144,12 @@ export default class SeedBackupView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
showSeedPhrase() {
|
||||
this.showSeed = true;
|
||||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||||
doCopyTwoSecRedo(text: string, fn: () => void) {
|
||||
fn();
|
||||
useClipboard()
|
||||
.copy(text)
|
||||
.then(() => setTimeout(fn, 2000));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
228
src/views/SharedPhotoView.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Image
|
||||
</h1>
|
||||
<div v-if="imageBlob">
|
||||
<div v-if="uploading" class="text-center mb-4">
|
||||
<fa icon="spinner" class="fa-spin-pulse" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-center mb-4">Choose how to use this image</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<button
|
||||
@click="recordGift"
|
||||
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
<fa icon="gift" class="fa-fw" />
|
||||
Record a Gift
|
||||
</button>
|
||||
<button
|
||||
@click="recordProfile"
|
||||
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
<fa icon="circle-user" class="fa-fw" />
|
||||
Save as Profile Image
|
||||
</button>
|
||||
<button
|
||||
@click="cancel"
|
||||
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
<fa icon="ban" class="fa-fw" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<PhotoDialog ref="photoDialog" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="URL.createObjectURL(imageBlob)"
|
||||
alt="Shared Image"
|
||||
class="rounded mt-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center mb-4">
|
||||
<p>No image found.</p>
|
||||
<p class="mt-4">
|
||||
If you shared an image, the cause is usually that you do not have the
|
||||
recent version of this app, or that the app has not refreshed the
|
||||
service code underneath. To fix this, first make sure you have latest
|
||||
version by comparing your version at the bottom of "Help" with the
|
||||
version at the bottom of https://timesafari.app/help in a browser. After
|
||||
that, it may eventually work, but you can speed up the process by
|
||||
clearing your data cache (in the browser on mobile, even if you
|
||||
installed it) and/or reinstalling the app (after backing up all your
|
||||
data, of course).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationRaw, Router } from "vue-router";
|
||||
|
||||
import PhotoDialog from "@/components/PhotoDialog.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import {
|
||||
DEFAULT_IMAGE_API_SERVER,
|
||||
IMAGE_TYPE_PROFILE,
|
||||
NotificationIface,
|
||||
} from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { base64ToBlob, SHARED_PHOTO_BASE64_KEY } from "@/libs/util";
|
||||
|
||||
@Component({ components: { PhotoDialog, QuickNav } })
|
||||
export default class SharedPhotoView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
activeDid: string | undefined = undefined;
|
||||
imageBlob: Blob | undefined = undefined;
|
||||
imageFileName: string | undefined = undefined;
|
||||
uploading = false;
|
||||
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async mounted() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid as string;
|
||||
|
||||
const temp = await db.temp.get(SHARED_PHOTO_BASE64_KEY);
|
||||
const imageB64 = temp?.blobB64 as string;
|
||||
if (temp) {
|
||||
this.imageBlob = base64ToBlob(imageB64);
|
||||
|
||||
// clear the temp image
|
||||
db.temp.delete(SHARED_PHOTO_BASE64_KEY);
|
||||
|
||||
this.imageFileName = (this.$route as Router).query[
|
||||
"fileName"
|
||||
] as string;
|
||||
} else {
|
||||
console.error("No appropriate image found in temp storage.", temp);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Got an error loading an identifier:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Got an error loading this data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async recordGift() {
|
||||
await this.sendToImageServer("GiveAction").then((url) => {
|
||||
if (url) {
|
||||
const route = {
|
||||
name: "gifted-details",
|
||||
// this might be wrong since "name" goes with params, but it works so test well when you change it
|
||||
query: {
|
||||
destinationPathAfter: "/",
|
||||
hideBackButton: true,
|
||||
imageUrl: url,
|
||||
recipientDid: this.activeDid,
|
||||
},
|
||||
} as RouteLocationRaw;
|
||||
(this.$router as Router).push(route);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recordProfile() {
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
async (imgUrl) => {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
profileImageUrl: imgUrl,
|
||||
});
|
||||
(this.$router as Router).push({ name: "account" });
|
||||
},
|
||||
IMAGE_TYPE_PROFILE,
|
||||
true,
|
||||
this.imageBlob,
|
||||
this.imageFileName,
|
||||
);
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
this.imageBlob = undefined;
|
||||
this.imageFileName = undefined;
|
||||
(this.$router as Router).push({ name: "home" });
|
||||
}
|
||||
|
||||
async sendToImageServer(imageType: string) {
|
||||
this.uploading = true;
|
||||
|
||||
let result;
|
||||
try {
|
||||
// send the image to the server
|
||||
const token = await accessToken(this.activeDid);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
// axios fills in Content-Type of multipart/form-data
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"image",
|
||||
this.imageBlob as Blob,
|
||||
this.imageFileName as string,
|
||||
);
|
||||
formData.append("claimType", imageType);
|
||||
|
||||
const response = await axios.post(
|
||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||
formData,
|
||||
{ headers },
|
||||
);
|
||||
if (response?.data?.url) {
|
||||
this.imageBlob = undefined;
|
||||
this.imageFileName = undefined;
|
||||
result = response.data.url as string;
|
||||
} else {
|
||||
console.error("Problem uploading the image", response.data);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
"There was a problem saving the picture. " +
|
||||
(response?.data?.message || ""),
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
} catch (error) {
|
||||
console.error("Error uploading the image", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error saving the picture.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -17,71 +17,119 @@
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Start Here
|
||||
Generate an Identity
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- id used by puppeteer test script -->
|
||||
<div id="start-question" class="mt-8">
|
||||
<p class="text-center text-xl font-light">
|
||||
Do you want a new identifier of your own?
|
||||
</p>
|
||||
<p class="text-center font-light">
|
||||
If you haven't used this before, click "Yes" to generate a new
|
||||
identifier.
|
||||
</p>
|
||||
<p class="text-center mb-4 font-light">
|
||||
Only click "No" if you have a seed of 12 or 24 words generated
|
||||
elsewhere.
|
||||
</p>
|
||||
<a
|
||||
@click="onClickYes()"
|
||||
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Yes, generate one
|
||||
</a>
|
||||
<a
|
||||
@click="onClickNo()"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-2"
|
||||
>
|
||||
No, I have a seed
|
||||
</a>
|
||||
<a
|
||||
v-if="numAccounts > 0"
|
||||
@click="onClickDerive()"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-2"
|
||||
>
|
||||
Derive new address from existing seed
|
||||
</a>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<p class="text-center text-xl font-light">
|
||||
How do you want to create this identifier?
|
||||
</p>
|
||||
<p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
|
||||
A <strong>passkey</strong> is easy to manage, though it is less
|
||||
interoperable with other systems for advanced uses.
|
||||
<a
|
||||
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
|
||||
target="_blank"
|
||||
>
|
||||
<fa icon="info-circle" class="fa-fw text-blue-500" />
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-center font-light mt-4">
|
||||
A <strong>new seed</strong> allows you full control over the keys,
|
||||
though you are responsible for backups.
|
||||
<a
|
||||
href="https://www.perplexity.ai/search/what-is-a-seed-phrase-OqiP9foVRXidr_2le5OFKA"
|
||||
target="_blank"
|
||||
>
|
||||
<fa icon="info-circle" class="fa-fw text-blue-500" />
|
||||
</a>
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
|
||||
<a
|
||||
v-if="PASSKEYS_ENABLED"
|
||||
@click="onClickNewPasskey()"
|
||||
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
|
||||
>
|
||||
Generate one with a passkey
|
||||
</a>
|
||||
<a
|
||||
@click="onClickNewSeed()"
|
||||
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
|
||||
>
|
||||
Generate one with a new seed
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-center font-light mt-4">
|
||||
You can also import an existing seed or derive a new address from an
|
||||
existing seed.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||
<a
|
||||
@click="onClickNo()"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"
|
||||
>
|
||||
You have a seed
|
||||
</a>
|
||||
<a
|
||||
v-if="numAccounts > 0"
|
||||
@click="onClickDerive()"
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"
|
||||
>
|
||||
Derive new address from existing seed
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB } from "@/db/index";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import { AppString, PASSKEYS_ENABLED } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { registerSaveAndActivatePasskey } from "@/libs/util";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class StartView extends Vue {
|
||||
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||
|
||||
givenName = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.givenName = settings?.firstName || "";
|
||||
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public onClickYes() {
|
||||
this.$router.push({ name: "new-identifier" });
|
||||
public onClickNewSeed() {
|
||||
(this.$router as Router).push({ name: "new-identifier" });
|
||||
}
|
||||
|
||||
public async onClickNewPasskey() {
|
||||
const keyName =
|
||||
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : "");
|
||||
await registerSaveAndActivatePasskey(keyName);
|
||||
(this.$router as Router).push({ name: "account" });
|
||||
}
|
||||
|
||||
public onClickNo() {
|
||||
this.$router.push({ name: "import-account" });
|
||||
(this.$router as Router).push({ name: "import-account" });
|
||||
}
|
||||
|
||||
public onClickDerive() {
|
||||
this.$router.push({ name: "import-derive" });
|
||||
(this.$router as Router).push({ name: "import-derive" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
|
||||
|
||||
<button
|
||||
@click="
|
||||
@@ -153,13 +153,316 @@
|
||||
Notif OFF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
|
||||
Populates the "shared-photo" view as if they used "share_target".
|
||||
<input type="file" data-testid="fileInput" @change="uploadFile" />
|
||||
<router-link
|
||||
v-if="showFileNextStep()"
|
||||
:to="{
|
||||
name: 'shared-photo',
|
||||
query: { fileName },
|
||||
}"
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
|
||||
data-testid="fileUploadButton"
|
||||
>
|
||||
Go to Shared Page
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
|
||||
See console for results.
|
||||
<br />
|
||||
See existing passkeys in Chrome at: chrome://settings/passkeys
|
||||
<br />
|
||||
Active DID: {{ activeDid || "nothing, which" }}
|
||||
{{ credIdHex ? "has a passkey ID" : "has no passkey ID" }}
|
||||
|
||||
<div>
|
||||
Register Passkey
|
||||
<button
|
||||
@click="register()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Simplewebauthn
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Create JWT
|
||||
<button
|
||||
@click="createJwtSimplewebauthn()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Simplewebauthn
|
||||
</button>
|
||||
<button
|
||||
@click="createJwtNavigator()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Navigator
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="jwt">
|
||||
Verify New JWT
|
||||
<button
|
||||
@click="verifySimplewebauthn()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Simplewebauthn
|
||||
</button>
|
||||
<button
|
||||
@click="verifyWebCrypto()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
WebCrypto
|
||||
</button>
|
||||
<button
|
||||
@click="verifyP256()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
p256 - broken
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>Verify New JWT -- requires creation first</div>
|
||||
<button
|
||||
@click="verifyMyJwt()"
|
||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Verify Hard-Coded JWT
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Buffer } from "buffer/";
|
||||
import { Base64URLString } from "@simplewebauthn/types";
|
||||
import { ref } from "vue";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { AppString, NotificationIface } from "@/constants/app";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import * as vcLib from "@/libs/crypto/vc";
|
||||
import {
|
||||
PeerSetup,
|
||||
verifyJwtP256,
|
||||
verifyJwtSimplewebauthn,
|
||||
verifyJwtWebCrypto,
|
||||
} from "@/libs/crypto/vc/passkeyDidPeer";
|
||||
import {
|
||||
AccountKeyInfo,
|
||||
blobToBase64,
|
||||
getAccount,
|
||||
registerAndSavePasskey,
|
||||
SHARED_PHOTO_BASE64_KEY,
|
||||
} from "@/libs/util";
|
||||
|
||||
const inputFileNameRef = ref<Blob>();
|
||||
|
||||
const TEST_PAYLOAD = {
|
||||
vc: {
|
||||
credentialSubject: {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GiveAction",
|
||||
description: "pizza",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {}
|
||||
export default class Help extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
// for file import
|
||||
fileName?: string;
|
||||
|
||||
// for passkeys
|
||||
credIdHex?: string;
|
||||
activeDid?: string;
|
||||
jwt?: string;
|
||||
peerSetup?: PeerSetup;
|
||||
userName?: string;
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = (settings?.activeDid as string) || "";
|
||||
this.userName = settings?.firstName as string;
|
||||
|
||||
await accountsDB.open();
|
||||
const account: { identity?: string } | undefined = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(this.activeDid)
|
||||
.first();
|
||||
if (this.activeDid) {
|
||||
if (account) {
|
||||
this.credIdHex = account.passkeyCredIdHex as string;
|
||||
} else {
|
||||
alert("No account found for DID " + this.activeDid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(event: Event) {
|
||||
inputFileNameRef.value = event.target?.["files"][0];
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||
// ... plus it has a `type` property from my testing
|
||||
const file = inputFileNameRef.value;
|
||||
if (file != null) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const data = e.target?.result as ArrayBuffer;
|
||||
if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], {
|
||||
type: file.type,
|
||||
});
|
||||
const blobB64 = await blobToBase64(blob);
|
||||
this.fileName = file.name as string;
|
||||
const temp = await db.temp.get(SHARED_PHOTO_BASE64_KEY);
|
||||
if (temp) {
|
||||
await db.temp.update(SHARED_PHOTO_BASE64_KEY, { blobB64 });
|
||||
} else {
|
||||
await db.temp.add({ id: SHARED_PHOTO_BASE64_KEY, blobB64 });
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file as Blob);
|
||||
}
|
||||
}
|
||||
|
||||
showFileNextStep() {
|
||||
return !!inputFileNameRef.value;
|
||||
}
|
||||
|
||||
public async register() {
|
||||
const DEFAULT_USERNAME = AppString.APP_NAME + " Tester";
|
||||
if (!this.userName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "No Name",
|
||||
text: "You should have a name to attach to this passkey. Would you like to enter your own name first?",
|
||||
onNo: async () => {
|
||||
this.userName = DEFAULT_USERNAME;
|
||||
},
|
||||
onYes: async () => {
|
||||
(this.$router as Router).push({ name: "new-edit-account" });
|
||||
},
|
||||
noText: "try again and use " + DEFAULT_USERNAME,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const account = await registerAndSavePasskey(
|
||||
AppString.APP_NAME + " - " + this.userName,
|
||||
);
|
||||
this.activeDid = account.did;
|
||||
this.credIdHex = account.passkeyCredIdHex;
|
||||
}
|
||||
|
||||
public async createJwtSimplewebauthn() {
|
||||
const account: AccountKeyInfo | undefined = await getAccount(
|
||||
this.activeDid || "",
|
||||
);
|
||||
if (!vcLib.isFromPasskey(account)) {
|
||||
alert(`The DID ${this.activeDid} is not passkey-enabled.`);
|
||||
return;
|
||||
}
|
||||
this.peerSetup = new PeerSetup();
|
||||
this.jwt = await this.peerSetup.createJwtSimplewebauthn(
|
||||
this.activeDid as string,
|
||||
TEST_PAYLOAD,
|
||||
this.credIdHex as string,
|
||||
);
|
||||
console.log("simple jwt4url", this.jwt);
|
||||
}
|
||||
|
||||
public async createJwtNavigator() {
|
||||
const account: AccountKeyInfo | undefined = await getAccount(
|
||||
this.activeDid || "",
|
||||
);
|
||||
if (!vcLib.isFromPasskey(account)) {
|
||||
alert(`The DID ${this.activeDid} is not passkey-enabled.`);
|
||||
return;
|
||||
}
|
||||
this.peerSetup = new PeerSetup();
|
||||
this.jwt = await this.peerSetup.createJwtNavigator(
|
||||
this.activeDid as string,
|
||||
TEST_PAYLOAD,
|
||||
this.credIdHex as string,
|
||||
);
|
||||
console.log("lower jwt4url", this.jwt);
|
||||
}
|
||||
|
||||
public async verifyP256() {
|
||||
const decoded = await verifyJwtP256(
|
||||
this.credIdHex as string,
|
||||
this.activeDid as string,
|
||||
this.peerSetup?.authenticatorData as ArrayBuffer,
|
||||
this.peerSetup?.challenge as Uint8Array,
|
||||
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
|
||||
this.peerSetup?.signature as Base64URLString,
|
||||
);
|
||||
console.log("decoded", decoded);
|
||||
}
|
||||
|
||||
public async verifySimplewebauthn() {
|
||||
const decoded = await verifyJwtSimplewebauthn(
|
||||
this.credIdHex as string,
|
||||
this.activeDid as string,
|
||||
this.peerSetup?.authenticatorData as ArrayBuffer,
|
||||
this.peerSetup?.challenge as Uint8Array,
|
||||
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
|
||||
this.peerSetup?.signature as Base64URLString,
|
||||
);
|
||||
console.log("decoded", decoded);
|
||||
}
|
||||
|
||||
public async verifyWebCrypto() {
|
||||
const decoded = await verifyJwtWebCrypto(
|
||||
this.credIdHex as string,
|
||||
this.activeDid as string,
|
||||
this.peerSetup?.authenticatorData as ArrayBuffer,
|
||||
this.peerSetup?.challenge as Uint8Array,
|
||||
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
|
||||
this.peerSetup?.signature as Base64URLString,
|
||||
);
|
||||
console.log("decoded", decoded);
|
||||
}
|
||||
|
||||
public async verifyMyJwt() {
|
||||
const did =
|
||||
"did:peer:0zKMFjvUgYrM1hXwDciYHiA9MxXtJPXnRLJvqoMNAKoDLX9pKMWLb3VDsgua1p2zW1xXRsjZSTNsfvMnNyMS7dB4k7NAhFwL3pXBrBXgyYJ9ri";
|
||||
const jwt =
|
||||
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
|
||||
const pieces = jwt.split(".");
|
||||
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
|
||||
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
|
||||
const clientJSON = Buffer.from(
|
||||
payload["ClientDataJSONB64URL"],
|
||||
"base64",
|
||||
).toString();
|
||||
const clientData = JSON.parse(clientJSON);
|
||||
const challenge = clientData.challenge;
|
||||
const signatureB64URL = pieces[2];
|
||||
const decoded = await verifyJwtWebCrypto(
|
||||
this.credIdHex as string,
|
||||
did,
|
||||
authData,
|
||||
challenge,
|
||||
payload["ClientDataJSONB64URL"],
|
||||
signatureB64URL,
|
||||
);
|
||||
console.log("decoded", decoded);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||