forked from trent_larson/crowd-funder-for-time-pwa
Compare commits
98 Commits
seed-backu
...
friend-tec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f15fde25 | ||
| 2db662c125 | |||
| b7892f4dfa | |||
|
|
3bbb138299 | ||
|
|
5b5c631001 | ||
|
|
e60b56a0b0 | ||
|
|
d3e025c293 | ||
|
|
6f4027f614 | ||
| 249811efe3 | |||
| bd2455458f | |||
| a053c48819 | |||
| 9486142b2a | |||
|
|
2fba7f2a55 | ||
|
|
31d13b9143 | ||
|
|
852bd93f3f | ||
| b707bfce40 | |||
| bdb8e2e32a | |||
| 06b173e861 | |||
| 6a8b9d36a7 | |||
| 52a6451a2d | |||
| 4b9cbd0e9f | |||
| a5e0c847b1 | |||
|
|
fd43da93a5 | ||
| b59bcf249a | |||
| b05b602acd | |||
| b8aaffbf8d | |||
|
|
5501ac1a2f | ||
|
|
b514d64068 | ||
|
|
c4537420b4 | ||
|
|
5f50338dd0 | ||
| 308386d829 | |||
| 999d7abc04 | |||
| f7f947bfdd | |||
| 26d9b134c7 | |||
| 43f942c905 | |||
| 8ee610c1bc | |||
| 8d15b7bfb8 | |||
| 5c57ee3e72 | |||
|
|
3f7bcbfd76 | ||
| ef0988c9ec | |||
| 22de6113e9 | |||
| 87139f203c | |||
| c8de13d376 | |||
| 2ccfb283b4 | |||
| 552fce3281 | |||
| 12de3dec4f | |||
| b171e1ae13 | |||
| dc54006fca | |||
| 9b4db018f5 | |||
| 519f320a2e | |||
|
|
f1b3094026 | ||
|
|
e5ad87f4d5 | ||
|
|
7de6171911 | ||
|
|
bb6bacac97 | ||
|
|
40fc6a29a4 | ||
|
|
9ec19fa4ee | ||
| 28b20f86ea | |||
| 502109de4b | |||
| 97274a701d | |||
| 81a6d73f2f | |||
| 5804f692b7 | |||
| 257aa8d49e | |||
| 34806b514b | |||
| 0024238ca8 | |||
| 0af05b4b0d | |||
| b9d59eb642 | |||
| 0c05505c46 | |||
| 98c093f655 | |||
| 88112e0629 | |||
|
|
6ab92a83bd | ||
|
|
bfc52151c0 | ||
|
|
868b5413de | ||
|
|
50005a0dc3 | ||
|
|
9247b6ed1f | ||
|
|
75f26ccf2d | ||
|
|
bfd2498906 | ||
|
|
4933017e9c | ||
|
|
18c23451bb | ||
| 6d1756b4a5 | |||
| ac4c92d8e8 | |||
| 937a3cb6c6 | |||
| bf6830a1a8 | |||
|
|
5addc3c206 | ||
|
|
69f2f3cfd2 | ||
| be348461f1 | |||
| 6e2c596030 | |||
|
|
c502869c5f | ||
|
|
b7aacd63e6 | ||
|
|
5bc0e27b30 | ||
|
|
a4fe94f081 | ||
|
|
8de95566df | ||
|
|
97569697f6 | ||
|
|
b9ed9d748b | ||
|
|
790d44db81 | ||
| e2bf469dc1 | |||
| 592ffacebc | |||
| b706e65598 | |||
|
|
6e3066ae92 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,13 +1,16 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
signature.bin
|
||||
*.pem
|
||||
verified.txt
|
||||
|
||||
*~
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -11,6 +11,9 @@ npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
@@ -96,6 +99,9 @@ See https://tea.xyz
|
||||
|
||||
### Reference Material
|
||||
|
||||
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
|
||||
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
|
||||
|
||||
```
|
||||
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
Prerequisites:
|
||||
|
||||
jq
|
||||
|
||||
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
|
||||
|
||||
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
|
||||
|
||||
25
openssl_signing_console.sh
Executable file
25
openssl_signing_console.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
||||
openssl ec -in private.pem -pubout -out public.pem
|
||||
|
||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
||||
|
||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
||||
|
||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
|
||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
|
||||
|
||||
signing_input="$header_b64.$payload_b64"
|
||||
|
||||
echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem -out signature.bin
|
||||
|
||||
# Read binary signature from file and encode it to Base64 URL-Safe format
|
||||
signature_b64=$(base64 -w 0 < signature.bin | tr -d '=' | tr '+' '-' | tr '/' '_')
|
||||
|
||||
# Construct the JWT
|
||||
jwt="$signing_input.$signature_b64"
|
||||
|
||||
openssl dgst -sha256 -verify public.pem -signature signature.bin -out verified.txt <(echo -n "$signing_input")
|
||||
|
||||
|
||||
33266
package-lock.json
generated
33266
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,15 +52,16 @@
|
||||
"vue": "^3.3.4",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "^2.1.20",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-router": "^4.2.3",
|
||||
"web-did-resolver": "^2.0.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.4",
|
||||
"@types/ramda": "^0.29.3",
|
||||
"@types/three": "^0.152.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
"@typescript-eslint/parser": "^5.61.0",
|
||||
"@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",
|
||||
@@ -74,6 +75,7 @@
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0-alpha.1",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"postcss": "^8.4.24",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
|
||||
tasks:
|
||||
|
||||
- 08 Scan QR code to import into contacts assignee:matthew
|
||||
- SEE - https://github.com/gruhn/vue-qrcode-reader
|
||||
|
||||
- in endorser-push-server - mount folder for persistent sqlite DB outside of container
|
||||
- test alerts on all pages -- or refactor to new "notify" (since AlertMessage refactoring may require a change, et. ContactQRScanShowView)
|
||||
- .2 bug - on contacts view, click on "to" & "from" and nothing happens
|
||||
- 01 add a location for a project via map pin :
|
||||
- add with a "location" field containing this: { "geo":{ "@type":"GeoCoordinates", "latitude":40.883944, "longitude":-111.884787 } }
|
||||
- 04 search by a bounding box for local projects (see API by clicking on "Nearby")
|
||||
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:jose
|
||||
- 02 Fix images on projectview - allow choice of image from a pallete of images or a url image.
|
||||
|
||||
- 08 Scan QR code to import into contacts.
|
||||
|
||||
- 40 notifications :
|
||||
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data
|
||||
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
|
||||
|
||||
- refactor UI :
|
||||
- .5 Alerts show at the top and can be missed if you've scrolled down on the page, eg. account data download
|
||||
- .2 Make alerts at the top more visible (because they're currently a similar color and sometimes aren't seen)
|
||||
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
|
||||
|
||||
- Show pop-up or some message confirming that settings & contacts download has been initiated/finished
|
||||
- 01 fix the Discovery map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
|
||||
|
||||
- Ensure each action sent to the server has a confirmation - eg registration
|
||||
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished assignee:matthew assignee-group:ui
|
||||
|
||||
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s) assignee-group:ui
|
||||
- SEE: https://github.com/emmanuelsw/notiwind assignee:jose assignee-group:ui
|
||||
|
||||
- Home Feed & Quick Give screen :
|
||||
- 01 save the feed-viewed status in settings storage ("afterQuery")
|
||||
- 01 quick action - send action, maybe choose via canvas tool https://github.com/konvajs/vue-konva
|
||||
- 01 quick action - send action, maybe choose via canvas tool
|
||||
- SEE: https://github.com/konvajs/vue-konva
|
||||
|
||||
- 24 Move to Vite
|
||||
- 24 Move to Vite assignee:matthew
|
||||
|
||||
- .2 Edit Plan does not have icons across the bottom assignee-group:ui
|
||||
- .5 include the hash of the latest commit, and maybe a version
|
||||
- .5 add link to further project / people when a project pays ahead
|
||||
- .5 add project ID to the URL, to make a project publicly-accessible
|
||||
- .5 remove edit from project page for projects owned by others
|
||||
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
|
||||
- .2 there are three dots at the top of ProjectViewView that refreshes the page but doesn't do anything else
|
||||
- 01 fix images on project page, on discovery page
|
||||
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist
|
||||
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?)
|
||||
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist assignee-group:ui
|
||||
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?) assignee:jose assignee-group:ui
|
||||
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
|
||||
- .2 move 'switch identity' to the advanced section
|
||||
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
||||
|
||||
- Discuss whether the remaining tasks are worthwhile before MVP release.
|
||||
|
||||
- 04 allow user to download claims, mine + ones I can see about me from others
|
||||
- .5 change the derivation path, and regenerate test IDs
|
||||
- 02 allow user to create new DIDs from the same seed phrase (ie. increment derivation path)
|
||||
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages assignee-group:ui
|
||||
- .5 customize favicon assignee-group:ui
|
||||
- .5 Do we want to combine first name & last name?
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?) assignee-group:ui
|
||||
- 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 assignee-group:ui
|
||||
|
||||
- contacts v+ :
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||
- .2 show error to user when adding a duplicate contact
|
||||
@@ -52,17 +62,10 @@ tasks:
|
||||
- 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 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages
|
||||
- .5 customize favicon
|
||||
- 04 allow user to download claims, mine + ones I can see about me from others
|
||||
- Do we want to combine first name & last name?
|
||||
- Show a warning if both giver and recipient are the same (but still allow?)
|
||||
|
||||
- Release Minimum Viable Product :
|
||||
- 08 thorough testing for errors & edge cases
|
||||
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
|
||||
- Add disclaimers.
|
||||
- Rename DB to TimeSafari.
|
||||
- Switch default server to the public server.
|
||||
- Deploy to a server.
|
||||
- Ensure public server has limits that work for group adoption.
|
||||
@@ -102,6 +105,8 @@ tasks:
|
||||
- 40 notifications v+ :
|
||||
- pull, w/ scheduled runs
|
||||
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
177
sample.txt
Normal file
177
sample.txt
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
> kickstart-for-time-pwa@0.1.0 build
|
||||
> vue-cli-service build
|
||||
|
||||
All browser targets in the browserslist configuration have supported ES module.
|
||||
Therefore we don't build two separate bundles for differential loading.
|
||||
|
||||
|
||||
WARNING Compiled with 5 warnings6:06:43 PM
|
||||
|
||||
[eslint]
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/components/World/components/objects/landmarks.js
|
||||
98:11 warning Unexpected console statement no-console
|
||||
133:7 warning Unexpected console statement no-console
|
||||
144:5 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/router/index.ts
|
||||
210:3 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/AccountViewView.vue
|
||||
362:7 warning Unexpected console statement no-console
|
||||
375:7 warning Unexpected console statement no-console
|
||||
404:7 warning Unexpected console statement no-console
|
||||
516:7 warning Unexpected console statement no-console
|
||||
536:7 warning Unexpected console statement no-console
|
||||
630:5 warning Unexpected console statement no-console
|
||||
682:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ContactAmountsView.vue
|
||||
206:9 warning Unexpected console statement no-console
|
||||
233:9 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ContactGiftingView.vue
|
||||
244:9 warning Unexpected console statement no-console
|
||||
267:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ContactsView.vue
|
||||
340:9 warning Unexpected console statement no-console
|
||||
577:9 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/DiscoverView.vue
|
||||
315:9 warning Unexpected console statement no-console
|
||||
343:7 warning Unexpected console statement no-console
|
||||
390:9 warning Unexpected console statement no-console
|
||||
423:7 warning Unexpected console statement no-console
|
||||
532:9 warning Unexpected console statement no-console
|
||||
575:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/HomeView.vue
|
||||
349:9 warning Unexpected console statement no-console
|
||||
498:9 warning Unexpected console statement no-console
|
||||
521:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/IdentitySwitcherView.vue
|
||||
142:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ImportAccountView.vue
|
||||
123:9 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ImportDerivedAccountView.vue
|
||||
159:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/NewEditProjectView.vue
|
||||
183:9 warning Unexpected console statement no-console
|
||||
215:7 warning Unexpected console statement no-console
|
||||
297:13 warning Unexpected console statement no-console
|
||||
320:11 warning Unexpected console statement no-console
|
||||
345:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ProjectViewView.vue
|
||||
387:9 warning Unexpected console statement no-console
|
||||
421:7 warning Unexpected console statement no-console
|
||||
457:7 warning Unexpected console statement no-console
|
||||
552:9 warning Unexpected console statement no-console
|
||||
554:11 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/ProjectsView.vue
|
||||
131:9 warning Unexpected console statement no-console
|
||||
144:7 warning Unexpected console statement no-console
|
||||
221:9 warning Unexpected console statement no-console
|
||||
237:7 warning Unexpected console statement no-console
|
||||
|
||||
/home/matthew/projects/kick-starter-for-time-pwa/src/views/SeedBackupView.vue
|
||||
94:7 warning Unexpected console statement no-console
|
||||
|
||||
✖ 44 problems (0 errors, 44 warnings)
|
||||
|
||||
|
||||
You may use special comments to disable some warnings.
|
||||
Use // eslint-disable-next-line to ignore the next line.
|
||||
Use /* eslint-disable */ to ignore all warnings in a file.
|
||||
warning
|
||||
|
||||
/models/lupine_plant/textures/lambert2SG_baseColor.png is 3.75 MB, and won't be precached. Configure maximumFileSizeToCacheInBytes to change this limit.
|
||||
|
||||
warning
|
||||
|
||||
/models/lupine_plant/textures/lambert2SG_normal.png is 4.91 MB, and won't be precached. Configure maximumFileSizeToCacheInBytes to change this limit.
|
||||
|
||||
warning
|
||||
|
||||
asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
|
||||
This can impact web performance.
|
||||
Assets:
|
||||
js/project.44f30c9f.js (318 KiB)
|
||||
js/statistics.8a97010a.js (586 KiB)
|
||||
js/chunk-vendors.a4845bfb.js (411 KiB)
|
||||
js/705.f6a6ce2a.js (252 KiB)
|
||||
img/textures/leafy-autumn-forest-floor.jpg (705 KiB)
|
||||
models/lupine_plant/textures/lambert2SG_baseColor.png (3.58 MiB)
|
||||
models/lupine_plant/textures/lambert2SG_normal.png (4.69 MiB)
|
||||
|
||||
warning
|
||||
|
||||
entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
|
||||
Entrypoints:
|
||||
app (447 KiB)
|
||||
js/chunk-vendors.a4845bfb.js
|
||||
css/app.8f21529c.css
|
||||
js/app.8833cebc.js
|
||||
|
||||
|
||||
File Size Gzipped
|
||||
|
||||
dist/js/statistics.8a97010a.js 585.72 KiB 148.80 KiB
|
||||
dist/js/chunk-vendors.a4845bfb.js 411.44 KiB 137.82 KiB
|
||||
dist/js/project.44f30c9f.js 317.61 KiB 78.67 KiB
|
||||
dist/js/705.f6a6ce2a.js 251.66 KiB 87.12 KiB
|
||||
dist/js/891.33615e4f.js 147.32 KiB 42.09 KiB
|
||||
dist/js/153.e2c8e249.js 146.26 KiB 42.21 KiB
|
||||
dist/js/820.13565d16.js 66.10 KiB 18.33 KiB
|
||||
dist/js/contact-qr.e170ec33.js 54.85 KiB 15.63 KiB
|
||||
dist/js/772.7b4c53a7.js 30.29 KiB 7.21 KiB
|
||||
dist/js/361.898a4525.js 27.40 KiB 8.19 KiB
|
||||
dist/js/account.77d86130.js 17.51 KiB 5.93 KiB
|
||||
dist/js/app.8833cebc.js 17.31 KiB 5.84 KiB
|
||||
dist/js/contacts.3fc90ff8.js 16.94 KiB 5.52 KiB
|
||||
dist/js/discover.24106939.js 15.30 KiB 5.22 KiB
|
||||
dist/js/536.3bb13201.js 15.23 KiB 4.84 KiB
|
||||
dist/workbox-5b385ed2.js 14.11 KiB 4.93 KiB
|
||||
dist/js/home.218b99dd.js 13.89 KiB 4.97 KiB
|
||||
dist/js/help.50d3117b.js 12.49 KiB 4.38 KiB
|
||||
dist/js/projects.417a6cb7.js 8.71 KiB 3.00 KiB
|
||||
dist/js/contact-amounts.a32b0ccd.js 8.44 KiB 3.25 KiB
|
||||
dist/js/229.120e09bf.js 7.99 KiB 2.72 KiB
|
||||
dist/js/identity-switcher.c7937333.js 7.44 KiB 2.52 KiB
|
||||
dist/js/new-edit-project.0552181b.js 7.36 KiB 3.11 KiB
|
||||
dist/js/300.dcaeb2a3.js 6.56 KiB 3.24 KiB
|
||||
dist/js/seed-backup.76a0f7b3.js 3.99 KiB 1.97 KiB
|
||||
dist/js/import-derive.c688d4b8.js 3.81 KiB 1.82 KiB
|
||||
dist/js/import-account.c3fa35fd.js 3.54 KiB 1.66 KiB
|
||||
dist/js/new-edit-account.bb763be2.js 3.39 KiB 1.51 KiB
|
||||
dist/js/431.5a6d64e0.js 3.38 KiB 2.56 KiB
|
||||
dist/service-worker.js 3.37 KiB 1.38 KiB
|
||||
dist/js/scan-contact.46be989a.js 2.79 KiB 1.18 KiB
|
||||
dist/js/start.091a7740.js 2.70 KiB 1.30 KiB
|
||||
dist/js/new-identifier.bb379420.js 2.12 KiB 1.18 KiB
|
||||
dist/js/93.b873dbbf.js 2.08 KiB 1.61 KiB
|
||||
dist/js/new-edit-commitment.9248d367.j 1.96 KiB 1.05 KiB
|
||||
s
|
||||
dist/js/confirm-contact.02004d1d.js 1.89 KiB 1.04 KiB
|
||||
dist/js/858.ae4c08ec.js 0.97 KiB 0.78 KiB
|
||||
dist/css/app.8f21529c.css 18.41 KiB 4.39 KiB
|
||||
dist/css/discover.73ee9bd3.css 14.77 KiB 6.25 KiB
|
||||
dist/css/new-edit-project.73ee9bd3.css 14.77 KiB 6.25 KiB
|
||||
dist/css/contacts.abb5e493.css 0.40 KiB 0.23 KiB
|
||||
dist/css/contact-amounts.5b26ccd4.css 0.31 KiB 0.20 KiB
|
||||
dist/css/home.828bc66e.css 0.25 KiB 0.19 KiB
|
||||
dist/css/project.828bc66e.css 0.25 KiB 0.19 KiB
|
||||
dist/css/statistics.828bc66e.css 0.25 KiB 0.19 KiB
|
||||
|
||||
Images and other types of assets omitted.
|
||||
Build at: 2023-09-07T10:06:43.972Z - Hash: 2b39fcd4d0e78263 - Time: 32016ms
|
||||
|
||||
DONE Build complete. The dist directory is ready to be deployed.
|
||||
INFO Check out deployment instructions at https://cli.vuejs.org/guide/deployment.html
|
||||
|
||||
192
src/App.vue
192
src/App.vue
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<!-- 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"
|
||||
@@ -127,6 +128,197 @@
|
||||
</Notification>
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
|
||||
<NotificationGroup group="modal">
|
||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
|
||||
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
|
||||
leave="transition ease-in duration-500"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
move="transition duration-500"
|
||||
move-delay="delay-300"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="w-full"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
v-if="notification.type === 'notification-permission'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
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">
|
||||
Would you like to <b>turn on</b> notifications for this app?
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Turn on Notifications
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<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"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Never
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'notification-mute'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
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">Mute app notifications:</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 1 Hour
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 8 Hours
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 24 Hours
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Until I turn it back on
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'notification-off'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
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">
|
||||
Would you like to <b>turn off</b> notifications for this app?
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Turn Off Notifications
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Leave it On
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'pwa-install-gate-ios'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
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">
|
||||
<fa
|
||||
icon="mobile-screen-button"
|
||||
class="inline-block text-7xl text-slate-400 mb-4"
|
||||
>
|
||||
</fa>
|
||||
<h3 class="text-2xl font-semibold mb-4">Add to Home Screen</h3>
|
||||
<p class="text-md mb-4">
|
||||
To install the app, you need to add this website to your home
|
||||
screen.
|
||||
</p>
|
||||
<p class="text-md">
|
||||
In your Safari browser menu, tap the
|
||||
<span class="whitespace-nowrap">
|
||||
<fa
|
||||
icon="arrow-up-from-bracket"
|
||||
class="fa-fw text-slate-500 bg-slate-200 py-1 -my-1 px-0.5 rounded"
|
||||
>
|
||||
</fa>
|
||||
Share
|
||||
</span>
|
||||
icon and choose
|
||||
<b>Add to Home Screen</b> in the options. Then, open the Time
|
||||
Safari app on your home screen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'pwa-install-gate-android'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
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">
|
||||
<fa
|
||||
icon="mobile-screen-button"
|
||||
class="inline-block text-7xl text-slate-400 mb-4"
|
||||
>
|
||||
</fa>
|
||||
<h3 class="text-2xl font-semibold mb-4">Add to Home Screen</h3>
|
||||
<p class="text-md mb-4">
|
||||
To install the app, you need to add this website to your home
|
||||
screen.
|
||||
</p>
|
||||
<p class="text-md">
|
||||
In your Chrome browser menu, tap the
|
||||
<span class="whitespace-nowrap">
|
||||
<fa
|
||||
icon="ellipsis-vertical"
|
||||
class="fa-fw text-slate-500 bg-slate-200 py-1 -my-1 px-0.5 rounded"
|
||||
>
|
||||
</fa>
|
||||
More
|
||||
</span>
|
||||
button and choose
|
||||
<b>Install App</b> in the options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Notification>
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<div v-bind:class="computedAlertClassNames()">
|
||||
<button
|
||||
class="close-button bg-amber-400 w-8 leading-loose rounded-full absolute top-2 right-2"
|
||||
@click="onClickClose()"
|
||||
>
|
||||
<fa icon="xmark"></fa>
|
||||
</button>
|
||||
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
|
||||
<p>{{ alertMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Component
|
||||
export default class AlertMessage extends Vue {
|
||||
@Prop alertTitle = "";
|
||||
@Prop alertMessage = "";
|
||||
isAlertVisible = this.alertMessage;
|
||||
|
||||
public onClickClose() {
|
||||
this.isAlertVisible = false;
|
||||
}
|
||||
|
||||
public computedAlertClassNames() {
|
||||
return {
|
||||
hidden: !this.isAlertVisible,
|
||||
"dismissable-alert": true,
|
||||
"bg-amber-200": true,
|
||||
"p-5": true,
|
||||
rounded: true,
|
||||
"drop-shadow-lg": true,
|
||||
fixed: true,
|
||||
"top-3": true,
|
||||
"inset-x-3": true,
|
||||
"transition-transform": true,
|
||||
"ease-in": true,
|
||||
"duration-300": true,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped></style>
|
||||
@@ -5,13 +5,26 @@
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { toSvg } from "jdenticon";
|
||||
|
||||
const BLANK_CONFIG = {
|
||||
lightness: {
|
||||
color: [1.0, 1.0],
|
||||
grayscale: [1.0, 1.0],
|
||||
},
|
||||
saturation: {
|
||||
color: 0.0,
|
||||
grayscale: 0.0,
|
||||
},
|
||||
backColor: "#0000",
|
||||
};
|
||||
|
||||
@Component
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = "";
|
||||
@Prop iconSize = 0;
|
||||
|
||||
generateIdenticon() {
|
||||
const svgString = toSvg(this.entityId, this.iconSize);
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||
return svgString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,18 +52,18 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Emit } from "vue-facing-decorator";
|
||||
import { GiverInputInfo, GiverOutputInfo } from "@/libs/endorserServer";
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
@Prop message = "";
|
||||
|
||||
giver = null;
|
||||
giver?: GiverInputInfo;
|
||||
description = "";
|
||||
hours = "0";
|
||||
visible = false;
|
||||
|
||||
open(giver) {
|
||||
// giver: GiverInputInfo
|
||||
open(giver: GiverInputInfo) {
|
||||
this.giver = giver;
|
||||
this.visible = true;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export default class GiftedDialog extends Vue {
|
||||
}
|
||||
|
||||
@Emit("dialog-result")
|
||||
confirm() {
|
||||
confirm(): GiverOutputInfo {
|
||||
const result = {
|
||||
action: "confirm",
|
||||
giver: this.giver,
|
||||
@@ -90,14 +90,14 @@ export default class GiftedDialog extends Vue {
|
||||
};
|
||||
this.close();
|
||||
this.description = "";
|
||||
this.giver = null;
|
||||
this.giver = undefined;
|
||||
this.hours = "0";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Emit("dialog-result")
|
||||
cancel() {
|
||||
cancel(): GiverOutputInfo {
|
||||
const result = { action: "cancel" };
|
||||
this.close();
|
||||
return result;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
export enum AppString {
|
||||
APP_NAME = "Kick-Start with Time",
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://endorser.ch:3000",
|
||||
TEST_ENDORSER_API_SERVER = "https://test.endorser.ch:8000",
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||
|
||||
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
|
||||
|
||||
@@ -28,12 +28,12 @@ type NonsensitiveTables = {
|
||||
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
|
||||
*/
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export const accountsDB = new BaseDexie("KickStartAccounts") as SensitiveDexie;
|
||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const SensitiveSchemas = Object.assign({}, AccountsSchema);
|
||||
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
export const db = new BaseDexie("KickStart") as NonsensitiveDexie;
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
/**
|
||||
* Represents an account stored in the database.
|
||||
*/
|
||||
export type Account = {
|
||||
id?: number; // auto-generated by Dexie
|
||||
/**
|
||||
* Auto-generated ID by Dexie.
|
||||
*/
|
||||
id?: number;
|
||||
|
||||
/**
|
||||
* The date the account was created.
|
||||
*/
|
||||
dateCreated: string;
|
||||
|
||||
/**
|
||||
* The derivation path for the account.
|
||||
*/
|
||||
derivationPath: string;
|
||||
|
||||
/**
|
||||
* Decentralized Identifier (DID) for the account.
|
||||
*/
|
||||
did: string;
|
||||
// stringified JSON containing underlying key material of type IIdentifier
|
||||
// https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts
|
||||
|
||||
/**
|
||||
* Stringified JSON containing underlying key material.
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The public key in hexadecimal format.
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
|
||||
/**
|
||||
* The mnemonic passphrase for the account.
|
||||
*/
|
||||
mnemonic: string;
|
||||
};
|
||||
|
||||
// mark encrypted field by starting with a $ character
|
||||
// see https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon
|
||||
/**
|
||||
* Schema for the accounts table in the database.
|
||||
* Fields starting with a $ character are encrypted.
|
||||
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
|
||||
*/
|
||||
export const AccountsSchema = {
|
||||
accounts:
|
||||
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export type BoundingBox = {
|
||||
eastLong: number;
|
||||
maxLat: number;
|
||||
minLat: number;
|
||||
westLong: number;
|
||||
};
|
||||
|
||||
// a singleton
|
||||
export type Settings = {
|
||||
id: number; // there's only one entry: MASTER_SETTINGS_KEY
|
||||
@@ -7,6 +14,10 @@ export type Settings = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
lastViewedClaimId?: string;
|
||||
searchBoxes?: Array<{
|
||||
name: string;
|
||||
bbox: BoundingBox;
|
||||
}>;
|
||||
showContactGivesInline?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { HDNode } from "@ethersproject/hdnode";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as u8a from "uint8arrays";
|
||||
|
||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'";
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -47,17 +49,17 @@ export const newIdentifier = (
|
||||
*/
|
||||
export const deriveAddress = (
|
||||
mnemonic: string,
|
||||
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
||||
): [string, string, string, string] => {
|
||||
const UPORT_ROOT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
|
||||
mnemonic = mnemonic.trim().toLowerCase();
|
||||
|
||||
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic);
|
||||
const rootNode: HDNode = hdnode.derivePath(UPORT_ROOT_DERIVATION_PATH);
|
||||
const rootNode: HDNode = hdnode.derivePath(derivationPath);
|
||||
const privateHex = rootNode.privateKey.substring(2); // original starts with '0x'
|
||||
const publicHex = rootNode.publicKey.substring(2); // original starts with '0x'
|
||||
const address = rootNode.address;
|
||||
|
||||
return [address, privateHex, publicHex, UPORT_ROOT_DERIVATION_PATH];
|
||||
return [address, privateHex, publicHex, derivationPath];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface GiverInputInfo {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface GiverOutputInfo {
|
||||
action: string;
|
||||
giver?: GiverInputInfo;
|
||||
description?: string;
|
||||
hours?: number;
|
||||
}
|
||||
|
||||
export interface ClaimResult {
|
||||
success: { claimId: string; handleId: string };
|
||||
error: { code: string; message: string };
|
||||
@@ -55,7 +62,30 @@ export interface GiveVerifiableCredential {
|
||||
fulfills?: { "@type": string; identifier: string };
|
||||
identifier?: string;
|
||||
object?: { amountOfThisGood: number; unitCode: string };
|
||||
recipient: { identifier: string };
|
||||
recipient?: { identifier: string };
|
||||
}
|
||||
|
||||
export interface PlanVerifiableCredential {
|
||||
"@context": "https://schema.org";
|
||||
"@type": "PlanAction";
|
||||
name: string;
|
||||
description: string;
|
||||
identifier?: string;
|
||||
location?: {
|
||||
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanServerRecord {
|
||||
agentDid?: string; // optional, if the issuer wants someone else to manage as well
|
||||
description: string;
|
||||
endTime?: string;
|
||||
issuerDid: string;
|
||||
handleId: string;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
startTime?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegisterVerifiableCredential {
|
||||
@@ -63,7 +93,7 @@ export interface RegisterVerifiableCredential {
|
||||
"@type": string;
|
||||
agent: { identifier: string };
|
||||
object: string;
|
||||
recipient: { identifier: string };
|
||||
participant: { identifier: string };
|
||||
}
|
||||
|
||||
export interface InternalError {
|
||||
@@ -75,7 +105,7 @@ export interface InternalError {
|
||||
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
||||
const HIDDEN_DID = "did:none:HIDDEN";
|
||||
|
||||
export function isHiddenDid(did) {
|
||||
export function isHiddenDid(did: string) {
|
||||
return did === HIDDEN_DID;
|
||||
}
|
||||
|
||||
@@ -85,28 +115,40 @@ export function isHiddenDid(did) {
|
||||
export function didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
contacts: Array<Contact>,
|
||||
allMyDids: string[],
|
||||
contacts: Contact[],
|
||||
): string {
|
||||
const myId: string | undefined = R.find(R.equals(did), allMyDids, did);
|
||||
if (myId) {
|
||||
return "You" + (myId !== activeDid ? " (Alt ID)" : "");
|
||||
} else {
|
||||
const contact: Contact | undefined = R.find((c) => c.did === did, contacts);
|
||||
if (contact) {
|
||||
return contact.name || "Someone Unnamed in Contacts";
|
||||
} else if (!did) {
|
||||
return "Unspecified Person";
|
||||
} else if (isHiddenDid(did)) {
|
||||
return "Someone Not In Network";
|
||||
} else {
|
||||
return "Someone Not In Contacts";
|
||||
}
|
||||
}
|
||||
const myId = R.find(R.equals(did), allMyDids);
|
||||
if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`;
|
||||
|
||||
const contact = R.find((c) => c.did === did, contacts);
|
||||
return contact
|
||||
? contact.name || "Someone Unnamed in Contacts"
|
||||
: !did
|
||||
? "Unspecified Person"
|
||||
: isHiddenDid(did)
|
||||
? "Someone Not In Network"
|
||||
: "Someone Not In Contacts";
|
||||
}
|
||||
|
||||
export interface ResultWithType {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SuccessResult extends ResultWithType {
|
||||
type: "success";
|
||||
response: AxiosResponse<ClaimResult>;
|
||||
}
|
||||
|
||||
export interface ErrorResult {
|
||||
type: "error";
|
||||
error: InternalError;
|
||||
}
|
||||
|
||||
export type CreateAndSubmitGiveResult = SuccessResult | ErrorResult;
|
||||
|
||||
/**
|
||||
* For result, see https://endorser.ch:3000/api-docs/#/claims/post_api_v2_claim
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param identity
|
||||
* @param fromDid may be null
|
||||
@@ -118,76 +160,83 @@ export async function createAndSubmitGive(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
identity: IIdentifier,
|
||||
fromDid: string,
|
||||
toDid: string,
|
||||
description: string,
|
||||
hours: number,
|
||||
fromDid?: string,
|
||||
toDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<AxiosResponse<ClaimResult> | InternalError> {
|
||||
// Make a claim
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GiveAction",
|
||||
recipient: { identifier: toDid },
|
||||
};
|
||||
if (fromDid) {
|
||||
vcClaim.agent = { identifier: fromDid };
|
||||
}
|
||||
if (description) {
|
||||
vcClaim.description = description;
|
||||
}
|
||||
if (hours) {
|
||||
vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" };
|
||||
}
|
||||
if (fulfillsProjectHandleId) {
|
||||
vcClaim.fulfills = {
|
||||
"@type": "PlanAction",
|
||||
identifier: fulfillsProjectHandleId,
|
||||
): Promise<CreateAndSubmitGiveResult> {
|
||||
try {
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GiveAction",
|
||||
recipient: toDid ? { identifier: toDid } : undefined,
|
||||
agent: fromDid ? { identifier: fromDid } : undefined,
|
||||
description: description || undefined,
|
||||
object: hours ? { amountOfThisGood: hours, unitCode: "HUR" } : undefined,
|
||||
fulfills: fulfillsProjectHandleId
|
||||
? { "@type": "PlanAction", identifier: fulfillsProjectHandleId }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
|
||||
// Create a signature using private key of identity
|
||||
const firstKey = identity.keys[0];
|
||||
const privateKeyHex = firstKey?.privateKeyHex;
|
||||
|
||||
if (!privateKeyHex) {
|
||||
throw {
|
||||
error: "No private key",
|
||||
message: `Your identifier ${identity.did} is not configured correctly. Use a different identifier.`,
|
||||
};
|
||||
}
|
||||
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
|
||||
// Create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
issuer: identity.did,
|
||||
signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = `${apiServer}/api/v2/claim`;
|
||||
const token = await accessToken(identity);
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { type: "success", response };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage: string =
|
||||
error === null
|
||||
? "Null error"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "object" && error !== null && "message" in error
|
||||
? (error as { message: string }).message
|
||||
: "Unknown error";
|
||||
|
||||
return {
|
||||
type: "error",
|
||||
error: {
|
||||
error: errorMessage,
|
||||
userMessage: "Failed to create and submit the claim.",
|
||||
},
|
||||
};
|
||||
}
|
||||
// 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) {
|
||||
return new Promise<InternalError>((resolve, reject) => {
|
||||
reject({
|
||||
error: "No private key",
|
||||
message:
|
||||
"Your identifier " +
|
||||
identity.did +
|
||||
" is not configured correctly. Use a different identifier.",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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 = apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
return axios.post(url, payload, { headers });
|
||||
}
|
||||
|
||||
// from https://stackoverflow.com/a/175787/845494
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@@ -11,6 +11,9 @@ import "./assets/styles/tailwind.css";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowUpFromBracket,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
@@ -33,9 +36,11 @@ import {
|
||||
faGift,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMobileScreenButton,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
@@ -54,6 +59,9 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowUpFromBracket,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
@@ -76,9 +84,11 @@ library.add(
|
||||
faGift,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMobileScreenButton,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
import { accountsDB } from "@/db";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
NavigationGuardNext,
|
||||
RouteLocationNormalized,
|
||||
RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { accountsDB } from "@/db/index";
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -7,7 +13,11 @@ import { accountsDB } from "@/db";
|
||||
* @param from :RouteLocationNormalized
|
||||
* @param next :NavigationGuardNext
|
||||
*/
|
||||
const enterOrStart = async (to, from, next) => {
|
||||
const enterOrStart = async (
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext,
|
||||
) => {
|
||||
await accountsDB.open();
|
||||
const num_accounts = await accountsDB.accounts.count();
|
||||
if (num_accounts > 0) {
|
||||
@@ -91,6 +101,14 @@ const routes: Array<RouteRecordRaw> = [
|
||||
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/import-derive",
|
||||
name: "import-derive",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-account",
|
||||
name: "new-edit-account",
|
||||
@@ -171,7 +189,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "contact-gives",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "statistics" */ "../views/ContactGiftingView.vue"
|
||||
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -182,10 +200,14 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
const errorHandler = (error, to, from) => {
|
||||
const errorHandler = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any,
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
) => {
|
||||
// Handle the error here
|
||||
console.error(error, to, from);
|
||||
console.log("XXXXX");
|
||||
console.error("Caught in top level error handler:", error, to, from);
|
||||
|
||||
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
||||
};
|
||||
|
||||
@@ -117,12 +117,67 @@
|
||||
>
|
||||
Edit Identity
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'identity-switcher' }"
|
||||
class="block text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
|
||||
>
|
||||
Switch Identity / No Identity
|
||||
</router-link>
|
||||
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
||||
<label
|
||||
for="toggleNotifications"
|
||||
class="flex items-center cursor-pointer"
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-permission',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- toggle -->
|
||||
<div class="relative">
|
||||
<!-- input -->
|
||||
<input type="checkbox" name="toggleNotifications" 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 -->
|
||||
<div class="ml-2">App Notifications</div>
|
||||
</label>
|
||||
<label
|
||||
for="toggleMuteNotifications"
|
||||
class="flex items-center cursor-pointer mt-4"
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-mute',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- toggle -->
|
||||
<div class="relative">
|
||||
<!-- input -->
|
||||
<input
|
||||
type="checkbox"
|
||||
name="toggleMuteNotifications"
|
||||
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 -->
|
||||
<div class="ml-2">Mute Notifications</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Data</h3>
|
||||
|
||||
@@ -162,6 +217,33 @@
|
||||
</button>
|
||||
</dialog>
|
||||
|
||||
<div class="flex py-2">
|
||||
<button class="text-center text-md text-blue-500" @click="checkLimits()">
|
||||
Check Limits
|
||||
</button>
|
||||
<!-- show spinner if loading limits -->
|
||||
<div v-if="loadingLimits" class="ml-2">
|
||||
Checking... <fa icon="spinner" class="fa-spin"></fa>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
{{ limitsMessage }}
|
||||
</div>
|
||||
<div v-if="!!limits?.nextWeekBeginDateTime" class="px-9">
|
||||
<span class="font-bold">Rate Limits</span>
|
||||
<p>
|
||||
You have done {{ limits.doneClaimsThisWeek }} claims out of
|
||||
{{ limits.maxClaimsPerWeek }} for this week. Your claims counter
|
||||
resets at {{ readableTime(limits.nextWeekBeginDateTime) }}
|
||||
</p>
|
||||
<p>
|
||||
You have done {{ limits.doneRegistrationsThisMonth }} registrations
|
||||
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
|
||||
registrations counter resets at
|
||||
{{ readableTime(limits.nextMonthBeginDateTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-sm uppercase font-semibold mb-3"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
@@ -195,33 +277,12 @@
|
||||
</label>
|
||||
|
||||
<div class="flex py-2">
|
||||
<button
|
||||
class="text-center text-md text-blue-500"
|
||||
@click="checkLimits()"
|
||||
<router-link
|
||||
:to="{ name: 'identity-switcher' }"
|
||||
class="block text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
Check Limits
|
||||
</button>
|
||||
<!-- show spinner if loading limits -->
|
||||
<div v-if="loadingLimits" class="ml-2">
|
||||
Checking... <fa icon="spinner" class="fa-spin"></fa>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
{{ limitsMessage }}
|
||||
</div>
|
||||
<div v-if="!!limits?.nextWeekBeginDateTime" class="px-9">
|
||||
<span class="font-bold">Rate Limits</span>
|
||||
<p>
|
||||
You have done {{ limits.doneClaimsThisWeek }} claims out of
|
||||
{{ limits.maxClaimsPerWeek }} for this week. Your claims counter
|
||||
resets at {{ readableTime(limits.nextWeekBeginDateTime) }}
|
||||
</p>
|
||||
<p>
|
||||
You have done {{ limits.doneRegistrationsThisMonth }} registrations
|
||||
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
|
||||
registrations counter resets at
|
||||
{{ readableTime(limits.nextMonthBeginDateTime) }}
|
||||
</p>
|
||||
</div>
|
||||
Switch Identity / No Identity
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
@@ -269,32 +330,52 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import "dexie-export-import";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { AxiosError } from "axios/index";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { ErrorResponse, RateLimits } from "@/libs/endorserServer";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface IAccount {
|
||||
did: string;
|
||||
publicKeyHex: string;
|
||||
privateHex?: string;
|
||||
derivationPath: string;
|
||||
}
|
||||
|
||||
interface SettingsType {
|
||||
activeDid?: string;
|
||||
apiServer?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
showContactGivesInline?: boolean;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class AccountViewView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
Constants = AppString;
|
||||
|
||||
activeDid = "";
|
||||
@@ -320,27 +401,60 @@ export default class AccountViewView extends Vue {
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
return identity;
|
||||
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
|
||||
try {
|
||||
// Open the accounts database
|
||||
await accountsDB.open();
|
||||
} catch (error) {
|
||||
console.error("Failed to open accounts database:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
let account: { identity?: string } | undefined;
|
||||
|
||||
try {
|
||||
// Search for the account with the matching DID (decentralized identifier)
|
||||
account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
} catch (error) {
|
||||
console.error("Failed to find account:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return parsed identity or null if not found
|
||||
return JSON.parse(account?.identity || "null");
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
/**
|
||||
* Asynchronously retrieves headers for HTTP requests.
|
||||
*
|
||||
* @param {IIdentifier} identity - The identity object for which to generate the headers.
|
||||
* @returns {Promise<Record<string,string>>} A Promise that resolves to an object containing the headers.
|
||||
*
|
||||
* @throws Will throw an error if unable to generate an access token.
|
||||
*/
|
||||
public async getHeaders(
|
||||
identity: IIdentifier,
|
||||
): Promise<Record<string, string>> {
|
||||
try {
|
||||
const token = await accessToken(identity);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
return headers;
|
||||
} catch (error) {
|
||||
console.error("Failed to get headers:", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||||
doCopyTwoSecRedo(text, fn) {
|
||||
doCopyTwoSecRedo(text: string, fn: () => void) {
|
||||
fn();
|
||||
useClipboard()
|
||||
.copy(text)
|
||||
@@ -361,59 +475,92 @@ export default class AccountViewView extends Vue {
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async function executed when the component is created.
|
||||
* Initializes the component's state with values from the database,
|
||||
* handles identity-related tasks, and checks limitations.
|
||||
*
|
||||
* @throws Will display specific messages to the user based on different errors.
|
||||
*/
|
||||
async created() {
|
||||
// Uncomment this to register this user on the test server.
|
||||
// To manage within the vue devtools browser extension https://devtools.vuejs.org/
|
||||
// assign this to a class variable, eg. "registerThisUser = testServerRegisterUser",
|
||||
// select a component in the extension, and enter in the console: $vm.ctx.registerThisUser()
|
||||
//testServerRegisterUser();
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.apiServerInput = settings?.apiServer || "";
|
||||
this.firstName = settings?.firstName || "";
|
||||
this.lastName = settings?.lastName || "";
|
||||
this.showContactGives = !!settings?.showContactGivesInline;
|
||||
|
||||
// Initialize component state with values from the database or defaults
|
||||
this.initializeState(settings);
|
||||
|
||||
// Get and process the identity
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
if (identity) {
|
||||
this.publicHex = identity.keys[0].publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString(
|
||||
"base64",
|
||||
);
|
||||
this.derivationPath = identity.keys[0].meta.derivationPath;
|
||||
this.processIdentity(identity);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: identity.did,
|
||||
});
|
||||
this.checkLimitsFor(identity);
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
err.message ===
|
||||
/**
|
||||
* Initializes component state with values from the database or defaults.
|
||||
* @param {SettingsType} settings - Object containing settings from the database.
|
||||
*/
|
||||
initializeState(settings: SettingsType | undefined) {
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.apiServerInput = settings?.apiServer || "";
|
||||
this.firstName = settings?.firstName || "";
|
||||
this.lastName = settings?.lastName || "";
|
||||
this.showContactGives = !!settings?.showContactGivesInline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the identity and updates the component's state.
|
||||
* @param {IdentityType} identity - Object containing identity information.
|
||||
*/
|
||||
processIdentity(identity: IIdentifier) {
|
||||
if (
|
||||
identity &&
|
||||
identity.keys &&
|
||||
identity.keys.length > 0 &&
|
||||
identity.keys[0].meta
|
||||
) {
|
||||
this.publicHex = identity.keys[0].publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||||
this.derivationPath = identity.keys[0].meta.derivationPath;
|
||||
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: identity.did,
|
||||
});
|
||||
this.checkLimitsFor(identity);
|
||||
} else {
|
||||
// Handle the case where any of these are null or undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors and updates the component's state accordingly.
|
||||
* @param {Error} err - The error object.
|
||||
*/
|
||||
handleError(err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message ===
|
||||
"Attempted to load account records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Creating Account",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to clear cache at page create because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Creating Account",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Telling user to clear cache at page create because:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,41 +587,96 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously exports the database into a downloadable JSON file.
|
||||
*
|
||||
* @throws Will notify the user if there is an export error.
|
||||
*/
|
||||
public async exportDatabase() {
|
||||
try {
|
||||
const blob = await db.export({ prettyJson: true });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// Generate the blob from the database
|
||||
const blob = await this.generateDatabaseBlob();
|
||||
|
||||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
||||
downloadAnchor.href = url;
|
||||
downloadAnchor.download = db.name + "-backup.json";
|
||||
downloadAnchor.click();
|
||||
// Create a temporary URL for the blob
|
||||
const url = this.createBlobURL(blob);
|
||||
|
||||
// Trigger the download
|
||||
this.downloadDatabaseBackup(url);
|
||||
|
||||
// Revoke the temporary URL
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Download Started",
|
||||
text: "See your downloads directory for the backup.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
// Notify the user that the download has started
|
||||
this.notifyDownloadStarted();
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
text: "See console logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Export Error:", error);
|
||||
this.handleExportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a blob object representing the database.
|
||||
*
|
||||
* @returns {Promise<Blob>} The generated blob object.
|
||||
*/
|
||||
private async generateDatabaseBlob(): Promise<Blob> {
|
||||
return await db.export({ prettyJson: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary URL for a blob object.
|
||||
*
|
||||
* @param {Blob} blob - The blob object.
|
||||
* @returns {string} The temporary URL for the blob.
|
||||
*/
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the download of the database backup.
|
||||
*
|
||||
* @param {string} url - The temporary URL for the blob.
|
||||
*/
|
||||
private downloadDatabaseBackup(url: string) {
|
||||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
||||
downloadAnchor.href = url;
|
||||
downloadAnchor.download = `${db.name}-backup.json`;
|
||||
downloadAnchor.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the user that the download has started.
|
||||
*/
|
||||
private notifyDownloadStarted() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Download Started",
|
||||
text: "See your downloads directory for the backup.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors during the database export process.
|
||||
*
|
||||
* @param {Error} error - The error object.
|
||||
*/
|
||||
private handleExportError(error: unknown) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
text: "See console logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Export Error:", error);
|
||||
}
|
||||
|
||||
async checkLimits() {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (identity) {
|
||||
@@ -482,71 +684,120 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async checkLimitsFor(identity: IIdentifier) {
|
||||
/**
|
||||
* Asynchronously checks rate limits for the given identity.
|
||||
*
|
||||
* Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`.
|
||||
*/
|
||||
public async checkLimitsFor(identity: IIdentifier) {
|
||||
this.loadingLimits = true;
|
||||
this.limitsMessage = "";
|
||||
|
||||
try {
|
||||
const url = this.apiServer + "/api/report/rateLimits";
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
// axios throws an exception on a 400
|
||||
const resp = await this.fetchRateLimits(identity);
|
||||
if (resp.status === 200) {
|
||||
this.limits = resp.data;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error.message ===
|
||||
"Attempted to load Give records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
const serverError = error as AxiosError;
|
||||
console.error("Bad response retrieving limits: ", serverError);
|
||||
|
||||
const data: ErrorResponse | undefined =
|
||||
serverError.response && serverError.response.data;
|
||||
if (data && data.error && data.error.message) {
|
||||
this.limitsMessage = data.error.message;
|
||||
} else {
|
||||
this.limitsMessage = "Bad server response.";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleRateLimitsError(error);
|
||||
}
|
||||
|
||||
this.loadingLimits = false;
|
||||
}
|
||||
|
||||
async switchAccount(accountNum: number) {
|
||||
// 0 means none
|
||||
if (accountNum === 0) {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: undefined,
|
||||
});
|
||||
this.activeDid = "";
|
||||
this.derivationPath = "";
|
||||
this.publicHex = "";
|
||||
this.publicBase64 = "";
|
||||
/**
|
||||
* Fetches rate limits from the server.
|
||||
*
|
||||
* @param {IIdentifier} identity - The identity object to check rate limits for.
|
||||
* @returns {Promise<AxiosResponse>} The Axios response object.
|
||||
*/
|
||||
private async fetchRateLimits(identity: IIdentifier) {
|
||||
const url = `${this.apiServer}/api/report/rateLimits`;
|
||||
const headers = await this.getHeaders(identity);
|
||||
return await this.axios.get(url, { headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors that occur while fetching rate limits.
|
||||
*
|
||||
* @param {AxiosError | Error} error - The error object.
|
||||
*/
|
||||
private handleRateLimitsError(error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
const data = error.response?.data as ErrorResponse;
|
||||
this.limitsMessage = data?.error?.message || "Bad server response.";
|
||||
console.error("Bad response retrieving limits:", error);
|
||||
} else if (
|
||||
error instanceof Error &&
|
||||
error.message ===
|
||||
"Attempted to load Give records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
} else {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = accounts[accountNum - 1];
|
||||
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: account.did,
|
||||
});
|
||||
|
||||
this.activeDid = account.did;
|
||||
this.derivationPath = account.derivationPath;
|
||||
this.publicHex = account.publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||||
// Handle other unknown errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously switches the active account based on the provided account number.
|
||||
*
|
||||
* @param {number} accountNum - The account number to switch to. 0 means none.
|
||||
*/
|
||||
public async switchAccount(accountNum: number) {
|
||||
await db.open(); // Assumes db needs to be open for both cases
|
||||
|
||||
if (accountNum === 0) {
|
||||
this.switchToNoAccount();
|
||||
} else {
|
||||
await this.switchToAccountNumber(accountNum);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to no active account and clears relevant properties.
|
||||
*/
|
||||
private async switchToNoAccount() {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: undefined });
|
||||
this.clearActiveAccountProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears properties related to the active account.
|
||||
*/
|
||||
private clearActiveAccountProperties() {
|
||||
this.activeDid = "";
|
||||
this.derivationPath = "";
|
||||
this.publicHex = "";
|
||||
this.publicBase64 = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to an account based on its number in the list.
|
||||
*
|
||||
* @param {number} accountNum - The account number to switch to.
|
||||
*/
|
||||
private async switchToAccountNumber(accountNum: number) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = accounts[accountNum - 1];
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, { activeDid: account.did });
|
||||
|
||||
this.updateActiveAccountProperties(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates properties related to the active account.
|
||||
*
|
||||
* @param {AccountType} account - The account object.
|
||||
*/
|
||||
private updateActiveAccountProperties(account: IAccount) {
|
||||
this.activeDid = account.did;
|
||||
this.derivationPath = account.derivationPath;
|
||||
this.publicHex = account.publicKeyHex;
|
||||
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
|
||||
}
|
||||
|
||||
public showContactGivesClassNames() {
|
||||
return {
|
||||
"bg-slate-900": !this.showContactGives,
|
||||
@@ -562,7 +813,7 @@ export default class AccountViewView extends Vue {
|
||||
this.apiServer = this.apiServerInput;
|
||||
}
|
||||
|
||||
setApiServerInput(value) {
|
||||
setApiServerInput(value: string) {
|
||||
this.apiServerInput = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<QuickNav selected="Contacts"></QuickNav>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Given with {{ contact?.name }}
|
||||
</h1>
|
||||
@@ -11,70 +23,73 @@
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div>
|
||||
<div class="border-b border-slate-300 flex">
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-1/4">from them</div>
|
||||
<div class="w-1/4"></div>
|
||||
<div class="w-1/4">to them</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-slate-300 flex"
|
||||
v-for="record in giveRecords"
|
||||
:key="record.id"
|
||||
>
|
||||
<div class="w-1/4">
|
||||
{{ new Date(record.issuedAt).toLocaleString() }}
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" class="tooltip">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Confirmed</span>
|
||||
</span>
|
||||
<button v-else class="tooltip" @click="confirm(record)">
|
||||
<fa icon="circle" class="text-blue-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Unconfirmed</span>
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
{{ record.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-1/8">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<fa icon="long-arrow-alt-left" class="text-slate-900 fa-fw ml-1" />
|
||||
</span>
|
||||
<span v-else>
|
||||
|
||||
<fa icon="long-arrow-alt-right" class="text-slate-900 fa-fw ml-1" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<span v-if="record.agentDid != contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" class="tooltip">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Confirmed</span>
|
||||
</span>
|
||||
<button v-else class="tooltip" @click="cannotConfirmMessage()">
|
||||
<fa icon="circle" class="text-slate-600 fa-fw ml-1" />
|
||||
<span class="tooltiptext">Unconfirmed</span>
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
{{ record.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
<table
|
||||
class="table-auto w-full border-t border-slate-300 text-sm sm:text-base text-center"
|
||||
>
|
||||
<thead class="bg-slate-100">
|
||||
<tr class="border-b border-slate-300">
|
||||
<th></th>
|
||||
<th class="px-1 py-2">From Them</th>
|
||||
<th></th>
|
||||
<th class="px-1 py-2">To Them</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="record in giveRecords"
|
||||
:key="record.id"
|
||||
class="border-b border-slate-300"
|
||||
>
|
||||
<td class="p-1 text-xs sm:text-sm text-left text-slate-500">
|
||||
{{ new Date(record.issuedAt).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
<button v-else @click="confirm(record)" title="Unconfirmed">
|
||||
<fa icon="circle" class="text-blue-600 fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="italic text-xs sm:text-sm text-slate-500">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<fa icon="arrow-right" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid != contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@click="cannotConfirmMessage()"
|
||||
title="Unconfirmed"
|
||||
>
|
||||
<fa icon="circle" class="text-slate-600 fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="italic text-xs sm:text-sm text-slate-500">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -82,7 +97,7 @@
|
||||
import * as R from "ramda";
|
||||
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
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";
|
||||
@@ -94,17 +109,24 @@ import {
|
||||
} from "@/libs/endorserServer";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AxiosError } from "axios";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class ContactsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contact: Contact | null = null;
|
||||
giveRecords: Array<GiveServerRecord> = [];
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
@@ -112,7 +134,7 @@ export default class ContactsView extends Vue {
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
@@ -128,7 +150,7 @@ export default class ContactsView extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -150,7 +172,8 @@ export default class ContactsView extends Vue {
|
||||
if (this.activeDid && this.contact) {
|
||||
this.loadGives(this.activeDid, this.contact);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -168,7 +191,7 @@ export default class ContactsView extends Vue {
|
||||
async loadGives(activeDid: string, contact: Contact) {
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
let result = [];
|
||||
let result: Array<GiveServerRecord> = [];
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow italic text-slate-500"
|
||||
><EntityIcon
|
||||
entityId="Anonymous"
|
||||
:entityId="null"
|
||||
:iconSize="32"
|
||||
class="opacity-50 inline-block align-middle border border-dashed border-slate-400 bg-slate-200 rounded-md mr-1"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
></EntityIcon>
|
||||
Anonymous
|
||||
</span>
|
||||
@@ -76,48 +76,53 @@
|
||||
message="Received from"
|
||||
>
|
||||
</GiftedDialog>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { createAndSubmitGive } from "@/libs/endorserServer";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
CreateAndSubmitGiveResult,
|
||||
ErrorResult,
|
||||
GiverInputInfo,
|
||||
GiverOutputInfo,
|
||||
} from "@/libs/endorserServer";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
export default class ContactGiftingView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allAccounts: Array<Account> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
isHiddenSpinner = true;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
accounts: AccountsSchema;
|
||||
accounts: typeof AccountsSchema;
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
accountsDB.open();
|
||||
this.accounts = accountsDB.accounts;
|
||||
this.numAccounts = await this.accounts.count();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
@@ -133,7 +138,7 @@ export default class HomeView extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -144,23 +149,20 @@ export default class HomeView extends Vue {
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await accountsDB.open();
|
||||
this.allAccounts = await accountsDB.accounts.toArray();
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.feedLastViewedId = settings?.lastViewedClaimId;
|
||||
this.updateAllFeed();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
err.message ||
|
||||
"There was an error retrieving the latest sweet, sweet action.",
|
||||
},
|
||||
-1,
|
||||
@@ -168,37 +170,20 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
public async buildHeaders() {
|
||||
const headers = { "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.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
}
|
||||
|
||||
openDialog(giver) {
|
||||
this.$refs.customDialog.open(giver);
|
||||
}
|
||||
|
||||
handleDialogResult(result) {
|
||||
handleDialogResult(result: GiverOutputInfo) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
||||
resolve();
|
||||
this.recordGive(
|
||||
result.giver?.did,
|
||||
result.description,
|
||||
result.hours,
|
||||
).then(() => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// action was "cancel" so do nothing
|
||||
@@ -211,7 +196,11 @@ export default class HomeView extends Vue {
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
public async recordGive(giverDid, description, hours) {
|
||||
public async recordGive(
|
||||
giverDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -250,8 +239,8 @@ export default class HomeView extends Vue {
|
||||
hours,
|
||||
);
|
||||
|
||||
if (isGiveCreationError(result)) {
|
||||
const errorMessage = getGiveCreationErrorMessage(result);
|
||||
if (this.isGiveCreationError(result)) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -273,39 +262,34 @@ export default class HomeView extends Vue {
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with give 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:
|
||||
getGiveErrorMessage(error) ||
|
||||
"There was an error recording the give.",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setAlert(title, message) {
|
||||
this.alertTitle = title;
|
||||
this.alertMessage = message;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
isGiveCreationError(result) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
isGiveCreationError(result: CreateAndSubmitGiveResult) {
|
||||
return result.type == "error";
|
||||
}
|
||||
|
||||
getGiveCreationErrorMessage(result) {
|
||||
return result.data?.error?.message;
|
||||
}
|
||||
|
||||
getGiveErrorMessage(error) {
|
||||
return error.userMessage || error.response?.data?.error?.message;
|
||||
getGiveCreationErrorMessage(result: CreateAndSubmitGiveResult) {
|
||||
return (result as ErrorResult).error?.userMessage;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,45 +17,50 @@
|
||||
:dotsOptions="{ type: 'square' }"
|
||||
class="flex justify-center"
|
||||
/>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import * as R from "ramda";
|
||||
import { SimpleSigner } from "@/libs/crypto";
|
||||
import * as didJwt from "did-jwt";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue3,
|
||||
AlertMessage,
|
||||
QuickNav,
|
||||
},
|
||||
})
|
||||
export default class ContactQRScanShow extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
qrValue = "";
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const account: Account | undefined = R.find(
|
||||
(acc) => acc.did === activeDid,
|
||||
accounts,
|
||||
);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
@@ -66,15 +71,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
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);
|
||||
|
||||
@@ -121,19 +121,21 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="contact.registered"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
title="Registered"
|
||||
>
|
||||
<fa icon="person-circle-check" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="register(contact)"
|
||||
class="text-sm uppercase bg-slate-500 text-white px-2 py-1.5 rounded-md"
|
||||
title="Registration unknown"
|
||||
>
|
||||
<fa icon="person-circle-question" class="fa-fw" />
|
||||
<fa
|
||||
v-if="contact.registered"
|
||||
icon="person-circle-check"
|
||||
class="fa-fw"
|
||||
title="Registered"
|
||||
/>
|
||||
<fa
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="fa-fw"
|
||||
title="Registration Unknown"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -202,10 +204,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>This identity has no contacts.</p>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -214,27 +212,36 @@ import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { accountsDB, db } from "@/db";
|
||||
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 {
|
||||
GiveServerRecord,
|
||||
GiveVerifiableCredential,
|
||||
RegisterVerifiableCredential,
|
||||
SERVICE_ID,
|
||||
} from "@/libs/endorserServer";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { AlertMessage, QuickNav, EntityIcon },
|
||||
components: { QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ContactsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contacts: Array<Contact> = [];
|
||||
@@ -256,8 +263,6 @@ export default class ContactsView extends Vue {
|
||||
showGiveNumbers = false;
|
||||
showGiveTotals = true;
|
||||
showGiveConfirmed = true;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
@@ -276,7 +281,7 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
@@ -290,7 +295,7 @@ export default class ContactsView extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -299,7 +304,7 @@ export default class ContactsView extends Vue {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async getHeadersAndIdentity(activeDid) {
|
||||
public async getHeadersAndIdentity(activeDid: string) {
|
||||
const identity = await this.getIdentity(activeDid);
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
@@ -308,11 +313,11 @@ export default class ContactsView extends Vue {
|
||||
|
||||
async loadGives() {
|
||||
const handleResponse = (
|
||||
resp,
|
||||
descriptions,
|
||||
confirmed,
|
||||
unconfirmed,
|
||||
useRecipient,
|
||||
resp: { status: number; data: { data: GiveServerRecord[] } },
|
||||
descriptions: Record<string, string>,
|
||||
confirmed: Record<string, number>,
|
||||
unconfirmed: Record<string, number>,
|
||||
useRecipient: boolean,
|
||||
) => {
|
||||
if (resp.status === 200) {
|
||||
const allData = resp.data.data;
|
||||
@@ -344,9 +349,8 @@ export default class ContactsView extends Vue {
|
||||
title: "Error With Server",
|
||||
text:
|
||||
"Got an error retrieving your " +
|
||||
resp.config.url.includes("recipientDid")
|
||||
? "received"
|
||||
: "given" + " time from the server.",
|
||||
(useRecipient ? "given" : "received") +
|
||||
" time from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -457,6 +461,9 @@ export default class ContactsView extends Vue {
|
||||
confirm(
|
||||
"Are you sure you want to use one of your registrations for " +
|
||||
this.nameForDid(this.contacts, contact.did) +
|
||||
(contact.registered
|
||||
? " -- especially since they are already marked as registered"
|
||||
: "") +
|
||||
"?",
|
||||
)
|
||||
) {
|
||||
@@ -608,6 +615,8 @@ export default class ContactsView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/report/canDidExplicitlySeeMe?did=" +
|
||||
encodeURIComponent(contact.did);
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
@@ -685,11 +694,17 @@ export default class ContactsView extends Vue {
|
||||
|
||||
// if they have unconfirmed amounts, ask to confirm those first
|
||||
if (toDid == identity?.did && this.givenToMeUnconfirmed[fromDid] > 0) {
|
||||
const isare = this.givenToMeUnconfirmed[fromDid] == 1 ? "is" : "are";
|
||||
const hours = this.givenToMeUnconfirmed[fromDid] == 1 ? "hour" : "hours";
|
||||
if (
|
||||
confirm(
|
||||
"There are " +
|
||||
"There " +
|
||||
isare +
|
||||
" " +
|
||||
this.givenToMeUnconfirmed[fromDid] +
|
||||
" unconfirmed hours from them." +
|
||||
" unconfirmed " +
|
||||
hours +
|
||||
" from them." +
|
||||
" Would you like to confirm some of those hours?",
|
||||
)
|
||||
) {
|
||||
@@ -697,6 +712,7 @@ export default class ContactsView extends Vue {
|
||||
name: "contact-amounts",
|
||||
query: { contactDid: fromDid },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this.isNumeric(this.hourInput)) {
|
||||
@@ -747,7 +763,9 @@ export default class ContactsView extends Vue {
|
||||
confirm(
|
||||
"Are you sure you want to record " +
|
||||
this.hourInput +
|
||||
" hours " +
|
||||
" hour" +
|
||||
(this.hourInput == "1" ? "" : "s") +
|
||||
" " +
|
||||
toFrom +
|
||||
description +
|
||||
"?",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="search()">
|
||||
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="searchAll()">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchTerms"
|
||||
@@ -17,7 +17,7 @@
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
@click="search()"
|
||||
@click="searchAll()"
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
@@ -32,6 +32,8 @@
|
||||
href="#"
|
||||
@click="
|
||||
projects = [];
|
||||
isLocalActive = true;
|
||||
isRemoteActive = false;
|
||||
searchLocal();
|
||||
"
|
||||
v-bind:class="computedLocalTabClassNames()"
|
||||
@@ -49,10 +51,12 @@
|
||||
v-bind:class="computedRemoteTabClassNames()"
|
||||
@click="
|
||||
projects = [];
|
||||
search();
|
||||
isRemoteActive = true;
|
||||
isLocalActive = false;
|
||||
searchAll();
|
||||
"
|
||||
>
|
||||
Remote
|
||||
Anywhere
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>{{ remoteCount }}</span
|
||||
@@ -62,6 +66,49 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocalActive">
|
||||
<div v-if="!isChoosingSearchBox">
|
||||
<button
|
||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="isChoosingSearchBox = true"
|
||||
>
|
||||
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
|
||||
Choose Location Below for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="storeSearchBox"
|
||||
>
|
||||
Store This Location for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="searchBox"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="forgetSearchBox"
|
||||
>
|
||||
Delete Stored Location
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="resetLatLong"
|
||||
>
|
||||
Reset Marker
|
||||
</button>
|
||||
<button
|
||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="cancelSearchBoxSelect"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</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"
|
||||
@@ -71,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<InfiniteScroll @reached-bottom="loadMoreData" v-if="!isChoosingSearchBox">
|
||||
<ul>
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
@@ -91,7 +138,6 @@
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">{{ project.name }}</h2>
|
||||
<h2 class="text-base font-semibold">{{ project.name }}</h2>
|
||||
<div class="text-sm">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
@@ -104,42 +150,103 @@
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
|
||||
<div
|
||||
v-if="isLocalActive && isChoosingSearchBox"
|
||||
style="height: 600px; width: 800px"
|
||||
>
|
||||
<l-map
|
||||
ref="map"
|
||||
:center="[localCenterLat, localCenterLong]"
|
||||
v-model:zoom="localZoom"
|
||||
@click="setMapPoint"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="isNewMarkerSet"
|
||||
:lat-lng="[localCenterLat, localCenterLong]"
|
||||
@click="isNewMarkerSet = false"
|
||||
/>
|
||||
<l-rectangle
|
||||
v-if="isNewMarkerSet"
|
||||
:bounds="[
|
||||
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
|
||||
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
|
||||
]"
|
||||
:weight="1"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
LMap,
|
||||
LMarker,
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { didInfo } from "@/libs/endorserServer";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
import { didInfo, ProjectData } from "@/libs/endorserServer";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
|
||||
const DEFAULT_LAT_LONG_DIFF = 0.01;
|
||||
const WORLD_ZOOM = 2;
|
||||
const DEFAULT_ZOOM = 2;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { AlertMessage, QuickNav, InfiniteScroll, EntityIcon },
|
||||
components: {
|
||||
LRectangle,
|
||||
QuickNav,
|
||||
InfiniteScroll,
|
||||
EntityIcon,
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
})
|
||||
export default class DiscoverView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
searchTerms = "";
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
projects: ProjectData[] = [];
|
||||
isChoosingSearchBox = false;
|
||||
isLocalActive = true;
|
||||
isRemoteActive = false;
|
||||
isNewMarkerSet = false;
|
||||
localCenterLat = 0;
|
||||
localCenterLong = 0;
|
||||
localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localCount = 0;
|
||||
localZoom = DEFAULT_ZOOM;
|
||||
remoteCount = 0;
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
isLoading = false;
|
||||
|
||||
// make this function available to the Vue template
|
||||
@@ -150,6 +257,9 @@ export default class DiscoverView extends Vue {
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||
this.resetLatLong();
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
@@ -159,8 +269,10 @@ export default class DiscoverView extends Vue {
|
||||
this.searchLocal();
|
||||
}
|
||||
|
||||
public async buildHeaders() {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
public async buildHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
@@ -181,16 +293,13 @@ export default class DiscoverView extends Vue {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async search(beforeId?: string) {
|
||||
public async searchAll(beforeId?: string) {
|
||||
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
|
||||
if (beforeId) {
|
||||
queryParams = queryParams + `&beforeId=${beforeId}`;
|
||||
}
|
||||
|
||||
this.isRemoteActive = true;
|
||||
this.isLocalActive = false;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await fetch(
|
||||
@@ -203,12 +312,13 @@ export default class DiscoverView extends Vue {
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
console.log("Problem with full search:", details);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `There was a problem accessing the server. Please try again later. (${details})`,
|
||||
text: `There was a problem accessing the server. Try again later.`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -221,14 +331,15 @@ export default class DiscoverView extends Vue {
|
||||
const plans: ProjectData[] = results.data;
|
||||
if (plans) {
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, rowid, issuerDid } = plan;
|
||||
this.projects.push({ name, description, handleId, rowid, issuerDid });
|
||||
const { name, description, handleId, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, rowid });
|
||||
}
|
||||
this.remoteCount = this.projects.length;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -245,14 +356,19 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
|
||||
public async searchLocal(beforeId?: string) {
|
||||
if (!this.searchBox) {
|
||||
this.projects = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const claimContents =
|
||||
"claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
let queryParams = [
|
||||
claimContents,
|
||||
"minLocLat=40.901000",
|
||||
"maxLocLat=40.904000",
|
||||
"westLocLon=-111.914000",
|
||||
"eastLocLon=-111.909000",
|
||||
"minLocLat=" + this.searchBox.bbox.minLat,
|
||||
"maxLocLat=" + this.searchBox.bbox.maxLat,
|
||||
"westLocLon=" + this.searchBox.bbox.westLong,
|
||||
"eastLocLon=" + this.searchBox.bbox.eastLong,
|
||||
].join("&");
|
||||
|
||||
if (beforeId) {
|
||||
@@ -261,8 +377,6 @@ export default class DiscoverView extends Vue {
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.isLocalActive = true;
|
||||
this.isRemoteActive = false;
|
||||
const response = await fetch(
|
||||
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
|
||||
{
|
||||
@@ -273,12 +387,13 @@ export default class DiscoverView extends Vue {
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
console.log("Problem with nearby search:", details);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `There was a problem accessing the server. Please try again later. (${details})`,
|
||||
text: "There was a problem accessing the server. Try again later.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -303,7 +418,8 @@ export default class DiscoverView extends Vue {
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -329,7 +445,7 @@ export default class DiscoverView extends Vue {
|
||||
if (this.isLocalActive) {
|
||||
this.searchLocal(latestProject["rowid"]);
|
||||
} else if (this.isRemoteActive) {
|
||||
this.search(latestProject["rowid"]);
|
||||
this.searchAll(latestProject["rowid"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +462,128 @@ export default class DiscoverView extends Vue {
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
setMapPoint(event: LeafletMouseEvent) {
|
||||
if (this.isNewMarkerSet) {
|
||||
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
|
||||
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
|
||||
} else {
|
||||
// marker is not set
|
||||
this.localCenterLat = event.latlng.lat;
|
||||
this.localCenterLong = event.latlng.lng;
|
||||
|
||||
let latDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
let longDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
// Guess at a size for the bounding box.
|
||||
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
|
||||
const bounds = event.target.boxZoom?._map?.getBounds();
|
||||
if (bounds) {
|
||||
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
|
||||
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
|
||||
}
|
||||
this.localLatDiff = latDiff;
|
||||
this.localLongDiff = longDiff;
|
||||
this.isNewMarkerSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
public resetLatLong() {
|
||||
if (this.searchBox?.bbox) {
|
||||
const bbox = this.searchBox.bbox;
|
||||
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
|
||||
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
|
||||
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
|
||||
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
this.isNewMarkerSet = true;
|
||||
} else {
|
||||
this.isNewMarkerSet = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async storeSearchBox() {
|
||||
if (this.localCenterLong || this.localCenterLat) {
|
||||
try {
|
||||
const newSearchBox = {
|
||||
name: "Local",
|
||||
bbox: {
|
||||
eastLong: this.localCenterLong + this.localLongDiff,
|
||||
maxLat: this.localCenterLat + this.localLatDiff,
|
||||
minLat: this.localCenterLat - this.localLatDiff,
|
||||
westLong: this.localCenterLong - this.localLongDiff,
|
||||
},
|
||||
};
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [newSearchBox],
|
||||
});
|
||||
this.searchBox = newSearchBox;
|
||||
this.isChoosingSearchBox = false;
|
||||
this.searchLocal();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "No Location Selected",
|
||||
text: "Select a location on the map.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async forgetSearchBox() {
|
||||
try {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [],
|
||||
});
|
||||
this.searchBox = null;
|
||||
this.localCenterLat = 0;
|
||||
this.localCenterLong = 0;
|
||||
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localZoom = DEFAULT_ZOOM;
|
||||
this.isChoosingSearchBox = false;
|
||||
this.isNewMarkerSet = false;
|
||||
this.searchLocal();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelSearchBoxSelect() {
|
||||
this.isChoosingSearchBox = false;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
}
|
||||
|
||||
public computedLocalTabClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<script lang="ts">
|
||||
import * as Package from "../../package.json";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {
|
||||
|
||||
@@ -92,13 +92,70 @@
|
||||
>
|
||||
Danger
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-permission',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notif ON
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-mute',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notif MUTE
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="
|
||||
this.$notify(
|
||||
{
|
||||
group: 'modal',
|
||||
type: 'notification-off',
|
||||
},
|
||||
-1,
|
||||
)
|
||||
"
|
||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||
>
|
||||
Notif OFF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold">Quick Action</h2>
|
||||
<p class="mb-4">Show appreciation to a contact:</p>
|
||||
<p class="mb-4">Record a gift from a contact:</p>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<EntityIcon
|
||||
:entityId="null"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Anonymous
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@@ -112,7 +169,7 @@
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || "(no name)" }}
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -129,6 +186,7 @@
|
||||
<!-- If there are no contacts, show this instead: -->
|
||||
<div
|
||||
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
||||
v-if="allContacts.length === 0"
|
||||
>
|
||||
(No contacts to show.)
|
||||
</div>
|
||||
@@ -168,40 +226,49 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiverOutputInfo,
|
||||
GiveServerRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class HomeView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
feedAllLoaded = false;
|
||||
feedData = [];
|
||||
feedPreviousOldestId = null;
|
||||
feedLastViewedId = null;
|
||||
feedPreviousOldestId?: string;
|
||||
feedLastViewedId?: string;
|
||||
isHiddenSpinner = true;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
@@ -209,7 +276,7 @@ export default class HomeView extends Vue {
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
@@ -225,7 +292,7 @@ export default class HomeView extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -247,7 +314,8 @@ export default class HomeView extends Vue {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.feedLastViewedId = settings?.lastViewedClaimId;
|
||||
this.updateAllFeed();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -263,7 +331,9 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
public async buildHeaders() {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
@@ -286,7 +356,7 @@ export default class HomeView extends Vue {
|
||||
|
||||
public async updateAllFeed() {
|
||||
this.isHiddenSpinner = false;
|
||||
await this.retrieveClaims(this.apiServer, null, this.feedPreviousOldestId)
|
||||
await this.retrieveClaims(this.apiServer, this.feedPreviousOldestId)
|
||||
.then(async (results) => {
|
||||
if (results.data.length > 0) {
|
||||
this.feedData = this.feedData.concat(results.data);
|
||||
@@ -321,7 +391,7 @@ export default class HomeView extends Vue {
|
||||
this.isHiddenSpinner = true;
|
||||
}
|
||||
|
||||
public async retrieveClaims(endorserApiServer, identifier, beforeId) {
|
||||
public async retrieveClaims(endorserApiServer: string, beforeId?: string) {
|
||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||
const response = await fetch(
|
||||
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
|
||||
@@ -344,14 +414,13 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
giveDescription(giveRecord) {
|
||||
let claim = giveRecord.fullClaim;
|
||||
if (claim.claim) {
|
||||
claim = claim.claim;
|
||||
}
|
||||
giveDescription(giveRecord: GiveServerRecord) {
|
||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const claim = (giveRecord.fullClaim as any).claim || giveRecord.fullClaim;
|
||||
// agent.did is for legacy data, before March 2023
|
||||
const giverDid =
|
||||
claim.agent?.identifier || claim.agent?.did;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const giverDid = claim.agent?.identifier || (claim.agent as any)?.did;
|
||||
const giverInfo = didInfo(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
@@ -362,7 +431,9 @@ export default class HomeView extends Vue {
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
: claim.description || "something unknown";
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
|
||||
const gaveRecipientId =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
claim.recipient?.identifier || (claim.recipient as any)?.did;
|
||||
const gaveRecipientInfo = gaveRecipientId
|
||||
? " to " +
|
||||
didInfo(
|
||||
@@ -375,23 +446,28 @@ export default class HomeView extends Vue {
|
||||
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
|
||||
}
|
||||
|
||||
displayAmount(code, amt) {
|
||||
displayAmount(code: string, amt: number) {
|
||||
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
||||
}
|
||||
|
||||
currencyShortWordForCode(unitCode, single) {
|
||||
currencyShortWordForCode(unitCode: string, single: boolean) {
|
||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||
}
|
||||
|
||||
openDialog(giver) {
|
||||
this.$refs.customDialog.open(giver);
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
}
|
||||
|
||||
handleDialogResult(result) {
|
||||
handleDialogResult(result: GiverOutputInfo) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
||||
resolve();
|
||||
this.recordGive(
|
||||
result.giver?.did,
|
||||
result.description,
|
||||
result.hours,
|
||||
).then(() => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// action was "cancel" so do nothing
|
||||
@@ -404,7 +480,11 @@ export default class HomeView extends Vue {
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
public async recordGive(giverDid, description, hours) {
|
||||
public async recordGive(
|
||||
giverDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -466,39 +546,35 @@ export default class HomeView extends Vue {
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with give 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:
|
||||
this.getGiveErrorMessage(error) ||
|
||||
"There was an error recording the give.",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setAlert(title, message) {
|
||||
this.alertTitle = title;
|
||||
this.alertMessage = message;
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
isGiveCreationError(result) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
}
|
||||
|
||||
getGiveCreationErrorMessage(result) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return result.data?.error?.message;
|
||||
}
|
||||
|
||||
getGiveErrorMessage(error) {
|
||||
return error.userMessage || error.response?.data?.error?.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -62,34 +62,38 @@
|
||||
>
|
||||
No Identity
|
||||
</a>
|
||||
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class IdentitySwitcherView extends Vue {
|
||||
Constants = AppString;
|
||||
public accounts: AccountsSchema;
|
||||
public activeDid;
|
||||
public firstName;
|
||||
public lastName;
|
||||
public alertTitle;
|
||||
public alertMessage;
|
||||
public otherIdentities = [];
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
Constants = AppString;
|
||||
public accounts: typeof AccountsSchema;
|
||||
public activeDid = "";
|
||||
public apiServer = "";
|
||||
public apiServerInput = "";
|
||||
public firstName = "";
|
||||
public lastName = "";
|
||||
public otherIdentities: Array<{ did: string }> = [];
|
||||
public showContactGives = false;
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
@@ -126,31 +130,20 @@ export default class IdentitySwitcherView extends Vue {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
err.message ===
|
||||
"Attempted to load account records with no identity available."
|
||||
) {
|
||||
this.limitsMessage = "No identity.";
|
||||
this.loadingLimits = false;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Creating Account",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to clear cache at page create because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Loading Accounts",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Telling user to clear cache at page create because:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async switchAccount(did: string) {
|
||||
async switchAccount(did?: string) {
|
||||
// 0 means none
|
||||
if (did === "0") {
|
||||
did = undefined;
|
||||
@@ -159,7 +152,7 @@ export default class IdentitySwitcherView extends Vue {
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: did,
|
||||
});
|
||||
this.activeDid = did;
|
||||
this.activeDid = did || "";
|
||||
this.otherIdentities = [];
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
|
||||
@@ -24,6 +24,24 @@
|
||||
v-model="mnemonic"
|
||||
/>
|
||||
{{ mnemonic }}
|
||||
<h3
|
||||
class="text-sm uppercase font-semibold mb-3"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
Advanced
|
||||
</h3>
|
||||
<div v-if="showAdvanced">
|
||||
Enter a custom derivation path
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="derivationPath"
|
||||
/>
|
||||
For previous uPort or Endorser users,
|
||||
<a @click="derivationPath = UPORT_DERIVATION_PATH" class="text-blue-500">
|
||||
click here to use that value.
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="from_mnemonic()"
|
||||
@@ -44,19 +62,26 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {
|
||||
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'";
|
||||
|
||||
mnemonic = "";
|
||||
address = "";
|
||||
privateHex = "";
|
||||
publicHex = "";
|
||||
derivationPath = "";
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
showAdvanced = false;
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
@@ -65,8 +90,10 @@ export default class ImportAccountView extends Vue {
|
||||
public async from_mnemonic() {
|
||||
const mne: string = this.mnemonic.trim().toLowerCase();
|
||||
if (this.mnemonic.trim().length > 0) {
|
||||
[this.address, this.privateHex, this.publicHex, this.derivationPath] =
|
||||
deriveAddress(mne);
|
||||
[this.address, this.privateHex, this.publicHex] = deriveAddress(
|
||||
mne,
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
const newId = newIdentifier(
|
||||
this.address,
|
||||
|
||||
163
src/views/ImportDerivedAccountView.vue
Normal file
163
src/views/ImportDerivedAccountView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left"></fa>
|
||||
</button>
|
||||
Derive from Existing Identity
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Import Account Form -->
|
||||
|
||||
<div>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Will increment the maximum derivation path from the existing seed.
|
||||
</p>
|
||||
|
||||
<p v-if="didArrays.length > 1">
|
||||
Choose existing DIDs from same seed phrase to compute derivation.
|
||||
</p>
|
||||
<ul class="mb-4">
|
||||
<li
|
||||
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
|
||||
v-for="dids in didArrays"
|
||||
:key="dids[0]"
|
||||
@click="switchAccount(dids[0])"
|
||||
>
|
||||
<fa
|
||||
v-if="dids[0] == selectedArrayFirstDid"
|
||||
icon="circle"
|
||||
class="fa-fw text-blue-400 text-xl mr-3"
|
||||
></fa>
|
||||
<fa
|
||||
v-else
|
||||
icon="circle"
|
||||
class="fa-fw text-slate-400 text-xl mr-3"
|
||||
></fa>
|
||||
<span class="overflow-hidden">
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<code>{{ dids.join(",") }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="incrementDerivation()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 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-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
didArrays: Array<Array<string>> = [];
|
||||
selectedArrayFirstDid = "";
|
||||
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const seedDids: Record<string, Array<string>> = {};
|
||||
accounts.forEach((account) => {
|
||||
const prevDids: Array<string> = seedDids[account.mnemonic] || [];
|
||||
seedDids[account.mnemonic] = prevDids.concat([account.did]);
|
||||
});
|
||||
this.didArrays = Object.values(seedDids);
|
||||
this.selectedArrayFirstDid = this.didArrays[0][0];
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
public switchAccount(did: string) {
|
||||
this.selectedArrayFirstDid = did;
|
||||
}
|
||||
|
||||
public async incrementDerivation() {
|
||||
await accountsDB.open();
|
||||
// find the maximum derivation path for the selected DIDs
|
||||
const selectedArray: Array<string> =
|
||||
this.didArrays.find((dids) => dids[0] === this.selectedArrayFirstDid) ||
|
||||
[];
|
||||
const allMatchingAccounts = await accountsDB.accounts
|
||||
.where("did")
|
||||
.anyOf(...selectedArray)
|
||||
.toArray();
|
||||
const accountWithMaxDeriv = allMatchingAccounts[0];
|
||||
allMatchingAccounts.slice(1).forEach((account) => {
|
||||
if (account.derivationPath > accountWithMaxDeriv.derivationPath) {
|
||||
accountWithMaxDeriv.derivationPath = account.derivationPath;
|
||||
}
|
||||
});
|
||||
// increment the last number in that max derivation path
|
||||
let lastStr = accountWithMaxDeriv.derivationPath.split("/").slice(-1)[0];
|
||||
if (lastStr.endsWith("'")) {
|
||||
lastStr = lastStr.slice(0, -1);
|
||||
}
|
||||
const lastNum = parseInt(lastStr, 10);
|
||||
const newLastNum = lastNum + 1;
|
||||
const newDerivPath = accountWithMaxDeriv.derivationPath
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.concat([newLastNum.toString() + "'"])
|
||||
.join("/");
|
||||
|
||||
const mne: string = accountWithMaxDeriv.mnemonic;
|
||||
|
||||
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
|
||||
|
||||
try {
|
||||
await accountsDB.accounts.add({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: newDerivPath,
|
||||
did: newId.did,
|
||||
identity: JSON.stringify(newId),
|
||||
mnemonic: mne,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
// record that as the active DID
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (err) {
|
||||
console.error("Error saving mnemonic & updating settings:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { db } from "@/db";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -39,6 +39,40 @@
|
||||
{{ description.length }}/500 max. characters
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
v-model="includeLocation"
|
||||
@change="includeLocation = true"
|
||||
/>
|
||||
<label for="includeLocation">Include Location</label>
|
||||
</div>
|
||||
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
||||
<l-map
|
||||
ref="map"
|
||||
v-model:zoom="zoom"
|
||||
:center="[0, 0]"
|
||||
@click="
|
||||
(event) => {
|
||||
latitude = event.latlng.lat;
|
||||
longitude = event.latlng.lng;
|
||||
}
|
||||
"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="latitude || longitude"
|
||||
:lat-lng="[latitude, longitude]"
|
||||
@click="maybeEraseLatLong()"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
:disabled="isHiddenSave"
|
||||
@@ -63,44 +97,53 @@
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import { useAppStore } from "@/store/app";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { AlertMessage },
|
||||
components: { LMap, LMarker, LTileLayer },
|
||||
})
|
||||
export default class NewEditProjectView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
projectName = "";
|
||||
description = "";
|
||||
errorMessage = "";
|
||||
includeLocation = false;
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
numAccounts = 0;
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
projectName = "";
|
||||
zoom = 2;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
@@ -116,7 +159,7 @@ export default class NewEditProjectView extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -175,7 +218,7 @@ export default class NewEditProjectView extends Vue {
|
||||
|
||||
private async SaveProject(identity: IIdentifier) {
|
||||
// Make a claim
|
||||
const vcClaim: VerifiableCredential = {
|
||||
const vcClaim: PlanVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PlanAction",
|
||||
name: this.projectName,
|
||||
@@ -185,6 +228,15 @@ export default class NewEditProjectView extends Vue {
|
||||
if (this.projectId) {
|
||||
vcClaim.identifier = this.projectId;
|
||||
}
|
||||
if (this.includeLocation) {
|
||||
vcClaim.location = {
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: this.latitude,
|
||||
longitude: this.longitude,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
@@ -218,22 +270,18 @@ export default class NewEditProjectView extends Vue {
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
||||
// version shows up here: https://endorser.ch:3000/api-docs/
|
||||
// version shows up here: https://api.endorser.ch/api-docs/
|
||||
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
|
||||
this.errorMessage = "";
|
||||
this.alertTitle = "";
|
||||
this.alertMessage = "";
|
||||
|
||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
||||
// version shows up here: https://endorser.ch:3000/api-docs/
|
||||
// version shows up here: https://api.endorser.ch/api-docs/
|
||||
useAppStore().setProjectId(
|
||||
resp.data.success.handleId || resp.data.success.fullIri,
|
||||
);
|
||||
setTimeout(
|
||||
function (that: Vue) {
|
||||
const route = {
|
||||
name: "project",
|
||||
};
|
||||
that.$router.push(route);
|
||||
function (that: NewEditProjectView) {
|
||||
that.$router.push({ name: "project" });
|
||||
},
|
||||
2000,
|
||||
this,
|
||||
@@ -241,11 +289,13 @@ export default class NewEditProjectView extends Vue {
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error saving the project.";
|
||||
const serverError = error as AxiosError;
|
||||
const serverError = error as AxiosError<{
|
||||
error?: { message?: string };
|
||||
}>;
|
||||
if (serverError) {
|
||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||
console.log(serverError);
|
||||
userMessage = serverError.response.data.error.message; // This is info for the user.
|
||||
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -299,6 +349,14 @@ export default class NewEditProjectView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
<script lang="ts">
|
||||
import "dexie-export-import";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class AccountViewView extends Vue {
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
<!-- Context Menu -->
|
||||
<a
|
||||
href=""
|
||||
class="text-lg text-center px-2 py-1 absolute -right-2 -top-1"
|
||||
><fa icon="ellipsis-vertical" class="fa-fw"></fa
|
||||
></a>
|
||||
|
||||
View Plan
|
||||
</h1>
|
||||
</div>
|
||||
@@ -38,13 +31,23 @@
|
||||
<div class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
||||
<div class="text-sm mb-3">
|
||||
<div class="truncate"
|
||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ issuer }}</div
|
||||
>
|
||||
<div
|
||||
><fa icon="calendar" class="fa-fw text-slate-400"></fa
|
||||
>{{ timeSince }}
|
||||
<div class="truncate">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ issuer }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
{{ timeSince }}
|
||||
</div>
|
||||
<div v-if="latitude || longitude">
|
||||
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
||||
<a
|
||||
:href="getOpenStreetMapUrl()"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>
|
||||
Map View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,6 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="issuer == activeDid"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onEditClick()"
|
||||
@@ -90,9 +94,11 @@
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<div class="mb-1">
|
||||
<fa icon="question-circle" class="fa-fw fa-xl text-slate-400"></fa>
|
||||
</div>
|
||||
<EntityIcon
|
||||
:entityId="null"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
@@ -104,9 +110,11 @@
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<fa icon="user" class="fa-fw fa-xl text-slate-400"></fa>
|
||||
</div>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
@@ -192,40 +200,45 @@
|
||||
message="Received from"
|
||||
>
|
||||
</GiftedDialog>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||
import * as moment from "moment";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiverOutputInfo,
|
||||
GiveServerRecord,
|
||||
ResultWithType,
|
||||
} from "@/libs/endorserServer";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, AlertMessage, QuickNav, EntityIcon },
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ProjectViewView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
@@ -233,6 +246,8 @@ export default class ProjectViewView extends Vue {
|
||||
expanded = false;
|
||||
givesToThis: Array<GiveServerRecord> = [];
|
||||
givesByThis: Array<GiveServerRecord> = [];
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
name = "";
|
||||
issuer = "";
|
||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||
@@ -256,7 +271,7 @@ export default class ProjectViewView extends Vue {
|
||||
this.LoadProject(identity);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
@@ -272,7 +287,7 @@ export default class ProjectViewView extends Vue {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity) {
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -290,7 +305,12 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
didInfo(did, activeDid, dids, contacts) {
|
||||
didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
dids: Array<string>,
|
||||
contacts: Array<Contact>,
|
||||
) {
|
||||
return didInfo(did, activeDid, dids, contacts);
|
||||
}
|
||||
|
||||
@@ -307,7 +327,7 @@ export default class ProjectViewView extends Vue {
|
||||
this.apiServer +
|
||||
"/api/claim/byHandle/" +
|
||||
encodeURIComponent(this.projectId);
|
||||
const headers = {
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (identity) {
|
||||
@@ -328,6 +348,8 @@ export default class ProjectViewView extends Vue {
|
||||
this.name = resp.data.claim?.name || "(no name)";
|
||||
this.description = resp.data.claim?.description || "(no description)";
|
||||
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
||||
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
||||
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
||||
} else if (resp.status === 404) {
|
||||
// actually, axios throws an error so we never get here
|
||||
this.$notify(
|
||||
@@ -439,15 +461,35 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(contact) {
|
||||
this.$refs.customDialog.open(contact);
|
||||
openDialog(contact: GiverInputInfo) {
|
||||
const dialog: GiftedDialog = this.$refs.customDialog as GiftedDialog;
|
||||
dialog.open(contact);
|
||||
}
|
||||
|
||||
handleDialogResult(result) {
|
||||
getOpenStreetMapUrl() {
|
||||
// Google URL is https://maps.google.com/?q=LAT,LONG
|
||||
return (
|
||||
"https://www.openstreetmap.org/?mlat=" +
|
||||
this.latitude +
|
||||
"&mlon=" +
|
||||
this.longitude +
|
||||
"#map=15/" +
|
||||
this.latitude +
|
||||
"/" +
|
||||
this.longitude
|
||||
);
|
||||
}
|
||||
|
||||
handleDialogResult(result: GiverOutputInfo) {
|
||||
if (result.action === "confirm") {
|
||||
return new Promise((resolve) => {
|
||||
this.recordGive(result.contact?.did, result.description, result.hours);
|
||||
resolve();
|
||||
this.recordGive(
|
||||
result.giver?.did,
|
||||
result.description,
|
||||
result.hours,
|
||||
).then(() => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// action was not "confirm" so do nothing
|
||||
@@ -460,7 +502,7 @@ export default class ProjectViewView extends Vue {
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
async recordGive(giverDid, description, hours) {
|
||||
async recordGive(giverDid?: string, description?: string, hours?: number) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -484,10 +526,7 @@ export default class ProjectViewView extends Vue {
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
} else {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
@@ -499,21 +538,7 @@ export default class ProjectViewView extends Vue {
|
||||
hours,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (result.status !== 201 || result.data?.error) {
|
||||
console.log("Error with give result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
result.data?.error?.message ||
|
||||
"There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
if (result.type == "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -523,21 +548,27 @@ export default class ProjectViewView extends Vue {
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
console.log("Error with give creation:", result);
|
||||
if (result.type != "error") {
|
||||
console.log(
|
||||
"... and it has an unexpected result type of",
|
||||
(result as ResultWithType).type,
|
||||
);
|
||||
}
|
||||
const message =
|
||||
result?.error?.userMessage ||
|
||||
"There was an error recording the Give.";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error with give caught:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
e.userMessage ||
|
||||
e.response?.data?.error?.message ||
|
||||
"There was an error recording the give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,28 +67,33 @@
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import EntityIcon from "@/components/EntityIcon";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { ProjectData } from "@/libs/endorserServer";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { InfiniteScroll, AlertMessage, QuickNav, EntityIcon },
|
||||
components: { InfiniteScroll, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ProjectsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
apiServer = "";
|
||||
projects: ProjectData[] = [];
|
||||
current: IIdentifier;
|
||||
@@ -119,21 +124,30 @@ export default class ProjectsView extends Vue {
|
||||
if (resp.status === 200 || !resp.data.data) {
|
||||
const plans: ProjectData[] = resp.data.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId = plan.fullIri, rowid } = plan;
|
||||
const { name, description, handleId, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, rowid });
|
||||
}
|
||||
} else {
|
||||
console.log("Bad server response & data:", resp.status, resp.data);
|
||||
throw Error("Failed to get projects from the server.");
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to get projects from the server. Try again later.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Got error loading projects:", error.message);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Got error loading projects:", error.message || error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Got an error loading projects: " + error.message,
|
||||
text: "Got an error loading projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -177,7 +191,7 @@ export default class ProjectsView extends Vue {
|
||||
await this.dataLoader(url, token);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid) {
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
<i>Don't 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 identity
|
||||
stored in this browser. If they are all based on the same seed as the
|
||||
current identity, this one backup is sufficient; however, if you have
|
||||
different seeds for other identities, you will have to back them up
|
||||
separately.
|
||||
</p>
|
||||
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@@ -42,27 +50,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>You do not have an active identity.</div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import * as R from "ramda";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { AlertMessage, QuickNav } })
|
||||
interface Account {
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class SeedBackupView extends Vue {
|
||||
activeAccount = null;
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeAccount: Account | null | undefined = null;
|
||||
numAccounts = 0;
|
||||
showSeed = false;
|
||||
alertMessage = "";
|
||||
alertTitle = "";
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
@@ -73,8 +88,9 @@ export default class SeedBackupView extends Vue {
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
this.numAccounts = accounts.length;
|
||||
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error("Got an error loading an identity:", err);
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -10,30 +10,46 @@
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Do you already have an identity to import?
|
||||
Do you have an identity to import?
|
||||
</p>
|
||||
<a
|
||||
@click="onClickYes()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
class="block w-full text-center text-lg uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
No
|
||||
</a>
|
||||
<a
|
||||
@click="onClickNo()"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>Yes</a
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
||||
>
|
||||
Yes
|
||||
</a>
|
||||
<a
|
||||
v-if="numAccounts > 0"
|
||||
@click="onClickDerive()"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mt-2"
|
||||
>
|
||||
Derive New Address from Seed Imported Previously
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB } from "@/db/index";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class StartView extends Vue {
|
||||
numAccounts = 0;
|
||||
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public onClickYes() {
|
||||
this.$router.push({ name: "new-identifier" });
|
||||
}
|
||||
@@ -41,5 +57,9 @@ export default class StartView extends Vue {
|
||||
public onClickNo() {
|
||||
this.$router.push({ name: "import-account" });
|
||||
}
|
||||
|
||||
public onClickDerive() {
|
||||
this.$router.push({ name: "import-derive" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,25 +34,35 @@
|
||||
</div>
|
||||
<button class="float-right" @click="captureGraphics()">Screenshot</button>
|
||||
<div id="scene-container" class="h-screen"></div>
|
||||
<AlertMessage
|
||||
:alertTitle="alertTitle"
|
||||
:alertMessage="alertMessage"
|
||||
></AlertMessage>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { SVGRenderer } from "three/addons/renderers/SVGRenderer.js";
|
||||
import { SVGRenderer } from "three/examples/jsm/renderers/SVGRenderer.js";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { World } from "@/components/World/World.js";
|
||||
import AlertMessage from "@/components/AlertMessage";
|
||||
import QuickNav from "@/components/QuickNav";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { AlertMessage, World, QuickNav } })
|
||||
interface RendererSVGType {
|
||||
domElement: Element;
|
||||
}
|
||||
|
||||
interface Dictionary<T> {
|
||||
[key: string]: T;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { World, QuickNav } })
|
||||
export default class StatisticsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
world: World;
|
||||
worldProperties: WorldProperties = {};
|
||||
alertTitle = "";
|
||||
alertMessage = "";
|
||||
worldProperties: Dictionary<number> = {};
|
||||
|
||||
mounted() {
|
||||
try {
|
||||
@@ -60,14 +70,14 @@ export default class StatisticsView extends Vue {
|
||||
const newWorld = new World(container, this);
|
||||
newWorld.start();
|
||||
this.world = newWorld;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Mounting Error",
|
||||
text: err.message,
|
||||
text: error.message,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -85,12 +95,12 @@ export default class StatisticsView extends Vue {
|
||||
ExportToSVG(rendererSVG, "test.svg");
|
||||
}
|
||||
|
||||
public setWorldProperty(propertyName, propertyValue) {
|
||||
public setWorldProperty(propertyName: string, propertyValue: number) {
|
||||
this.worldProperties[propertyName] = propertyValue;
|
||||
}
|
||||
}
|
||||
|
||||
function ExportToSVG(rendererSVG, filename) {
|
||||
function ExportToSVG(rendererSVG: RendererSVGType, filename: string) {
|
||||
const XMLS = new XMLSerializer();
|
||||
const svgfile = XMLS.serializeToString(rendererSVG.domElement);
|
||||
const svgData = svgfile;
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "./src",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
],
|
||||
"paths": {
|
||||
"@/components/*": ["components/*"],
|
||||
"@/views/*": ["views/*"],
|
||||
"@/db/*": ["db/*"],
|
||||
"@/libs/*": ["libs/*"],
|
||||
"@/constants/*": ["constants/*"],
|
||||
"@/store/*": ["store/*"],
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
389
web-push.md
Normal file
389
web-push.md
Normal file
@@ -0,0 +1,389 @@
|
||||
Web Push notifications is a web browser messaging protocol defined by the W3C.
|
||||
|
||||
Discussions of this interesting technology are clouded because of a
|
||||
terminological morass.
|
||||
|
||||
To understand how Web Push operates, we need to observe that are three (and
|
||||
potentially four) parties involved. These are:
|
||||
|
||||
1) The user's web browser. Let's call that BROWSER
|
||||
2) The Web Push Service Provider which is operated by the organization
|
||||
controlling the web browser's source code. Here named PROVIDER. An example of a
|
||||
PROVIDER is FCM (Firebase Cloud Messaging) which is owned by Google.
|
||||
3) The Web Application that a user is visiting from their web browser. Let's
|
||||
call this the SERVICE (short for Web Push application service)
|
||||
4) A Custom Web Push Intermediary Service, either third party or self-hosted.
|
||||
Called INTERMEDIARY here. FCM also may fit in this category if the SERVICE
|
||||
has an API key from FCM.]
|
||||
|
||||
The workflow works like this:
|
||||
|
||||
BROWSER visits a website which hosts a SERVICE.
|
||||
|
||||
The SERVICE asks BROWSER for its permission to subscribe to messages coming
|
||||
from the SERVICE.
|
||||
|
||||
The SERVICE will provide context and obtain explicit permission before prompting
|
||||
for notification permission:
|
||||
|
||||
In order to provide this context and explict permission a two-step opt-in process
|
||||
where the user is first presented with a pre-permission dialog box that explains
|
||||
what the notifications are for and why they are useful. This may help reduce the
|
||||
possibility of users clicking "don't allow".
|
||||
|
||||
Now, to explain what happens in Typescript, we can activate a browser's
|
||||
permission dialogue in this manner:
|
||||
|
||||
```
|
||||
function askPermission(): Promise<NotificationPermission> {
|
||||
return new Promise(function(resolve, reject) {
|
||||
const permissionResult = Notification.requestPermission(function(result) {
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
if (permissionResult) {
|
||||
permissionResult.then(resolve, reject);
|
||||
}
|
||||
}).then(function(permissionResult) {
|
||||
if (permissionResult !== 'granted') {
|
||||
throw new Error("We weren't granted permission.");
|
||||
}
|
||||
return permissionResult;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The Notification.permission property indicates the permission level for the
|
||||
current session and returns one of the following string values:
|
||||
|
||||
'granted': The user has granted permission for notifications.
|
||||
'denied': The user has denied permission for notifications.
|
||||
'default': The user has not made a choice yet.
|
||||
|
||||
Once the user has granted permission, the client application registers a service
|
||||
worker using the `ServiceWorkerRegistration` API.
|
||||
|
||||
The `ServiceWorkerRegistration` API is accessible via the browser's `navigator`
|
||||
object and the `navigator.serviceWorker` child object and ultimately directly
|
||||
accessible via the navigator.serviceWorker.register method which also creates
|
||||
the service worker or the navigator.serviceWorker.getRegistration method.
|
||||
|
||||
Once you have a `ServiceWorkerRegistration` object, that object will provide a
|
||||
child object named `pushManager` through which subscription and management of
|
||||
subscriptions may be done.
|
||||
|
||||
Let's go through the `register` method first:
|
||||
|
||||
```
|
||||
navigator.serviceWorker.register('sw.js', { scope: '/' })
|
||||
.then(function(registration) {
|
||||
console.log('Service worker registered successfully:', registration);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.log('Service worker registration failed:', error);
|
||||
});
|
||||
```
|
||||
|
||||
The `sw.js` file contains the logic for what a service worker should do.
|
||||
It executes in a separate thread of execution from the web page but provides a
|
||||
means of communicating between itself and the web page via messages.
|
||||
|
||||
Note that there is a scope can specify what network requests it may
|
||||
intercept.
|
||||
|
||||
The Vue project already has its own service worker but it is possible to
|
||||
create multiple service worker files by registering them on different scopes.
|
||||
|
||||
It is useful architecturally to specify a separate server worker file.
|
||||
|
||||
In the case of web push, the path of the scope only has reference to the domain
|
||||
of the service worker and no relationship to the pathing for the web push
|
||||
server. In order to specify more than one server workers each needs to be on
|
||||
different scope paths!
|
||||
|
||||
Here's a version which can be used for testing locally. Note there can be
|
||||
caching issues in your browser! Incognito is highly recommended.
|
||||
|
||||
sw-dev.ts
|
||||
```
|
||||
self.addEventListener('push', function(event: PushEvent) {
|
||||
console.log('Received a push message', event);
|
||||
|
||||
const title = 'Push message';
|
||||
const body = 'The message body';
|
||||
const icon = '/images/icon-192x192.png';
|
||||
const tag = 'simple-push-demo-notification-tag';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: body,
|
||||
icon: icon,
|
||||
tag: tag
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
vue.config.js
|
||||
```
|
||||
module.exports = {
|
||||
pwa: {
|
||||
workboxOptions: {
|
||||
importScripts: ['sw-dev.ts']
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Once we have the service worker registered and the ServiceWorkerRegistration is
|
||||
returned, we then have access to a `pushManager` property object. This property
|
||||
allows us to continue with the web push work flow.
|
||||
|
||||
In the next step, BROWSER requests a data structure from SERVICE called a VAPID
|
||||
(Voluntary Application Server Identification) which is the public key from a
|
||||
key-pair.
|
||||
|
||||
The VAPID is a specification used to identify the application server (i.e. the
|
||||
SERVICE server) that is sending push messages through a push PROVIDER. It's an
|
||||
authentication mechanism that allows the server to demonstrate its identity to
|
||||
the push PROVIDER, by use of a public and private key pair. These keys are used
|
||||
by the SERVICE in encrypting messages being sent to the BROWSER, as well as
|
||||
being used by the BROWSER in decrypting the messages coming from the SERVICE.
|
||||
|
||||
The VAPID (Voluntary Application Server Identification) key provides more
|
||||
security and authenticity for web push notifications in the following ways:
|
||||
|
||||
Identifying the Application Server:
|
||||
|
||||
The VAPID key is used to identify the application server that is sending
|
||||
the push notifications. This ensures that the push notifications are
|
||||
authentic and not sent by a malicious third party.
|
||||
|
||||
Encrypting the Messages:
|
||||
|
||||
The VAPID key is used to sign the push notifications sent by the
|
||||
application server, ensuring that they are not tampered with during
|
||||
transmission. This provides an additional layer of security and
|
||||
authenticity for the push notifications.
|
||||
|
||||
Adding Contact Information:
|
||||
|
||||
The VAPID key allows a web application to add contact information to
|
||||
the push messages sent to the browser push service. This enables the
|
||||
push service to contact the application server in case of need or
|
||||
provide additional debug information about the push messages.
|
||||
|
||||
Improving Delivery Rates:
|
||||
|
||||
Using the VAPID key can help improve the overall performance of web push
|
||||
notifications, specifically improving delivery rates. By streamlining the
|
||||
delivery process, the chance of delivery errors along the way is lessened.
|
||||
|
||||
If the BROWSER accepts and grants permission to subscribe to receiving from the
|
||||
SERVICE Web Push messages, then the BROWSER makes a subscription request to
|
||||
PROVIDER which creates and stores a special URL for that BROWSER.
|
||||
|
||||
Here's a bit of code describing the above process:
|
||||
|
||||
```
|
||||
// b64 is the VAPID
|
||||
b64 = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
|
||||
const applicationServerKey = urlBase64ToUint8Array(b64);
|
||||
const options: PushSubscriptionOptions = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
};
|
||||
|
||||
registration.pushManager.subscribe(options)
|
||||
.then(function(subscription) {
|
||||
console.log('Push subscription successful:', subscription);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Push subscription failed:', error);
|
||||
});
|
||||
```
|
||||
|
||||
In this example, the `applicationServerKey` variable contains the VAPID public
|
||||
key, which is converted to a `Uint8Array` using a function such as this:
|
||||
|
||||
```
|
||||
export function toUint8Array(base64String: string, atobFn: typeof atob): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const rawData = atobFn(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
```
|
||||
|
||||
The options object is of type `PushSubscriptionOptions`, which includes the
|
||||
`userVisibleOnly` and `applicationServerKey` (ie VAPID public key) properties.
|
||||
|
||||
options: An object that contains the options used for creating the
|
||||
subscription. This object itself has the following sub-properties:
|
||||
|
||||
applicationServerKey: A public key your push service uses for application
|
||||
server identification. This is normally a Uint8Array.
|
||||
|
||||
userVisibleOnly: A boolean value indicating that the push messages that
|
||||
are sent should be made visible to the user through a notification.
|
||||
This is often set to true.
|
||||
|
||||
The subscribe() method returns a `Promise` that resolves to a `PushSubscription`
|
||||
object containing details of the subscription, such as the endpoint URL and the
|
||||
public key. The returned data would have a form like this:
|
||||
|
||||
{
|
||||
"endpoint": "https://some.pushservice.com/some/unique/identifier",
|
||||
"expirationTime": null,
|
||||
"keys": {
|
||||
"p256dh": "some_base64_encoded_string",
|
||||
"auth": "some_other_base64_encoded_string"
|
||||
}
|
||||
}
|
||||
|
||||
endpoint: A string representing the endpoint URL for the push service. This
|
||||
URL is essentially the push service address to which the push message would
|
||||
be sent for this particular subscription.
|
||||
|
||||
expirationTime: A DOMHighResTimeStamp (which is basically a number or null)
|
||||
representing the subscription's expiration time in milliseconds since
|
||||
01 January, 1970 UTC. This can be null if the subscription never expires.
|
||||
|
||||
The BROWSER will, internally, then use that URL to check for incoming messages
|
||||
by way of the service worker we described earlier. The BROWSER also sends this
|
||||
URL back to SERVICE which will use that URL to send messages to the BROWSER via
|
||||
the PROVIDER.
|
||||
|
||||
Ultimately, the actual internal process of receiving messages varies from BROWSER
|
||||
to BROWSER. Approaches vary from long-polling HTTP connections to WebSockets. A
|
||||
lot of handwaving and voodoo magic. The bottom line is that the BROWSER itself
|
||||
manages the connection to the PROVIDER whilst the SERVICE must send messages
|
||||
via the PROVIDER so that they reach the BROWSER service worker.
|
||||
|
||||
Just to remind us that in our service worker our code for receiving messages
|
||||
will look something like this:
|
||||
|
||||
```
|
||||
self.addEventListener('push', function(event: PushEvent) {
|
||||
console.log('Received a push message', event);
|
||||
|
||||
const title = 'Push message';
|
||||
const body = 'The message body';
|
||||
const icon = '/images/icon-192x192.png';
|
||||
const tag = 'simple-push-demo-notification-tag';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: body,
|
||||
icon: icon,
|
||||
tag: tag
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
Now to address the issue of receiving notification messages on mobile devices.
|
||||
It should be noted that Web Push messages are only received when BROWSER is
|
||||
open, except in the cases of Chrome and Firefox mobile BROWSERS. In iOS, the
|
||||
mobile application (in our case a PWA) must be added to the Home Screen and
|
||||
permissions must be explicitly granted that allow the application to receive
|
||||
push notifications. Further, with an iOS device the user must enable wake on
|
||||
notification to have their device light-up when it receives a notification
|
||||
(https://support.apple.com/enus/HT208081).
|
||||
|
||||
So what about #4? - The INTERMEDIARY. Well, It is possible under very special
|
||||
circumstances to create your own Web Push PROVIDER. The only case I've found so
|
||||
far relates to making an Android Custom ROM. (An Android Custom ROM is a
|
||||
customized version of the Android Operating System.) There are open source
|
||||
IMTERMEDIARY products such as UnifiedPush (https://unifiedpush.org/) which can
|
||||
fulfill this role. If you are using iOS you are not permitted to make or use
|
||||
your own custom Web Push PROVIDER. Apple will never allow anyone to do that.
|
||||
Apple has none of its own.
|
||||
|
||||
It is, however, possible to have a sort of proxy working between your SERVICE
|
||||
and FCM (or iOS). Services that mash up various Push notification services (like
|
||||
OneSignal) can perform in the role of such proxies.
|
||||
|
||||
#4 -The INTERMEDIARY- doesn't appear to be anything we should be spending our
|
||||
time on.
|
||||
|
||||
A BROWSER may also remove a subscription. In order to remove a subscription,
|
||||
the registration record must be retrieved from the serviceWorker using
|
||||
`navigator.serviceWorker.ready`. Within the `ready` property is the
|
||||
`pushManager` which has a `getSubscription` method. Once you have the
|
||||
subscription object, you may call the `unsubscribe` method. `unsubscribe` is
|
||||
asynchronnous and returns a boolean true if it is successful in removing the
|
||||
subscription and false if not.
|
||||
|
||||
|
||||
```
|
||||
async function unsubscribeFromPush() {
|
||||
// Check if the browser supports service workers
|
||||
if ("serviceWorker" in navigator) {
|
||||
// Get the registration object for the service worker
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Get the existing subscription
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
// Unsubscribe
|
||||
const successful = await subscription.unsubscribe();
|
||||
if (successful) {
|
||||
console.log("Successfully unsubscribed from push notifications.");
|
||||
// You can also inform your server to remove this subscription
|
||||
} else {
|
||||
console.log("Failed to unsubscribe from push notifications.");
|
||||
}
|
||||
} else {
|
||||
console.log("No subscription was found.");
|
||||
}
|
||||
} else {
|
||||
console.log("Service workers are not supported by this browser.");
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
unsubscribeFromPush().catch((err) => {
|
||||
console.error("An error occurred while unsubscribing from push notifications", err);
|
||||
});
|
||||
```
|
||||
|
||||
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
|
||||
|
||||
|
||||
## NOTIFICATION DIALOG WORKFLOW
|
||||
|
||||
# ON APP FIRST-LAUNCH:
|
||||
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
|
||||
|
||||
- "Turn on Notifications": triggers the browser's own notification permission prompt.
|
||||
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
|
||||
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
|
||||
|
||||
# IF THE USER CHOOSES "NEVER":
|
||||
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
|
||||
|
||||
# TO TEMPORARILY MUTE NOTIFICATIONS:
|
||||
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
|
||||
|
||||
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
|
||||
- "Mute until I turn it back on" button to indefinitely mute notifications.
|
||||
- "Cancel" to make no changes and dismiss the dialog.
|
||||
|
||||
# TO UNMUTE NOTIFICATIONS:
|
||||
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
|
||||
|
||||
# TO TURN OFF NOTIFICATIONS:
|
||||
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
|
||||
|
||||
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).
|
||||
- "Leave it On" to make no changes and dismiss the dialog.
|
||||
Reference in New Issue
Block a user