Compare commits

..

55 Commits

Author SHA1 Message Date
1befff0abd Merge pull request 'misc tweaks for new vite build' (#4) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent3 into feat/vitejs
Reviewed-on: jsnbuchanan/crowd-funder-for-time-pwa#4
2024-04-09 06:05:55 -04:00
0b446ec134 misc tweaks for new vite build 2024-04-07 18:12:33 -06:00
333ac773f6 Merge pull request 'A couple small fixes, plus a merge from master' (#1) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent into feat/vitejs
Reviewed-on: jsnbuchanan/crowd-funder-for-time-pwa#1
2024-04-07 13:52:43 -04:00
1459719a47 remove a lingering debug console.log 2024-04-07 11:39:13 -06:00
5994365a6c fix title of the test app 2024-04-07 11:32:53 -06:00
28f72640d7 add linting before any build 2024-04-07 11:22:20 -06:00
ab81648aca fix linting 2024-04-07 11:02:54 -06:00
5828a290c7 Merge remote-tracking branch 'original-origin/master' into feat/vitejs-trent 2024-04-07 09:41:14 -06:00
78fab735e6 avoid a huge error message in a likely-well-known scenario 2024-04-07 09:24:55 -06:00
2ae165d56f reorder home page vapid check to avoid an error on localhost 2024-04-07 09:16:42 -06:00
0fbd1ad51a add missing Dexie import (which causes failure upon download click) 2024-04-07 09:13:32 -06:00
d49bf61524 on home page, change the filtered button color 2024-04-06 17:58:10 -06:00
1a80bbb714 Merge pull request 'ui-additions-2024-03' (#113) from ui-additions-2024-03 into master
Reviewed-on: #113
2024-04-06 19:46:16 -04:00
5a2a8659f7 Merge branch 'master' into ui-additions-2024-03 2024-04-06 17:45:32 -06:00
0632fb9b39 show in description when recipient is a project (not just Anonymous) 2024-04-06 17:39:40 -06:00
640d273646 filter by selections (now all working), add cache for plans 2024-04-06 14:01:18 -06:00
8f4289c14d Merge pull request 'send a time for notifications to the push server' (#112) from notify-time into master
Reviewed-on: #112
2024-04-03 22:04:36 -04:00
7f56c90d97 Merge pull request 'ui-fixes-2024-03' (#111) from ui-fixes-2024-03 into master
Reviewed-on: #111
2024-04-03 22:04:02 -04:00
8e1daf7015 feed filter: save the changed values to the DB, go to map if no location chosen, reload if necessary 2024-04-03 19:54:01 -06:00
Jose Olarte III
e90a0be6d9 Names and variables for filter toggles 2024-04-03 15:51:23 +08:00
489bb76a60 adjust more code to the PushSubscriptionJSON 2024-04-02 19:32:41 -06:00
2ca33bb9eb adjust the notification-subscription objects to try and send correct info 2024-04-02 19:18:31 -06:00
121181c6a1 add adjustment to UTC hour for notification time 2024-04-02 18:38:44 -06:00
e4cf79b558 update tasks 2024-04-01 19:06:18 -06:00
7692cc2b35 add logic to send a time for notifications 2024-04-01 19:04:54 -06:00
Jose Olarte III
0b4f2484f7 Additions to Account View 2024-03-29 21:41:14 +08:00
Jose Olarte III
cfd53bc186 Removed one more 2024-03-29 15:55:16 +08:00
Jose Olarte III
7f66addfe3 Filter options reduced for release 2024-03-29 15:53:46 +08:00
Jose Olarte III
4635c1ac48 Feed filters dialog 2024-03-27 19:57:31 +08:00
Jose Olarte III
55da3d0b1c Map fix #2 2024-03-26 21:38:21 +08:00
Jose Olarte III
dce4d3cc72 Button width changes
For buttons that are next to each other
2024-03-26 19:55:16 +08:00
Jose Olarte III
e028197a2a Optimized grid space for wider screens 2024-03-26 17:12:55 +08:00
Jose Olarte III
0dab475d8b Fixed map z-index 2024-03-26 16:54:43 +08:00
Jose Olarte III
4e227fc07a Added close icon to gifted prompts dialog 2024-03-26 16:02:24 +08:00
2dfc8fedaa refactor tasks 2024-03-25 19:03:01 -06:00
035f2a5b04 docs: adding do for updated development server run command
- `npm run dev`
2024-03-25 08:15:04 -06:00
09dccc34d6 fix: buffer typescript error in util.ts when parsing ArrayBuffer 2024-03-25 08:10:38 -06:00
b28104af5b bump version and add -beta 2024-03-24 19:04:24 -06:00
3a07e31d63 bump to version 0.3.6 2024-03-24 18:28:42 -06:00
35455e6648 fix check for more camera-device options 2024-03-24 18:27:06 -06:00
0e2c5af16e add onboarding help instructions as separate page 2024-03-24 17:01:53 -06:00
1fc5b0ea2b Merge pull request 'add button during photo to switch to mirror mode' (#109) from photo-reverse into master
Reviewed-on: #109
2024-03-24 18:59:50 -04:00
ca240ab795 fix: es modules syntax for buffer deps instead of commonjs require 2024-03-24 13:05:22 -06:00
01b5ca6ec8 chore: update vitejs config to deploy on the same default port as the @vue/cli-service
This port is 8080. This is done to not break existing tooling and devops code.
2024-03-24 12:05:09 -06:00
6f49260c1e fix: AccountViewView.vue template not resolving dep for dexie-export-import/dist/import
Previous error:

Error: The following dependencies are imported but could not be resolved:

  dexie-export-import/dist/import (imported by /Users/jason/dev/src/trent/crowd-funder-for-time-pwa/src/views/AccountViewView.vue?id=0)

Are they installed?
    at file:///Users/jason/dev/src/trent/crowd-funder-for-time-pwa/node_modules/vite/dist/node/chunks/dep-DJaaTb_D.js:52506:23
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async file:///Users/jason/dev/src/trent/crowd-funder-for-time-pwa/node_modules/vite/dist/node/chunks/dep-DJaaTb_D.js:51972:38
2024-03-24 11:51:58 -06:00
38f44771e9 Initial stab at vitejs update 2024-03-24 11:18:29 -06:00
be2154f847 change icon for detail view (from circle-info to file-lines) 2024-03-23 19:07:53 -06:00
4ad4cab25e add blurb explaining what data is shared with the world 2024-03-23 18:45:26 -06:00
e020caaa50 show warnings before dismissing prompt, and add to tasks and help 2024-03-23 17:35:58 -06:00
40d12b1f9c add button on photo to switch to mirror mode 2024-03-23 16:31:23 -06:00
28754bdfb1 bump version to 0.3.5 2024-03-23 02:41:25 -06:00
2b8f9579f1 fix so that project agent & location removals get saved 2024-03-23 02:31:44 -06:00
6dc0c2cd58 add a camera-switch button 2024-03-23 01:32:55 -06:00
cc6d0958dc Merge pull request 'fix: npm audit fix to resolve vulnerabilities 1 low, 3 moderate, 1 high' (#108) from jsnbuchanan/crowd-funder-for-time-pwa:master into master
Reviewed-on: #108
2024-03-22 12:01:21 -04:00
7ce00b86e8 deps: npm audit fix to resolve vulnerabilities 1 low, 3 moderate, 1 high
There are still 9 moderate severity vulnerabilities, but I will work on those independentally because they may involve updating to library version that have breaking changes.
2024-03-22 09:29:42 -06:00
62 changed files with 3638 additions and 9607 deletions

View File

@@ -1,3 +1,7 @@
# I tried and failed to set things here with vue-cli-service but # I tried setting values here and using `vue-cli-service build --mode development`
# things may be more reliable with vite so let's try again. # but it didn't create some things in "dist":
# - the "css" directory with the CSS extracted from Vue files
# - the sw_scripts-combined* files
#
# ¯\_(ツ)_/¯

View File

@@ -5,80 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.14]
### Added
- Clearer give-confirmation screen
- BX currency https://thebx.medium.com/
- Deselection of project on gifted details page
### Fixed
- Don't show registration pop-up for a new contact that is registered
### Changed in DB or environment
- Nothing
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
### Added
- Photos on projects
### Changed in DB or environment
- Nothing
## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
### Fixed
- Photo share (share_target) failed because requests were sent to server
### Changed in DB or environment
- Nothing
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
### Added
- Choose a file for gifts, and a URL for gifts & profiles
### Fixed
- Multiple button pushes were required to switch camera
### Changed in DB or environment
- Nothing
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
### Added
- Share an image
- Choose a file on the device for a profile image
### Changed in DB or environment
- Nothing
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
### Added
- Offers on contacts page
- Checks on front page until they show as registered
### Changed
- Scanned contacts now add immediately and prompt for registration.
- Better UI for gives on contact page
- Better UI for all confirmation messages
### Fixed
- Repeated elements at top of main feed
### Changed in DB or environment
- Nothing
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
### Added
- Profile image for user
### Fixed
- Slow loading of home page feed
### Changed in DB or environment
- Nothing
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
### Added
- Filter on home page feed
- Ability to set time of daily notification
- Jump to app on click of notification
### Changed
- Built with vite
- Descriptions on home page to include projects
### Changed in DB or environment ### Changed in DB or environment
- Nothing - Nothing

View File

@@ -10,44 +10,44 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup ## Setup
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh` We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
``` ```
npm install npm install
``` ```
### Compile and hot-reloads for development ### Compiles and hot-reloads for development
``` ```
npm run dev npm run dev
``` ```
### Build the test & production app ### Builds the production app
``` ```
npm run serve npm run serve
``` ```
### Lint and fix files ### Lints and fixes files
``` ```
npm run lint npm run lint
``` ```
### Compile and minify for test & production ### Compiles and minifies for production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations. * If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
* `npx prettier --write ./sw_scripts/` * `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`. * Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
* Record what version is currently on production. * Record what version is currently on production.
* Run the correct build: * Run the correct build
* Test * Test
``` ```
# (Let's replace this with a .env.development or .env.staging file.) # (See .env.development for more details.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there. # The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app PASSKEYS_ENABLED=yep npm run build VITE_TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build
``` ```
* Production * Production
@@ -95,11 +95,11 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
### Manual walk-through test ### Manual walk-through test
- Backup seed & data & get a CSV dump from Endorser Mobile. - If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act.
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
- Use a mobile user as well as a desktop user. - Use a mobile user as well as a desktop user.
- Backup seed & data & get a CSV dump from Endorser Mobile.
- Check that the version is updated. - Check that the version is updated.
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts. - Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
- Make sure that it's using the test API (under Identity in 'Advanced'). - Make sure that it's using the test API (under Identity in 'Advanced').
- Clear the browser data again. (See "Reset" below.) - Clear the browser data again. (See "Reset" below.)
- Go to the account page before visiting the home page to see that there is no ID. - Go to the account page before visiting the home page to see that there is no ID.
@@ -109,19 +109,15 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
- Copy the contact URL. - Copy the contact URL.
- On each page, verify the messaging, and that they cannot take action. - On each page, verify the messaging, and that they cannot take action.
- On the discovery page, check that they can see projects, and set a search area to see projects nearby. - On the discovery page, check that they can see projects, and set a search area to see projects nearby.
- On the contacts page, check that they can add a contact even without their own ID. - On the contacts page, check that they can add User #0 even without their own ID.
- Install the PWA. - As User #0 in another browser on the test API, add a give & a project.
- As User 0 in another browser on the test API, add a give & a project. - `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
- Note that some combinations of desktop with mobile emulation stretch the image. - With the new user on the home page, see the feed that shows User #0 in network but without the name.
- Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage` - As the new user on the contacts page, add User #0 as a contact.
- Add new user as a contact (which allows them to see User 0). - On the home page, see the feed that shows User #0 with a name.
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
- As the new user, import contacts & identifiers.
- As the new user on the contacts page, add User 0 as a contact.
- On the home page, see the feed that shows User 0 with a name.
- Switch back to the generated identifier. - Switch back to the generated identifier.
- On the account page, check that they see messages on limits. - On the account page, check that they see messages on limits.
- As User 0, register the ID. - As User #0, register the ID.
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery. - As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
- On the contacts page, check that they cannot register someone else yet. - On the contacts page, check that they cannot register someone else yet.
- Walk through the functions on each page. - Walk through the functions on each page.
@@ -129,7 +125,6 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
- Export & import, both seed and contacts & settings. - Export & import, both seed and contacts & settings.
- Choose location on the search map. - Choose location on the search map.
- Offer, deliver a give, and confirm. Create a third user and test connections. - Offer, deliver a give, and confirm. Create a third user and test connections.
- On mobile, share an image with the app.
- Switch to "no identifier" to see that things look OK without any ID. - Switch to "no identifier" to see that things look OK without any ID.
### Clear/Reset data & restart ### Clear/Reset data & restart

4030
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.7-beta",
"private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"postbuild": "cp ./sw_scripts-combined.js dist/",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js" "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js"
}, },
"dependencies": { "dependencies": {
@@ -16,25 +18,20 @@
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6", "@fortawesome/vue-fontawesome": "^3.0.6",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1", "@tweenjs/tween.js": "^21.1.1",
"@types/js-yaml": "^4.0.9",
"@types/luxon": "^3.4.2",
"@veramo/core": "^5.6.0", "@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0", "@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0", "@veramo/data-store": "^5.6.0",
"@veramo/did-manager": "^5.6.0", "@veramo/did-manager": "^5.6.0",
"@veramo/did-provider-ethr": "^5.6.0", "@veramo/did-provider-ethr": "^5.6.0",
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0", "@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0", "@veramo/key-manager": "^5.6.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.1", "dexie-export-import": "^4.1.1",
@@ -49,6 +46,7 @@
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"moment": "^2.30.1",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",
@@ -65,15 +63,12 @@
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4", "vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3", "vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"web-did-resolver": "^2.0.27" "web-did-resolver": "^2.0.27"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/ramda": "^0.29.11", "@types/ramda": "^0.29.11",
"@types/three": "^0.155.1", "@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
@@ -92,7 +87,6 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0"
"vite-plugin-pwa": "^0.19.8"
} }
} }

View File

@@ -1,4 +1,181 @@
tasks : tasks :
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d - fix the notification link to the app
- 01 change scanning flow - allow them to stay on the QR/scanning screen after scanning someone
- 24 contextual tutorials https://docs.google.com/document/d/11C_K3RM0rgo0onih20KFhcIzukZyq_CRWqaWX5om_kM/edit#heading=h.iwiwcydou5hw
- feeds - add "remote" filter, if they choose 'visible' then warn that they won't see any others, cache list & don't reload front page on change
- .1 add shortcut from project (etc?) to the public project page in a browser
- .1 add KindSpring link to ideas
- .1 on feed, don't show "to someone anonymous" if it's to a project
- 16 save data backups in Google
- 16 generate and use passkeys for identities
- .5 show "give" buttons (eg. from anonymous) even if they can't give, greyed out, and give them a warning and instructions
- .2 when adding a claim on home screen, push that claim to the top of the list
- .2 fix give dialog from "more contacts" off home page to allow giving to this user
- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window)
- .2 don't show a warning on a totally new project when the authorized agent is set
- .2 anchor hash into BTC
- .2 list the "show more" contacts alphabetically
- .5 add back the explicit wait for browser subscription timing problems?
- .5 make Time Safari a share_target for images
- 08 add image on profile
- 01 ask to detect location & record it in settings
- 01 if personal location is set, show potential local affiliations
- 02 refactor the buttons for chosing a search location so that the actions are clear assignee-group:ui
- 24 compelling UI for credential presentations
- discover who in my network has activity on a project
- 24 compelling UI for statistics (eg. World?)
- 01 in the feed, group by project or contact or topic or time/$ (via BC); new projects, offers, search area, etc assignee-group:ui
- 01 separate not-on-platform vs totally anonymous; terminology "unidentified"?
- .2 add links between projects assignee-group:ui
- 24 make the contact browsing on the front page something that invites more action
- .5 change server plan & project endpoints to use jwtId as identifier rather than rowid
- 16 edit offers & gives, or revoke allowing re-creation
- .1 When available in the server, give message for 'nonAmountGiven' on offers on ProjectsView page.
- .1 Add help instructions for "Encryption key has changed" error. (It is a problem if localStorage is cleared, but the contacts & settings remain and they have to restore their seeds.)
- .1 show better error when user with no ID goes to the "My Project" page
- 01 in front page prompt for ideas for gratitude :
- randomize (not show in order)
- checkboxes - show non-person-oriented messages, show only contacts, show only projects
- .5 add a notice on the front page if their notifications are off
- 08 allow user to add a time when they want their daily notification
- .5 prompt for the name directly when they visit the QR scan page
- 01 mark a project as inactive
- 01 add share button for sending a message to confirmers when we can't see the claim (like the "visible" links)
- .5 add TimeSafari as a shareable app https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
- .5 choose a project's alternative agent ("authorized representative") via a contact chooser (not just copy-paste a DID)
- .5 find out why clicking quickly back-and-forth onto the "my project" page often shows error "You need an identifier to load your projects." (easier to reproduce on desktop?)
- .5 bug - it didn't show the "fulfills offer" on the claim detail page for a give that had one - https://test.timesafari.app/claim/01HMFWRPA3PD6Q9EYFKX3MC41J
- 01 replace all "confirm" prompts with nicer modal
- .1 hide project-create button on project page if not registered
- .1 hide offer & give buttons on project list page if not registered
- .1 add cursor-pointer on the icons for giving on the project page, and on the list of projects on the discover page
- .2 record when InfiniteScroll hits the end of the list and don't trigger any more loads (feed, project list, give & offer lists)
- bug (that is hard to reproduce) - got blank screen and errors on iPhone with no bottom tabs
- bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.)
- refactor - supply the projectId to the OfferDialog just like we do with the GiftedDialog offerId (in the "open" method, maybe as well as an attribute)
- the confirm button on each give on the ProjectViewView page doesn't have all the context of the ClaimView page, so it can show sometimes inappropriately; consider consolidation
- make the "give" on contact screen work like other give (allowing donation vs current blank)
- .2 on ClaimView, the "ask someone" should refer to "visible" IDs, or to confirmations only if confirmations are visible
- message "send them to this page" on ClaimView should be a link (for installed app)
- When we update a version, desktop browser users have seen nothing happen after clicking on the contact page QR and on the account page "Help"; errors show in the console. Reload fixed it. If this happens on mobile, ask the user to reload.
- 01 show my VCs - most interesting, or via search
- 04 allow user to download & prove chains of VCs, mine + ones I can see about me from others
- revenue to support server operation
- .1 copy button for seed
- .5 If notifications are not enabled, add message to front page with link/button to enable
- make server endpoint for full English description of limits
- create a help-desk document & add screenshots
- .1 update "offer" units to have same functionality as "give" units
- .5 add a link to any 'give' records that fulfill an offer on ClaimView
- 01 on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed)
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list)
- bug (that is hard to reproduce) - got error adding on Firefox user #0 as contact for themselves
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function"
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
- 04 remove 'rowid' references (that are sqlite-specific); may involve server
- 04 look at other examples for better onboarding UI, eg friend.tech
- .5 Add inactive flag / end date, start date to project
- .3 check that Android shows "back" buttons on screens without bottom tray
- .1 Make give description text box into something that expands as they type?
- .2 Show a warning if both giver and recipient are the same (but still allow?)
- .5 Shrink the buttons on project pages so they don't expand to the width of the screen assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
- switch some checks for activeDid to check for isRegistered
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
- warn if they're using the web (android only?)
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps
https://web.dev/articles/get-installed-related-apps
- .5 fix the "onboarding help" list of instructions so that it always formats right (currently doesn't show numbers aligned on Google Pixel 6a, iPhone 11 Pro, iPhone 12 mini)
- .5 make the "onboarding help" it so that it doesn't cover the QR icon on the contacts page
- .5 fix masked icon (because some of the top-right of the binoculars is cut off)
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 show error to user when adding a duplicate contact
- 01 parse input more robustly (with CSV lib and not commas)
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people (gnome?) on world
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- .5 show seed phrase in a QR code for transfer to another device
- .5 on DiscoverView, switch to a filter UI (eg. just from friend)
- .5 don't show "Offer" on project screen if they aren't registered
- 01 especially for iOS, check for new version & update, eg. https://stackoverflow.com/questions/52221805/any-way-yet-to-auto-update-or-just-clear-the-cache-on-a-pwa-on-ios
- 24 allow a person record with interests, including location; purpose? contact methods? enhance other connections the same? (suggestion from Philippines) assignee-group:ui
- 24 brief introduction slides https://docs.google.com/document/d/11C_K3RM0rgo0onih20KFhcIzukZyq_CRWqaWX5om_kM/edit#heading=h.iwiwcydou5hw
- 12 feedback https://docs.google.com/document/d/11C_K3RM0rgo0onih20KFhcIzukZyq_CRWqaWX5om_kM/edit#heading=h.iwiwcydou5hw
- 32 accept images for projects
- 32 accept images for contacts
- import project interactions from GitHub/GitLab and manage signing
- show total time offered to & fulfilled to a project
- show total time offered by & fulfilled by a contact
- linking between projects or plans :
- show total time given to & from a project
- terminology:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
- Stats :
- 01 point out user's location on the world
- 01 present a credential selected from the stats
- 04 show gives spreading to other places
- badge for most gives/receives/confirms per day/week/month
- badge for amount given/offered to your project
- set a goal of given/offers
- automated tests, eg. pup-test or cypress
- Notifications (wake on the phone, push notifications)
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee
- pull instead of push, maybe via scheduled runs
- have a notification pop-up on Mac screen
- 16 Connect with phone contacts - this may be a whole different app, because we want a quick link A) to the same phone contact and B) from the phone contact app
- Support KERI AIDs
- Support Peer DIDs
- Support messaging through DIDComm
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections -- and that allows spam
- .1 When Chrome shows compatibility https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
then change the canShare check in this app to check the real canShare() method.
log :
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27

View File

@@ -129,10 +129,7 @@
</div> </div>
</NotificationGroup> </NotificationGroup>
<!-- <!-- These are general-purpose messages - except there are some for turning app notifications on and off. -->
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
-->
<NotificationGroup group="modal"> <NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full"> <div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification <Notification
@@ -146,19 +143,13 @@
move="transition duration-500" move="transition duration-500"
move-delay="delay-300" move-delay="delay-300"
> >
<!-- see NotificationIface in constants/app.ts -->
<div <div
v-for="notification in notifications" v-for="notification in notifications"
:key="notification.id" :key="notification.id"
class="w-full" class="w-full"
role="alert" role="alert"
> >
<!-- <!-- type "confirm" will post a message and, with onYes function, show a "Yes" button to call that function -->
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
-->
<div <div
v-if="notification.type === 'confirm'" v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -167,10 +158,9 @@
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" 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"> <div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg"> <p class="text-lg mb-4">
{{ notification.title }} {{ notification.title }}
</span> </p>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button <button
v-if="notification.onYes" v-if="notification.onYes"
@@ -181,55 +171,10 @@
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
> >
Yes Yes
{{ notification.yesText ? ", " + notification.yesText : "" }}
</button> </button>
<button <button
v-if="notification.onNo" @click="close(notification.id)"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No {{ notification.noText ? ", " + notification.noText : "" }}
</button>
<label
v-if="notification.promptToStopAsking && notification.onNo"
for="toggleStopAsking"
class="flex items-center justify-between cursor-pointer my-4"
@click="stopAsking = !stopAsking"
>
<!-- label -->
<span class="ml-2">... and do not ask again.</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
name="stopAsking"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<button
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
> >
{{ notification.onYes ? "Cancel" : "Close" }} {{ notification.onYes ? "Cancel" : "Close" }}
@@ -413,7 +358,6 @@ import { sendTestThroughPushServer } from "@/libs/util";
export default class App extends Vue { export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
b64 = ""; b64 = "";
hourAm = true; hourAm = true;
hourInput = "8"; hourInput = "8";

View File

@@ -5,36 +5,20 @@
import { createAvatar, StyleOptions } from "@dicebear/core"; import { createAvatar, StyleOptions } from "@dicebear/core";
import { avataaars } from "@dicebear/collection"; import { avataaars } from "@dicebear/collection";
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { Contact } from "@/db/tables/contacts";
@Component @Component
export default class EntityIcon extends Vue { export default class EntityIcon extends Vue {
@Prop contact: Contact; @Prop entityId = "";
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
@Prop iconSize = 0; @Prop iconSize = 0;
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIcon() { generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl; const options: StyleOptions<object> = {
if (imageUrl) { seed: this.entityId || "",
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; size: this.iconSize,
} else { };
const identifier = this.contact?.did || this.entityId; const avatar = createAvatar(avataaars, options);
if (!identifier) { const svgString = avatar.toString();
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; return svgString;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = {
seed: (identifier as string) || "",
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString();
return svgString;
}
} }
} }
</script> </script>

View File

@@ -2,12 +2,12 @@
<div v-if="visible" class="dialog-overlay"> <div v-if="visible" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
<h1 class="text-xl font-bold text-center mb-4"> <h1 class="text-xl font-bold text-center mb-4">
{{ customTitle }} {{ message }} {{ giver?.name || "somebody not named" }}
</h1> </h1>
<input <input
type="text" type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was given" placeholder="What was received"
v-model="description" v-model="description"
/> />
<div class="flex flex-row justify-center"> <div class="flex flex-row justify-center">
@@ -15,7 +15,7 @@
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20" class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()" @click="changeUnitCode()"
> >
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }} {{ libsUtil.UNIT_SHORT[unitCode] }}
</span> </span>
<div <div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@@ -45,16 +45,15 @@
description, description,
giverDid: giver?.did, giverDid: giver?.did,
giverName: giver?.name, giverName: giver?.name,
message,
offerId, offerId,
projectId, projectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode, unitCode,
}, },
}" }"
class="text-blue-500" class="text-blue-500"
> >
Photo & Details ... Photo, ...
</router-link> </router-link>
</span> </span>
</div> </div>
@@ -91,7 +90,7 @@ import { NotificationIface } from "@/constants/app";
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
GiverReceiverInputInfo, GiverInputInfo,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
@@ -102,7 +101,9 @@ import { Contact } from "@/db/tables/contacts";
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop message = "";
@Prop projectId = ""; @Prop projectId = "";
@Prop showGivenToUser = false;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
@@ -110,32 +111,22 @@ export default class GiftedDialog extends Vue {
apiServer = ""; apiServer = "";
amountInput = "0"; amountInput = "0";
callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string;
description = ""; description = "";
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent givenToUser = false;
giver?: GiverInputInfo; // undefined means no identified giver agent
isTrade = false; isTrade = false;
offerId = ""; offerId = "";
receiver?: GiverReceiverInputInfo;
unitCode = "HUR"; unitCode = "HUR";
visible = false; visible = false;
libsUtil = libsUtil; libsUtil = libsUtil;
async open( async open(giver?: GiverInputInfo, offerId?: string) {
giver?: GiverReceiverInputInfo,
receiver?: GiverReceiverInputInfo,
offerId?: string,
customTitle?: string,
callbackOnSuccess?: (amount: number) => void,
) {
this.customTitle = customTitle;
this.description = ""; this.description = "";
this.giver = giver; this.giver = giver || {};
this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true // if we show "given to user" selection, default checkbox to true
this.givenToUser = this.showGivenToUser;
this.amountInput = "0"; this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || ""; this.offerId = offerId || "";
try { try {
@@ -150,7 +141,7 @@ export default class GiftedDialog extends Vue {
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did); this.allMyDids = allAccounts.map((acc) => acc.did);
if (this.giver && !this.giver.name) { if (!this.giver.name) {
this.giver.name = didInfo( this.giver.name = didInfo(
this.giver.did, this.giver.did,
this.activeDid, this.activeDid,
@@ -205,6 +196,7 @@ export default class GiftedDialog extends Vue {
eraseValues() { eraseValues() {
this.description = ""; this.description = "";
this.giver = undefined; this.giver = undefined;
this.givenToUser = this.showGivenToUser;
this.amountInput = "0"; this.amountInput = "0";
this.unitCode = "HUR"; this.unitCode = "HUR";
} }
@@ -262,7 +254,6 @@ export default class GiftedDialog extends Vue {
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
await this.recordGive( await this.recordGive(
(this.giver?.did as string) || null, (this.giver?.did as string) || null,
(this.receiver?.did as string) || null,
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.unitCode, this.unitCode,
@@ -274,27 +265,26 @@ export default class GiftedDialog extends Vue {
/** /**
* *
* @param giverDid may be null * @param giverDid may be null
* @param recipientDid may be null
* @param description may be an empty string * @param description may be an empty string
* @param amount may be 0 * @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR" * @param unitCode may be omitted, defaults to "HUR"
*/ */
async recordGive( public async recordGive(
giverDid: string | null, giverDid: string | null,
recipientDid: string | null,
description: string, description: string,
amount: number, amountInput: number,
unitCode: string = "HUR", unitCode: string = "HUR",
) { ) {
try { try {
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitGive( const result = await createAndSubmitGive(
this.axios, this.axios,
this.apiServer, this.apiServer,
this.activeDid, identity,
giverDid, giverDid,
this.receiver?.did as string, this.givenToUser ? this.activeDid : undefined,
description, description,
amount, amountInput,
unitCode, unitCode,
this.projectId, this.projectId,
this.offerId, this.offerId,
@@ -326,14 +316,11 @@ export default class GiftedDialog extends Vue {
}, },
7000, 7000,
); );
if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount);
}
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
console.error("Error with give recordation caught:", error); console.error("Error with give recordation caught:", error);
const errorMessage = const message =
error.userMessage || error.userMessage ||
error.response?.data?.error?.message || error.response?.data?.error?.message ||
"There was an error recording the give."; "There was an error recording the give.";
@@ -342,7 +329,7 @@ export default class GiftedDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: errorMessage, text: message,
}, },
-1, -1,
); );

View File

@@ -4,7 +4,7 @@
<div class="text-lg text-center font-light relative z-50"> <div class="text-lg text-center font-light relative z-50">
<div <div
id="ViewHeading" id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none" class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-2 bg-black/50 text-white leading-none"
> >
<span v-if="uploading"> Uploading... </span> <span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span> <span v-else-if="blob"> Look Good? </span>
@@ -12,7 +12,7 @@
</div> </div>
<div <div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" class="text-lg text-center p-2 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<fa icon="xmark" class="w-[1em]"></fa> <fa icon="xmark" class="w-[1em]"></fa>
@@ -20,59 +20,28 @@
</div> </div>
<div v-if="uploading" class="flex justify-center"> <div v-if="uploading" class="flex justify-center">
<fa <fa icon="spinner" class="fa-spin fa-3x text-center block" />
icon="spinner"
class="fa-spin fa-3x text-center block px-12 py-12"
/>
</div> </div>
<div v-else-if="blob"> <div v-else-if="blob">
<div v-if="crop"> <div
<VuePictureCropper class="flex justify-center gap-2 absolute bottom-[1rem] left-[1rem] right-[1rem] bg-black/50 px-4 py-2"
:boxStyle="{ >
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 9 / 9,
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
<!-- This gives a round cropper.
:presetMode="{
mode: 'round',
}"
-->
</div>
<div v-else>
<div class="flex justify-center">
<img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
/>
</div>
</div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
<button <button
@click="uploadImage" @click="uploadImage"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white font-bold py-2 px-4 rounded-md"
> >
<span>Upload</span> <span>Upload</span>
</button> </button>
</div>
<div
v-if="showRetry"
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button <button
@click="retryImage" @click="retryImage"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white font-bold py-2 px-4 rounded-md"
> >
<span>Retry</span> <span>Retry</span>
</button> </button>
</div> </div>
<div class="flex justify-center">
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" />
</div>
</div> </div>
<div v-else ref="cameraContainer"> <div v-else ref="cameraContainer">
<!-- <!--
@@ -123,27 +92,23 @@
import axios from "axios"; import axios from "axios";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { getIdentity } from "@/libs/util";
import { db } from "@/db/index"; import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto"; import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera, VuePictureCropper } }) @Component({ components: { Camera } })
export default class PhotoDialog extends Vue { export default class GiftedPhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0; activeDeviceNumber = 0;
activeDid = ""; activeDid = "";
blob?: Blob; blob: Blob | null = null;
claimType = "";
crop = false;
fileName?: string;
mirror = false; mirror = false;
numDevices = 0; numDevices = 0;
setImageCallback: (arg: string) => void = () => {}; setImage: (arg: string) => void = () => {};
showRetry = true;
uploading = false; uploading = false;
visible = false; visible = false;
@@ -169,31 +134,13 @@ export default class PhotoDialog extends Vue {
} }
} }
open( open(setImageFn: (arg: string) => void) {
setImageFn: (arg: string) => void,
claimType: string,
crop?: boolean,
blob?: Blob, // for image upload, just to use the cropping function
inputFileName?: string,
) {
this.visible = true; this.visible = true;
this.claimType = claimType;
this.crop = !!crop;
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = "none"; bottomNav.style.display = "none";
} }
this.setImageCallback = setImageFn; this.setImage = setImageFn;
if (blob) {
this.blob = blob;
this.fileName = inputFileName;
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false;
} else {
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
}
} }
close() { close() {
@@ -202,7 +149,7 @@ export default class PhotoDialog extends Vue {
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = ""; bottomNav.style.display = "";
} }
this.blob = undefined; this.blob = null;
} }
async cameraStarted() { async cameraStarted() {
@@ -210,12 +157,6 @@ export default class PhotoDialog extends Vue {
if (cameraComponent) { if (cameraComponent) {
this.numDevices = (await cameraComponent.devices(["videoinput"])).length; this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
this.mirror = cameraComponent.facingMode === "user"; this.mirror = cameraComponent.facingMode === "user";
// figure out which device is active
const currentDeviceId = cameraComponent.currentDeviceID();
const devices = await cameraComponent.devices(["videoinput"]);
this.activeDeviceNumber = devices.findIndex(
(device) => device.deviceId === currentDeviceId,
);
} }
} }
@@ -223,9 +164,7 @@ export default class PhotoDialog extends Vue {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices; this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
const devices = await cameraComponent?.devices(["videoinput"]); const devices = await cameraComponent?.devices(["videoinput"]);
await cameraComponent?.changeCamera( cameraComponent?.changeCamera(devices[this.activeDeviceNumber].deviceId);
devices[this.activeDeviceNumber].deviceId,
);
} }
async takeImage(/* payload: MouseEvent */) { async takeImage(/* payload: MouseEvent */) {
@@ -266,13 +205,10 @@ export default class PhotoDialog extends Vue {
// The resolution is only necessary because of that mobile portrait-orientation case. // The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine. // The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob = this.blob = await cameraComponent?.snapshot({
(await cameraComponent?.snapshot({ height: imageHeight,
height: imageHeight, width: imageWidth,
width: imageWidth, }); // png is default; if that changes, change extension in formData.append
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) { if (!this.blob) {
this.$notify( this.$notify(
{ {
@@ -287,12 +223,8 @@ export default class PhotoDialog extends Vue {
} }
} }
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() { async retryImage() {
this.blob = undefined; this.blob = null;
} }
/**** /****
@@ -342,12 +274,8 @@ export default class PhotoDialog extends Vue {
async uploadImage() { async uploadImage() {
this.uploading = true; this.uploading = true;
const identifier = await getIdentity(this.activeDid);
if (this.crop) { const token = await accessToken(identifier);
this.blob = (await cropper?.getBlob()) || undefined;
}
const token = await accessToken(this.activeDid);
const headers = { const headers = {
Authorization: "Bearer " + token, Authorization: "Bearer " + token,
}; };
@@ -366,8 +294,8 @@ export default class PhotoDialog extends Vue {
this.uploading = false; this.uploading = false;
return; return;
} }
formData.append("image", this.blob, this.fileName || "snapshot.png"); formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot()
formData.append("claimType", this.claimType); formData.append("claimType", "GiveAction");
try { try {
const response = await axios.post( const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image", DEFAULT_IMAGE_API_SERVER + "/image",
@@ -376,8 +304,9 @@ export default class PhotoDialog extends Vue {
); );
this.uploading = false; this.uploading = false;
this.close(); this.visible = false;
this.setImageCallback(response.data.url as string); this.blob = null;
this.setImage(response.data.url as string);
} catch (error) { } catch (error) {
console.error("Error uploading the image", error); console.error("Error uploading the image", error);
this.$notify( this.$notify(
@@ -385,12 +314,12 @@ export default class PhotoDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was an error saving the picture.", text: "There was an error saving the picture. Please try again.",
}, },
5000, 5000,
); );
this.uploading = false; this.uploading = false;
this.blob = undefined; this.blob = null;
} }
} }

View File

@@ -1,177 +0,0 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
Camera or Other?
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</div>
<div>
<div class="text-center mt-8">
<div class>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog()"
/>
</div>
<div class="mt-4">
<input type="file" @change="uploadImageFile" />
</div>
<div class="mt-4">
<span class="mt-2">
... or paste a URL:
<input type="text" v-model="imageUrl" class="border-2" />
</span>
<span class="ml-2">
<fa
v-if="imageUrl"
icon="check"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl"
/>
<!-- so that there's no shifting when it becomes visible -->
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
</span>
</div>
</div>
</div>
</div>
</div>
<PhotoDialog ref="photoDialog" />
</template>
<script lang="ts">
import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "@/components/PhotoDialog.vue";
import { NotificationIface } from "@/constants/app";
const inputImageFileNameRef = ref<Blob>();
@Component({
components: { PhotoDialog },
})
export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string;
crop: boolean = false;
imageCallback: (imageUrl?: string) => void = () => {};
imageUrl?: string;
visible = false;
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName,
);
}
async uploadImageFile(event: Event) {
this.visible = false;
inputImageFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.openPhotoDialog(blob, file.name as string);
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
async acceptUrl() {
this.visible = false;
if (this.crop) {
try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob
});
const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error retrieving that image.",
},
5000,
);
}
} else {
this.imageCallback(this.imageUrl);
}
}
close() {
this.visible = false;
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
}
</style>

View File

@@ -43,7 +43,7 @@
<input <input
type="text" type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r" class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="datePlaceholder()" :placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
v-model="expirationDateInput" v-model="expirationDateInput"
/> />
</div> </div>
@@ -69,7 +69,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon";
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@@ -82,7 +81,8 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId? = ""; @Prop message = "";
@Prop projectId = "";
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
@@ -91,20 +91,16 @@ export default class OfferDialog extends Vue {
amountUnitCode = "HUR"; amountUnitCode = "HUR";
description = ""; description = "";
expirationDateInput = ""; expirationDateInput = "";
recipientDid? = "";
visible = false; visible = false;
libsUtil = libsUtil; libsUtil = libsUtil;
async open(recipientDid?: string) { async open() {
try { try {
this.recipientDid = recipientDid;
await db.open(); await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
console.error("Error retrieving settings from database:", err); console.error("Error retrieving settings from database:", err);
@@ -144,12 +140,6 @@ export default class OfferDialog extends Vue {
)}`; )}`;
} }
datePlaceholder() {
return (
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
);
}
cancel() { cancel() {
this.close(); this.close();
this.eraseValues(); this.eraseValues();
@@ -223,15 +213,15 @@ export default class OfferDialog extends Vue {
} }
try { try {
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitOffer( const result = await createAndSubmitOffer(
this.axios, this.axios,
this.apiServer, this.apiServer,
this.activeDid, identity,
description, description,
amount, amount,
unitCode, unitCode,
expirationDateInput, expirationDateInput,
this.recipientDid,
this.projectId, this.projectId,
); );

View File

@@ -1,17 +1,5 @@
<template> <template>
<a <div v-html="generateIdenticon()" class="w-fit"></div>
v-if="linkToFull && imageUrl"
:href="imageUrl"
target="_blank"
class="h-full w-full object-contain"
>
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
</a>
<div
v-else
v-html="generateIdenticon()"
class="h-full w-full object-contain"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from "jdenticon"; import { toSvg } from "jdenticon";
@@ -33,17 +21,11 @@ const BLANK_CONFIG = {
export default class ProjectIcon extends Vue { export default class ProjectIcon extends Vue {
@Prop entityId = ""; @Prop entityId = "";
@Prop iconSize = 0; @Prop iconSize = 0;
@Prop imageUrl = "";
@Prop linkToFull = false;
generateIdenticon() { generateIdenticon() {
if (this.imageUrl) { const config = this.entityId ? undefined : BLANK_CONFIG;
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`; const svgString = toSvg(this.entityId, this.iconSize, config);
} else { return svgString;
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
}
} }
} }
</script> </script>

View File

@@ -12,7 +12,7 @@
}" }"
> >
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"> <router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw" /> <fa icon="house-chimney" class="fa-fw"></fa>
</router-link> </router-link>
</li> </li>
<!-- Search --> <!-- Search -->
@@ -28,7 +28,7 @@
:to="{ name: 'discover' }" :to="{ name: 'discover' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="magnifying-glass" class="fa-fw" /> <fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link> </router-link>
</li> </li>
<!-- Projects --> <!-- Projects -->
@@ -44,7 +44,7 @@
:to="{ name: 'projects' }" :to="{ name: 'projects' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="hand" class="fa-fw" /> <fa icon="folder-open" class="fa-fw"></fa>
</router-link> </router-link>
</li> </li>
<!-- Contacts --> <!-- Contacts -->
@@ -60,7 +60,7 @@
:to="{ name: 'contacts' }" :to="{ name: 'contacts' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="users" class="fa-fw" /> <fa icon="users" class="fa-fw"></fa>
</router-link> </router-link>
</li> </li>
<!-- Profile --> <!-- Profile -->
@@ -76,7 +76,7 @@
:to="{ name: 'account' }" :to="{ name: 'account' }"
class="block text-center py-3 px-1" class="block text-center py-3 px-1"
> >
<fa icon="circle-user" class="fa-fw" /> <fa icon="circle-user" class="fa-fw"></fa>
</router-link> </router-link>
</li> </li>
</ul> </ul>

View File

@@ -1,11 +1,12 @@
import axios from "axios"; import axios from "axios";
import * as R from "ramda";
import * as THREE from "three"; import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils"; import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js"; import * as TWEEN from "@tweenjs/tween.js";
import { db } from "@/db"; import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getHeaders } from "@/libs/endorserServer"; import { accessToken } from "@/libs/crypto";
const ANIMATION_DURATION_SECS = 10; const ANIMATION_DURATION_SECS = 10;
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/"; const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
@@ -18,7 +19,17 @@ export async function loadLandmarks(vue, world, scene, loop) {
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || ""; const activeDid = settings?.activeDid || "";
const apiServer = settings?.apiServer; const apiServer = settings?.apiServer;
const headers = await getHeaders(activeDid); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const headers = {
"Content-Type": "application/json",
};
const identity = JSON.parse(account?.identity || "null");
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction"; const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
const resp = await axios.get(url, { headers: headers }); const resp = await axios.get(url, { headers: headers });

View File

@@ -4,10 +4,6 @@
* See also ../libs/veramo/setup.ts * See also ../libs/veramo/setup.ts
*/ */
export enum AppString { export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch", PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch", TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000", LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
@@ -34,11 +30,6 @@ export const DEFAULT_IMAGE_API_SERVER =
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host; window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
/** /**
* The possible values for "group" and "type" are in App.vue. * The possible values for "group" and "type" are in App.vue.
* From the notiwind package * From the notiwind package
@@ -47,11 +38,6 @@ export interface NotificationIface {
group: string; // "alert" | "modal" group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger" type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string; title: string;
text?: string; text: string;
noText?: string;
onCancel?: (stopAsking: boolean) => Promise<void>;
onNo?: (stopAsking: boolean) => Promise<void>;
onYes?: () => Promise<void>; onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
yesText?: string;
} }

View File

@@ -8,7 +8,6 @@ import {
Settings, Settings,
SettingsSchema, SettingsSchema,
} from "./tables/settings"; } from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data // Define types for tables that hold sensitive and non-sensitive data
@@ -17,7 +16,6 @@ type NonsensitiveTables = {
contacts: Table<Contact>; contacts: Table<Contact>;
logs: Table<Log>; logs: Table<Log>;
settings: Table<Settings>; settings: Table<Settings>;
temp: Table<Temp>;
}; };
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
@@ -27,7 +25,14 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
// Initialize Dexie databases for sensitive and non-sensitive data // Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = {
...ContactSchema,
...LogSchema,
...SettingsSchema,
};
// Manage the encryption key. If not present in localStorage, create and store it. // Manage the encryption key. If not present in localStorage, create and store it.
const secret = const secret =
@@ -37,18 +42,11 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key // Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret }); encrypted(accountsDB, { secretKey: secret });
// Define the schemas for our databases // Define the schema for our databases
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning accountsDB.version(1).stores(SensitiveSchemas);
accountsDB.version(1).stores(AccountsSchema); // v1 was contacts & settings
// v1 also had contacts & settings // v2 added logs
// v2 added Log db.version(2).stores(NonsensitiveSchemas);
db.version(2).stores({
...ContactSchema,
...LogSchema,
...SettingsSchema,
});
// v3 added Temp
db.version(3).stores(TempSchema);
// Event handler to initialize the non-sensitive database with default settings // Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => { db.on("populate", () => {

View File

@@ -3,46 +3,41 @@
*/ */
export type Account = { export type Account = {
/** /**
* Auto-generated ID by Dexie * Auto-generated ID by Dexie.
*/ */
id?: number; id?: number;
/** /**
* The date the account was created * The date the account was created.
*/ */
dateCreated: string; dateCreated: string;
/** /**
* The derivation path for the account, if this is from a mnemonic * The derivation path for the account.
*/ */
derivationPath?: string; derivationPath: string;
/** /**
* Decentralized Identifier (DID) for the account * Decentralized Identifier (DID) for the account.
*/ */
did: string; did: string;
/** /**
* Stringified JSON containing underlying key material, if generated from a mnemonic * Stringified JSON containing underlying key material.
* Based on the IIdentifier type from Veramo * Based on the IIdentifier type from Veramo.
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts} * @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
*/ */
identity?: string; identity: string;
/** /**
* The mnemonic phrase for the account, if this is from a mnemonic * The public key in hexadecimal format.
*/
mnemonic?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
/**
* The public key in hexadecimal format
*/ */
publicKeyHex: string; publicKeyHex: string;
/**
* The mnemonic passphrase for the account.
*/
mnemonic: string;
}; };
/** /**

View File

@@ -2,7 +2,6 @@ export interface Contact {
did: string; did: string;
name?: string; name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
profileImageUrl?: string;
publicKeyBase64?: string; publicKeyBase64?: string;
seesMe?: boolean; seesMe?: boolean;
registered?: boolean; registered?: boolean;

View File

@@ -20,14 +20,11 @@ export type Settings = {
filterFeedByNearby?: boolean; // filter by nearby filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
firstName?: string; // user's full name firstName?: string; // User's first name
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean; isRegistered?: boolean;
lastName?: string; // deprecated - put all names in firstName lastName?: string; // deprecated - put all names in firstName
lastNotifiedClaimId?: string; lastNotifiedClaimId?: string; // Last notified claim ID
lastViewedClaimId?: string; lastViewedClaimId?: string; // Last viewed claim ID
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
profileImageUrl?: string;
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders reminderOn?: boolean; // Toggle to enable or disable reminders
@@ -38,8 +35,7 @@ export type Settings = {
}>; }>;
showContactGivesInline?: boolean; // Display contact inline or not showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag showShortcutBvc?: boolean; // Show shortcut for BVC actions
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server warnIfTestServer?: boolean; // Warn if using a testing server
@@ -47,7 +43,7 @@ export type Settings = {
}; };
export function isAnyFeedFilterOn(settings: Settings): boolean { export function isAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible); return !!(settings.filterFeedByNearby || settings.filterFeedByVisible);
} }
/** /**
@@ -61,5 +57,3 @@ export const SettingsSchema = {
* Constants. * Constants.
*/ */
export const MASTER_SETTINGS_KEY = 1; export const MASTER_SETTINGS_KEY = 1;
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

View File

@@ -1,13 +0,0 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = {
id: string;
blob?: Blob;
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = {
temp: "id",
};

View File

@@ -3,18 +3,14 @@ import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39"; import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english"; import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode"; import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
import { import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
export const LOCAL_KMS_NAME = "local";
/** /**
* *
* *
@@ -35,7 +31,7 @@ export const newIdentifier = (
keys: [ keys: [
{ {
kid: publicHex, kid: publicHex,
kms: LOCAL_KMS_NAME, kms: "local",
meta: { derivationPath: derivationPath }, meta: { derivationPath: derivationPath },
privateKeyHex: privateHex, privateKeyHex: privateHex,
publicKeyHex: publicHex, publicKeyHex: publicHex,
@@ -68,10 +64,6 @@ export const deriveAddress = (
return [address, privateHex, publicHex, derivationPath]; return [address, privateHex, publicHex, derivationPath];
}; };
export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes);
};
/** /**
* *
* *
@@ -85,21 +77,82 @@ export const generateSeed = (): string => {
}; };
/** /**
* Retrieve an access token, or "" if no DID is provided. * Retreive an access token
* *
* @param {IIdentifier} identifier
* @return {*} * @return {*}
*/ */
export const accessToken = async (did?: string) => { export const accessToken = async (identifier: IIdentifier) => {
if (did) { const did: string = identifier.did;
const nowEpoch = Math.floor(Date.now() / 1000); const privateKeyHex: string = identifier.keys[0].privateKeyHex as string;
const endEpoch = nowEpoch + 60; // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; const signer = SimpleSigner(privateKeyHex);
return createEndorserJwtForDid(did, tokenPayload);
} else { const nowEpoch = Math.floor(Date.now() / 1000);
return ""; const endEpoch = nowEpoch + 60; // add one minute
}
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
const jwt: string = await didJwt.createJWT(tokenPayload, {
alg,
issuer: did,
signer,
});
return jwt;
}; };
export const sign = async (privateKeyHex: string) => {
const signer = SimpleSigner(privateKeyHex);
return signer;
};
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
export function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
export function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
/** /**
@return results of uportJwtPayload: @return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } } { iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
@@ -116,7 +169,7 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
} }
// JWT format: { header, payload, signature, data } // JWT format: { header, payload, signature, data }
const jwt = decodeEndorserJwt(jwtText); const jwt = didJwt.decodeJWT(jwtText);
return jwt.payload; return jwt.payload;
}; };

View File

@@ -1,96 +0,0 @@
import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x";
import { bytesToMultibase, multibaseToBytes } from "did-jwt";
import { getWebCrypto } from "@/libs/crypto/vc/passkeyHelpers";
export const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
/**
*
*
* similar code is in crowd-funder-for-time-pwa libs/crypto/vc/passkeyDidPeer.ts verifyJwtWebCrypto
*
* @returns {Promise<boolean>}
*/
export async function verifyPeerSignature(
payloadBytes: Buffer,
issuerDid: string,
signatureBytes: Uint8Array,
): Promise<boolean> {
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const WebCrypto = await getWebCrypto();
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"],
);
const verified = await WebCrypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
signatureBytes,
payloadBytes,
);
return verified;
}
export function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes);
if (
jwkObj[1] != 2 || // kty "EC"
jwkObj[3] != -7 || // alg "ES256"
jwkObj[-1] != 1 || // crv "P-256"
jwkObj[-2].length != 32 || // x
jwkObj[-3].length != 32 // y
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2]),
y: arrayToBase64Url(jwkObj[-3]),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2]),
Buffer.from(jwkObj[-3]),
]);
return { publicKeyJwk, publicKeyBuffer };
}
export function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
export function createPeerDid(publicKeyBytes: Uint8Array) {
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
const methodSpecificId = bytesToMultibase(
publicKeyBytes,
"base58btc",
"p256-pub",
);
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
}

View File

@@ -1,112 +0,0 @@
/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*
* The goal is to make this folder similar across projects, then move it to a library.
* Other projects: endorser-ch, image-api
*
*/
import * as didJwt from "did-jwt";
import { JWTDecoded } from "did-jwt/lib/JWT";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer";
export const ETHR_DID_PREFIX = "did:ethr:";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
/**
* Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
) {
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
return didJwt.createJWT(payload, {
issuer: account.did,
signer: signer,
});
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
export function decodeEndorserJwt(jwt: string): JWTDecoded {
return didJwt.decodeJWT(jwt);
}

View File

@@ -1,539 +0,0 @@
import { Buffer } from "buffer/";
import { JWTPayload } from "did-jwt";
import { DIDResolutionResult } from "did-resolver";
import { sha256 } from "ethereum-cryptography/sha256.js";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
import {
Base64URLString,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/types";
import { AppString } from "@/constants/app";
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers";
import {
arrayToBase64Url,
cborToKeys,
peerDidToPublicKeyBytes,
verifyPeerSignature,
} from "@/libs/crypto/vc/didPeer";
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
}
export async function registerCredential(passkeyName?: string) {
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
rpName: AppString.APP_NAME,
rpID: window.location.hostname,
userName: passkeyName || AppString.APP_NAME + " User",
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: "none",
authenticatorSelection: {
// Defaults
residentKey: "preferred",
userVerification: "preferred",
// Optional
authenticatorAttachment: "platform",
},
});
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
// with pubKeyCredParams: { type: "public-key", alg: -7 }
const attResp = await startRegistration(options);
const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: options.challenge,
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
});
// references for parsing auth data and getting the public key
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) {
console.log("Warning! The raw ID does not match the credential ID.");
}
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),
).toString("hex");
const { publicKeyJwk } = cborToKeys(
verification.registrationInfo?.credentialPublicKey as Uint8Array,
);
return {
authData: verification.registrationInfo?.attestationObject,
credIdHex: credIdHex,
publicKeyJwk: publicKeyJwk,
publicKeyBytes: verification.registrationInfo
?.credentialPublicKey as Uint8Array,
};
}
export class PeerSetup {
public authenticatorData?: ArrayBuffer;
public challenge?: Uint8Array;
public clientDataJsonBase64Url?: Base64URLString;
public signature?: Base64URLString;
public async createJwtSimplewebauthn(
issuerDid: string,
payload: object,
credIdHex: string,
expMinutes: number = 1,
) {
const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const issuedAt = Math.floor(Date.now() / 1000);
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
const fullPayload = {
...payload,
exp: expiryTime,
iat: issuedAt,
iss: issuerDid,
};
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
// const payloadHash: Uint8Array = sha256(this.challenge);
const options: PublicKeyCredentialRequestOptionsJSON =
await generateAuthenticationOptions({
challenge: this.challenge,
rpID: window.location.hostname,
allowCredentials: [{ id: credentialId }],
});
// console.log("simple authentication options", options);
const clientAuth = await startAuthentication(options);
// console.log("simple credential get", clientAuth);
const authenticatorDataBase64Url = clientAuth.response.authenticatorData;
this.authenticatorData = Buffer.from(
clientAuth.response.authenticatorData,
"base64",
).buffer;
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON;
// console.log("simple authenticatorData for signing", this.authenticatorData);
this.signature = clientAuth.response.signature;
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
exp: expiryTime,
iat: issuedAt,
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const signature = clientAuth.response.signature;
return headerBase64 + "." + payloadBase64 + "." + signature;
}
public async createJwtNavigator(
issuerDid: string,
payload: object,
credIdHex: string,
expMinutes: number = 1,
) {
const issuedAt = Math.floor(Date.now() / 1000);
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
const fullPayload = {
...payload,
exp: expiryTime,
iat: issuedAt,
iss: issuerDid,
};
const dataToSignString = JSON.stringify(fullPayload);
const dataToSignBuffer = Buffer.from(dataToSignString);
const credentialId = Buffer.from(credIdHex, "hex");
// console.log("lower credentialId", credentialId);
this.challenge = new Uint8Array(dataToSignBuffer);
const options = {
publicKey: {
allowCredentials: [
{
id: credentialId,
type: "public-key" as const,
},
],
challenge: this.challenge.buffer,
rpID: window.location.hostname,
userVerification: "preferred" as const,
},
};
const credential = await navigator.credentials.get(options);
// console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData as ArrayBuffer,
);
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.response.clientDataJSON,
);
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
exp: expiryTime,
iat: issuedAt,
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature).toString(
"base64",
);
this.signature = origSignature
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
return jwt;
}
// To use this, add the asn1-ber library and add this import:
// import asn1 from "asn1-ber";
//
// return a low-level signing function, similar to createJWS approach
// async webAuthnES256KSigner(credentialID: string) {
// return async (data: string | Uint8Array) => {
// // get signature from WebAuthn
// const signature = await this.generateWebAuthnSignature(data);
//
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
// const signatureBuffer = Buffer.from(signature);
// console.log("lower signature inside signer", signature);
// console.log("lower buffer signature inside signer", signatureBuffer);
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
// // Decode the DER-encoded signature to extract R and S values
// const reader = new asn1.BerReader(signatureBuffer);
// console.log("lower after reader");
// reader.readSequence();
// console.log("lower after read sequence");
// const r = reader.readString(asn1.Ber.Integer, true);
// console.log("lower after r");
// const s = reader.readString(asn1.Ber.Integer, true);
// console.log("lower after r & s");
//
// // Ensure R and S are 32 bytes each
// const rBuffer = Buffer.from(r);
// const sBuffer = Buffer.from(s);
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
// const rPadded =
// rWithoutPrefix.length < 32
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
// : rWithoutPrefix;
// const sPadded =
// rWithoutPrefix.length < 32
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
// : sWithoutPrefix;
//
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
// console.log(
// "lower combinedSignature",
// combinedSignature.length,
// combinedSignature,
// );
//
// const combSig64 = combinedSignature.toString("base64");
// console.log("lower combSig64", combSig64);
// const combSig64Url = combSig64
// .replace(/\+/g, "-")
// .replace(/\//g, "_")
// .replace(/=+$/, "");
// console.log("lower combSig64Url", combSig64Url);
// return combSig64Url;
// };
// }
}
export async function createDidPeerJwt(
did: string,
credIdHex: string,
payload: object,
): Promise<string> {
const peerSetup = new PeerSetup();
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex);
return jwt;
}
// I'd love to use this but it doesn't verify.
// Requires:
// npm install @noble/curves
// ... and this import:
// import { p256 } from "@noble/curves/p256";
export async function verifyJwtP256(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
const isValid = p256.verify(
finalSigBuffer,
new Uint8Array(preimage),
publicKeyBytes,
);
return isValid;
}
export async function verifyJwtSimplewebauthn(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const credId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const authOpts: VerifyAuthenticationResponseOpts = {
authenticator: {
credentialID: credId,
credentialPublicKey: publicKeyBytes,
counter: 0,
},
expectedChallenge: arrayToBase64Url(challenge),
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
response: {
authenticatorAttachment: "platform",
clientExtensionResults: {},
id: credId,
rawId: credId,
response: {
authenticatorData: authData,
clientDataJSON: clientDataJsonBase64Url,
signature: signature,
},
type: "public-key",
},
};
const verification = await verifyAuthenticationResponse(authOpts);
return verification.verified;
}
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
export async function verifyJwtWebCrypto(
credId: Base64URLString,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) {
throw new Error(
"This only verifies a peer DID, method 0, encoded base58btc.",
);
}
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is the @aviarytech/did-peer resolver)
/**
* Looks like JsonWebKey2020 isn't too difficult:
* - change context security/suites link to jws-2020/v1
* - change publicKeyMultibase to publicKeyJwk generated with cborToKeys
* - change type to JsonWebKey2020
*/
const id = did.split(":")[2];
const multibase = id.slice(1);
const encnumbasis = multibase.slice(1);
const didDocument = {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
],
assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis],
capabilityDelegation: [did + "#" + encnumbasis],
capabilityInvocation: [did + "#" + encnumbasis],
id: did,
keyAgreement: undefined,
service: undefined,
verificationMethod: [
{
controller: did,
id: did + "#" + encnumbasis,
publicKeyMultibase: multibase,
type: "EcdsaSecp256k1VerificationKey2019",
},
],
};
return {
didDocument,
didDocumentMetadata: {},
didResolutionMetadata: { contentType: "application/did+ld+json" },
};
}
// convert COSE public key to PEM format
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because it complains about the type of x and y
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format
const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")}
-----END PUBLIC KEY-----`;
return pem;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecode(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncode(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
let str = "";
for (const charCode of bytes) {
str += String.fromCharCode(charCode);
}
const base64String = btoa(str);
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
// from @simplewebauthn/browser
function base64URLStringToArrayBuffer(base64URLString: string) {
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (base64.length % 4)) % 4;
const padded = base64.padEnd(base64.length + padLength, "=");
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function pemToCryptoKey(pem: string) {
const binaryDerString = atob(
pem
.split("\n")
.filter((x) => !x.includes("-----"))
.join(""),
);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
// console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey(
"spki",
binaryDer.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["verify"],
);
}

View File

@@ -1,105 +0,0 @@
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
/**
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
*
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
*/
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]);
return finalSignature;
}
/**
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence
* should be removed based on the following logic:
*
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0,
* then remove the leading 0x0 byte"
*/
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
/**
* Combine multiple Uint8Arrays into a single Uint8Array
*/
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined;
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
/**
* Hello there! If you came here wondering why this method is asynchronous when use of
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()`
* become synchronous if we make this synchronous (since nothing else in that method is async)
* which represents a breaking API change in this library's core API.
*
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense
* to keep this method asynchronous.
*/
const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise(
(resolve, reject) => {
if (webCrypto) {
return resolve(webCrypto);
}
/**
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
* support (and Node v20+)
*/
const _globalThisCrypto =
_getWebCryptoInternals.stubThisGlobalThisCrypto();
if (_globalThisCrypto) {
webCrypto = _globalThisCrypto;
return resolve(webCrypto);
}
// We tried to access it both in Node and globally, so bail out
return reject(new MissingWebCrypto());
},
);
return toResolve;
}
class MissingWebCrypto extends Error {
constructor() {
const message = "An instance of the Crypto API could not be located";
super(message);
this.name = "MissingWebCrypto";
}
}
// Make it possible to stub return values during testing
const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => {
webCrypto = newCrypto;
},
};

View File

@@ -1,14 +1,11 @@
import { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; import { Axios, AxiosResponse, RawAxiosRequestHeaders } from "axios";
import * as didJwt from "did-jwt";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { accessToken } from "@/libs/crypto"; import { accessToken, SimpleSigner } from "@/libs/crypto";
import { db, NonsensitiveDexie } from "@/db/index";
import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util";
import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
export const SCHEMA_ORG_CONTEXT = "https://schema.org"; export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims // the object in RegisterAction claims
@@ -30,14 +27,14 @@ export interface AgreeVerifiableCredential {
object: Record<string, any>; object: Record<string, any>;
} }
export interface GiverReceiverInputInfo { export interface GiverInputInfo {
did?: string; did?: string;
name?: string; name?: string;
} }
export interface GiverOutputInfo { export interface GiverOutputInfo {
action: string; action: string;
giver?: GiverReceiverInputInfo; giver?: GiverInputInfo;
description?: string; description?: string;
amount?: number; amount?: number;
unitCode?: string; unitCode?: string;
@@ -55,7 +52,7 @@ export interface GenericVerifiableCredential {
} }
export interface GenericCredWrapper extends GenericVerifiableCredential { export interface GenericCredWrapper extends GenericVerifiableCredential {
handleId: string; handleId?: string;
id: string; id: string;
issuedAt: string; issuedAt: string;
issuer: string; issuer: string;
@@ -67,7 +64,6 @@ export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = {
"@context": SCHEMA_ORG_CONTEXT, "@context": SCHEMA_ORG_CONTEXT,
"@type": "", "@type": "",
claim: {}, claim: {},
handleId: "",
id: "", id: "",
issuedAt: "", issuedAt: "",
issuer: "", issuer: "",
@@ -113,7 +109,6 @@ export interface PlanSummaryRecord {
endTime?: string; endTime?: string;
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string;
handleId: string; handleId: string;
image?: string;
issuerDid: string; issuerDid: string;
locLat?: number; locLat?: number;
locLon?: number; locLon?: number;
@@ -148,7 +143,6 @@ export interface OfferVerifiableCredential {
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string }; isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
}; };
offeredBy?: { identifier: string }; offeredBy?: { identifier: string };
recipient?: { identifier: string };
validThrough?: string; validThrough?: string;
} }
@@ -171,7 +165,7 @@ export interface PlanVerifiableCredential {
* Represents data about a project * Represents data about a project
* *
* @deprecated * @deprecated
* We should use PlanSummaryRecord instead. * We should use PlanServerRecord instead.
**/ **/
export interface PlanData { export interface PlanData {
/** /**
@@ -186,7 +180,6 @@ export interface PlanData {
* URL referencing information about the project * URL referencing information about the project
**/ **/
handleId: string; handleId: string;
image?: string;
/** /**
* The DID of the issuer * The DID of the issuer
*/ */
@@ -401,8 +394,7 @@ export function contactForDid(
* @param activeDid * @param activeDid
* @param contact * @param contact
* @param allMyDids * @param allMyDids
* @return { known: boolean, displayName: string, profileImageUrl?: string } * @return { known: boolean, displayName: string } where known is true if the display name is some easily-recogizable name, false if it's a generic name like "Someone Anonymous"
* where 'known' is true if they are in the contacts
*/ */
export function didInfoForContact( export function didInfoForContact(
did: string | undefined, did: string | undefined,
@@ -410,16 +402,15 @@ export function didInfoForContact(
contact?: Contact, contact?: Contact,
allMyDids: string[] = [], allMyDids: string[] = [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
): { known: boolean; displayName: string; profileImageUrl?: string } { ): { known: boolean; displayName: string } {
if (!did) return { displayName: "Someone Unnamed/Unknown", known: false }; if (!did) return { displayName: "Someone Anonymous", known: false };
if (did === activeDid) { if (contact) {
return { displayName: "You", known: true };
} else if (contact) {
return { return {
displayName: contact.name || "Contact With No Name", displayName: contact.name || "Contact With No Name",
known: !!contact, known: !!contact.name,
profileImageUrl: contact.profileImageUrl,
}; };
} else if (did === activeDid) {
return { displayName: "You", known: true };
} else { } else {
const myId = R.find(R.equals(did), allMyDids); const myId = R.find(R.equals(did), allMyDids);
return myId return myId
@@ -448,76 +439,29 @@ export function didInfo(
return didInfoForContact(did, activeDid, contact, allMyDids).displayName; return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
} }
let passkeyAccessToken: string = ""; async function getHeaders(identity: IIdentifier | null) {
let passkeyTokenExpirationEpochSeconds: number = 0; const headers: RawAxiosRequestHeaders = {
export function clearPasskeyToken() {
passkeyAccessToken = "";
passkeyTokenExpirationEpochSeconds = 0;
}
export function tokenExpiryTimeDescription() {
if (
!passkeyAccessToken ||
passkeyTokenExpirationEpochSeconds < new Date().getTime() / 1000
) {
return "Token has expired";
} else {
return (
"Token expires at " +
new Date(passkeyTokenExpirationEpochSeconds * 1000).toLocaleString()
);
}
}
/**
* Get the headers for a request, potentially including Authorization
*/
export async function getHeaders(did?: string) {
const headers: { "Content-Type": string; Authorization?: string } = {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
if (did) { if (identity) {
let token; const token = await accessToken(identity);
const account = await getAccount(did);
if (account?.passkeyCredIdHex) {
if (
passkeyAccessToken &&
passkeyTokenExpirationEpochSeconds > Date.now() / 1000
) {
// there's an active current passkey token
token = passkeyAccessToken;
} else {
// there's no current passkey token or it's expired
token = await accessToken(did);
passkeyAccessToken = token;
const passkeyExpirationSeconds = await getPasskeyExpirationSeconds();
passkeyTokenExpirationEpochSeconds =
Date.now() / 1000 + passkeyExpirationSeconds;
}
} else {
token = await accessToken(did);
}
headers["Authorization"] = "Bearer " + token; headers["Authorization"] = "Bearer " + token;
} else {
// it's often OK to request without auth; we assume necessary checks are done earlier
} }
return headers; return headers;
} }
/** /**
* @param handleId nullable, in which case "undefined" will be returned * @param handleId nullable -- which means that "undefined" will be returned
* @param requesterDid optional, in which case no private info will be returned * @param identity nullable -- which means no private info will be returned
* @param axios * @param axios
* @param apiServer * @param apiServer
*/ */
export async function getPlanFromCache( export async function getPlanFromCache(
handleId: string | null, handleId: string | null,
identity: IIdentifier | null,
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
requesterDid?: string, ) {
): Promise<PlanSummaryRecord | undefined> {
if (!handleId) { if (!handleId) {
return undefined; return undefined;
} }
@@ -527,14 +471,14 @@ export async function getPlanFromCache(
apiServer + apiServer +
"/api/v2/report/plans?handleId=" + "/api/v2/report/plans?handleId=" +
encodeURIComponent(handleId); encodeURIComponent(handleId);
const headers = await getHeaders(requesterDid); const headers = await getHeaders(identity);
try { try {
const resp = await axios.get(url, { headers }); const resp = await axios.get(url, { headers });
if (resp.status === 200 && resp.data?.data?.length > 0) { if (resp.status === 200 && resp.data?.data?.length > 0) {
cred = resp.data.data[0]; cred = resp.data.data[0];
planCache.set(handleId, cred); planCache.set(handleId, cred);
} else { } else {
console.error( console.log(
"Failed to load plan with handle", "Failed to load plan with handle",
handleId, handleId,
" Got data:", " Got data:",
@@ -542,7 +486,7 @@ export async function getPlanFromCache(
); );
} }
} catch (error) { } catch (error) {
console.error( console.log(
"Failed to load plan with handle", "Failed to load plan with handle",
handleId, handleId,
" Got error:", " Got error:",
@@ -561,9 +505,18 @@ export async function setPlanInCache(
} }
/** /**
* Construct GiveAction VC for submission to server * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param fromDid may be null
* @param toDid
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
*/ */
export function constructGive( export async function createAndSubmitGive(
axios: Axios,
apiServer: string,
identity: IIdentifier,
fromDid?: string | null, fromDid?: string | null,
toDid?: string, toDid?: string,
description?: string, description?: string,
@@ -573,9 +526,9 @@ export function constructGive(
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, isTrade: boolean = false,
imageUrl?: string, imageUrl?: string,
): GiveVerifiableCredential { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: GiveVerifiableCredential = { const vcClaim: GiveVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT, "@context": "https://schema.org",
"@type": "GiveAction", "@type": "GiveAction",
recipient: toDid ? { identifier: toDid } : undefined, recipient: toDid ? { identifier: toDid } : undefined,
agent: fromDid ? { identifier: fromDid } : undefined, agent: fromDid ? { identifier: fromDid } : undefined,
@@ -602,46 +555,9 @@ export function constructGive(
if (imageUrl) { if (imageUrl) {
vcClaim.image = imageUrl; vcClaim.image = imageUrl;
} }
return vcClaim;
}
/**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param fromDid may be null
* @param toDid
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
*/
export async function createAndSubmitGive(
axios: Axios,
apiServer: string,
issuerDid: string,
fromDid?: string | null,
toDid?: string,
description?: string,
amount?: number,
unitCode?: string,
fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = constructGive(
fromDid,
toDid,
description,
amount,
unitCode,
fulfillsProjectHandleId,
fulfillsOfferHandleId,
isTrade,
imageUrl,
);
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericCredWrapper, vcClaim as GenericCredWrapper,
issuerDid, identity,
apiServer, apiServer,
axios, axios,
); );
@@ -659,18 +575,17 @@ export async function createAndSubmitGive(
export async function createAndSubmitOffer( export async function createAndSubmitOffer(
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
issuerDid: string, identity: IIdentifier,
description?: string, description?: string,
amount?: number, amount?: number,
unitCode?: string, unitCode?: string,
expirationDate?: string, expirationDate?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = { const vcClaim: OfferVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT, "@context": "https://schema.org",
"@type": "Offer", "@type": "Offer",
offeredBy: { identifier: issuerDid }, offeredBy: { identifier: identity.did },
validThrough: expirationDate || undefined, validThrough: expirationDate || undefined,
}; };
if (amount) { if (amount) {
@@ -682,9 +597,6 @@ export async function createAndSubmitOffer(
if (description) { if (description) {
vcClaim.itemOffered = { description }; vcClaim.itemOffered = { description };
} }
if (recipientDid) {
vcClaim.recipient = { identifier: recipientDid };
}
if (fulfillsProjectHandleId) { if (fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {}; vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = { vcClaim.itemOffered.isPartOf = {
@@ -694,7 +606,7 @@ export async function createAndSubmitOffer(
} }
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericCredWrapper, vcClaim as GenericCredWrapper,
issuerDid, identity,
apiServer, apiServer,
axios, axios,
); );
@@ -702,7 +614,7 @@ export async function createAndSubmitOffer(
// similar logic is found in endorser-mobile // similar logic is found in endorser-mobile
export const createAndSubmitConfirmation = async ( export const createAndSubmitConfirmation = async (
issuerDid: string, identifier: IIdentifier,
claim: GenericVerifiableCredential, claim: GenericVerifiableCredential,
lastClaimId: string, // used to set the lastClaimId lastClaimId: string, // used to set the lastClaimId
handleId: string | undefined, handleId: string | undefined,
@@ -715,16 +627,16 @@ export const createAndSubmitConfirmation = async (
), ),
); );
const confirmationClaim: GenericVerifiableCredential = { const confirmationClaim: GenericVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT, "@context": "https://schema.org",
"@type": "AgreeAction", "@type": "AgreeAction",
object: goodClaim, object: goodClaim,
}; };
return createAndSubmitClaim(confirmationClaim, issuerDid, apiServer, axios); return createAndSubmitClaim(confirmationClaim, identifier, apiServer, axios);
}; };
export async function createAndSubmitClaim( export async function createAndSubmitClaim(
vcClaim: GenericVerifiableCredential, vcClaim: GenericVerifiableCredential,
issuerDid: string, identity: IIdentifier,
apiServer: string, apiServer: string,
axios: Axios, axios: Axios,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
@@ -737,15 +649,34 @@ export async function createAndSubmitClaim(
}, },
}; };
const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload); // 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 // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = `${apiServer}/api/v2/claim`; const url = `${apiServer}/api/v2/claim`;
const token = await accessToken(identity);
const response = await axios.post(url, payload, { const response = await axios.post(url, payload, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
}); });
@@ -767,20 +698,6 @@ export async function createAndSubmitClaim(
} }
} }
export async function createEndorserJwtForDid(
issuerDid: string,
payload: object,
) {
const account = await getAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload);
}
/**
* An AcceptAction is when someone accepts some contract or pledge.
*
* @param claim has properties '@context' & '@type'
* @return true if the claim is a schema.org AcceptAction
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isAccept = (claim: Record<string, any>) => { export const isAccept = (claim: Record<string, any>) => {
return ( return (
@@ -977,128 +894,3 @@ export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
}, },
}; };
}; };
export async function createEndorserJwtVcFromClaim(
issuerDid: string,
claim: object,
) {
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: claim,
},
};
return createEndorserJwtForDid(issuerDid, vcPayload);
}
export async function register(
activeDid: string,
apiServer: string,
axios: Axios,
contact: Contact,
) {
const vcClaim: RegisterVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "RegisterAction",
agent: { identifier: activeDid },
object: SERVICE_ID,
participant: { identifier: contact.did },
};
// 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
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload);
const url = apiServer + "/api/v2/claim";
const resp = await axios.post(url, { jwtEncoded: vcJwt });
if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message =
"There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError == "string") {
message += " " + resp.data.success.embeddedRecordError;
}
return { error: message };
} else {
console.error(resp);
return { error: "Got a server error when registering." };
}
}
export async function setVisibilityUtil(
activeDid: string,
apiServer: string,
axios: Axios,
db: NonsensitiveDexie,
contact: Contact,
visibility: boolean,
) {
if (!activeDid) {
return { error: "Cannot set visibility without an identifier." };
}
const url =
apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe");
const headers = await getHeaders(activeDid);
const payload = JSON.stringify({ did: contact.did });
try {
const resp = await axios.post(url, payload, { headers });
if (resp.status === 200) {
db.contacts.update(contact.did, { seesMe: visibility });
return { success: true };
} else {
console.error(
"Got some bad server response when setting visibility: ",
resp.status,
resp,
);
const message =
resp.data.error?.message || "Got some error setting visibility.";
return { error: message };
}
} catch (err) {
console.error("Got some error when setting visibility:", err);
return { error: "Check connectivity and try again." };
}
}
/**
* Fetches rate limits from the Endorser server.
*
* @param apiServer endorser server URL string
* @param axios Axios instance
* @param {string} issuerDid - The DID for which to check rate limits.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
export async function fetchEndorserRateLimits(
apiServer: string,
axios: Axios,
issuerDid: string,
) {
const url = `${apiServer}/api/report/rateLimits`;
const headers = await getHeaders(issuerDid);
return await axios.get(url, { headers } as AxiosRequestConfig);
}
/**
* Fetches rate limits from the image server.
*
* @param apiServer image server URL string
* @param axios Axios instance
* @param {string} issuerDid - The DID for which to check rate limits.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
export async function fetchImageRateLimits(axios: Axios, issuerDid: string) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
const headers = await getHeaders(issuerDid);
return await axios.get(url, { headers } as AxiosRequestConfig);
}

View File

@@ -1,27 +1,22 @@
// many of these are also found in endorser-mobile utility.ts // many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import { IIdentifier } from "@veramo/core";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER } from "@/constants/app"; import { DEFAULT_PUSH_SERVER } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import {DEFAULT_PASSKEY_EXPIRATION_MINUTES, MASTER_SETTINGS_KEY} from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer"; import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
import { Buffer } from "buffer";
import { KeyMeta } from "@/libs/crypto/vc";
import { createPeerDid } from "@/libs/crypto/vc/didPeer";
export const PRIVACY_MESSAGE = export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; "The data you send be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to those you allow.";
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = { export const UNIT_SHORT: Record<string, string> = {
"BX": "BX",
"BTC": "BTC", "BTC": "BTC",
"ETH": "ETH", "ETH": "ETH",
"HUR": "Hours", "HUR": "Hours",
@@ -31,7 +26,6 @@ export const UNIT_SHORT: Record<string, string> = {
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = { export const UNIT_LONG: Record<string, string> = {
"BX": "Buxbe",
"BTC": "Bitcoin", "BTC": "Bitcoin",
"ETH": "Ethereum", "ETH": "Ethereum",
"HUR": "hours", "HUR": "hours",
@@ -76,7 +70,7 @@ export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
}; };
export const isGiveAction = (veriClaim: GenericCredWrapper) => { export const giveIsConfirmable = (veriClaim: GenericCredWrapper) => {
return veriClaim.claimType === "GiveAction"; return veriClaim.claimType === "GiveAction";
}; };
@@ -97,7 +91,7 @@ export const isGiveRecordTheUserCanConfirm = (
confirmerIdList: string[] = [], confirmerIdList: string[] = [],
) => { ) => {
return ( return (
isGiveAction(veriClaim) && giveIsConfirmable(veriClaim) &&
!confirmerIdList.includes(activeDid) && !confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid && veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim) !containsHiddenDid(veriClaim.claim)
@@ -197,17 +191,20 @@ export function findAllVisibleToDids(
* *
**/ **/
export interface AccountKeyInfo extends Account, KeyMeta {} export const getIdentity = async (activeDid: string): Promise<IIdentifier> => {
export const getAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
await accountsDB.open(); await accountsDB.open();
const account = (await accountsDB.accounts const account = (await accountsDB.accounts
.where("did") .where("did")
.equals(activeDid) .equals(activeDid)
.first()) as Account; .first()) as Account;
return account; const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
`Attempted to load Offer records for DID ${activeDid} but no identifier was found`,
);
}
return identity;
}; };
/** /**
@@ -240,47 +237,6 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
return newId.did; return newId.did;
}; };
export const registerAndSavePasskey = async (
keyName: string,
): Promise<Account> => {
const cred = await registerCredential(keyName);
const publicKeyBytes = cred.publicKeyBytes;
const did = createPeerDid(publicKeyBytes as Uint8Array);
const passkeyCredIdHex = cred.credIdHex as string;
const account = {
dateCreated: new Date().toISOString(),
did,
passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
};
await accountsDB.open();
await accountsDB.accounts.add(account);
return account;
};
export const registerSaveAndActivatePasskey = async (
keyName: string,
): Promise<Account> => {
const account = await registerAndSavePasskey(keyName);
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: account.did,
});
return account;
};
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const passkeyExpirationSeconds =
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60;
return passkeyExpirationSeconds;
};
export const sendTestThroughPushServer = async ( export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON, subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean, skipFilter: boolean,

View File

@@ -1,5 +1,5 @@
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue"; import { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import "./registerServiceWorker"; import "./registerServiceWorker";
import router from "./router"; import router from "./router";
@@ -11,12 +11,9 @@ import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from "@fortawesome/fontawesome-svg-core";
import { import {
faArrowDown,
faArrowLeft, faArrowLeft,
faArrowRight, faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare, faArrowUpRightFromSquare,
faArrowUp,
faBan, faBan,
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
@@ -37,7 +34,6 @@ import {
faComment, faComment,
faCopy, faCopy,
faDollar, faDollar,
faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEye, faEye,
faEyeSlash, faEyeSlash,
@@ -49,10 +45,8 @@ import {
faGlobe, faGlobe,
faHammer, faHammer,
faHand, faHand,
faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImagePortrait,
faLeftRight, faLeftRight,
faLocationDot, faLocationDot,
faLongArrowAltLeft, faLongArrowAltLeft,
@@ -69,7 +63,6 @@ import {
faRotate, faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquare,
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
@@ -81,12 +74,9 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
library.add( library.add(
faArrowDown,
faArrowLeft, faArrowLeft,
faArrowRight, faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare, faArrowUpRightFromSquare,
faArrowUp,
faBan, faBan,
faBitcoinSign, faBitcoinSign,
faBurst, faBurst,
@@ -107,7 +97,6 @@ library.add(
faComment, faComment,
faCopy, faCopy,
faDollar, faDollar,
faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEye, faEye,
faEyeSlash, faEyeSlash,
@@ -119,10 +108,8 @@ library.add(
faGlobe, faGlobe,
faHammer, faHammer,
faHand, faHand,
faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImagePortrait,
faLeftRight, faLeftRight,
faLocationDot, faLocationDot,
faLongArrowAltLeft, faLongArrowAltLeft,
@@ -139,7 +126,6 @@ library.add(
faRotate, faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquare,
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
@@ -153,38 +139,11 @@ library.add(
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView createApp(App)
function setupGlobalErrorHandler(app: VueApp) {
// @ts-expect-error 'cause we cannot see why config is not defined
app.config.errorHandler = (
err: Error,
instance: ComponentPublicInstance | null,
info: string,
) => {
console.error(
"Ouch! Global Error Handler. Info:",
info,
"Error:",
err,
"Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.
alert(
(err.message || "Something bad happened") +
" - Try reloading or restarting the app.",
);
};
}
const app = createApp(App)
.component("fa", FontAwesomeIcon) .component("fa", FontAwesomeIcon)
.component("camera", Camera) .component("camera", Camera)
.use(createPinia()) .use(createPinia())
.use(VueAxios, axios) .use(VueAxios, axios)
.use(router) .use(router)
.use(Notifications); .use(Notifications)
.mount("#app");
setupGlobalErrorHandler(app);
app.mount("#app");

View File

@@ -38,29 +38,19 @@ const routes: Array<RouteRecordRaw> = [
name: "claim", name: "claim",
component: () => import("../views/ClaimView.vue"), component: () => import("../views/ClaimView.vue"),
}, },
{
path: "/claim-add-raw/:id?",
name: "claim-add-raw",
component: () => import("../views/ClaimAddRawView.vue"),
},
{ {
path: "/confirm-contact", path: "/confirm-contact",
name: "confirm-contact", name: "confirm-contact",
component: () => import("../views/ConfirmContactView.vue"), component: () => import("../views/ConfirmContactView.vue"),
}, },
{
path: "/confirm-gift/:id?",
name: "confirm-gift",
component: () => import("@/views/ConfirmGiftView.vue"),
},
{ {
path: "/contact-amounts", path: "/contact-amounts",
name: "contact-amounts", name: "contact-amounts",
component: () => import("../views/ContactAmountsView.vue"), component: () => import("../views/ContactAmountsView.vue"),
}, },
{ {
path: "/contact-gift", path: "/contact-gives",
name: "contact-gift", name: "contact-gives",
component: () => import("../views/ContactGiftingView.vue"), component: () => import("../views/ContactGiftingView.vue"),
}, },
{ {
@@ -73,11 +63,6 @@ const routes: Array<RouteRecordRaw> = [
name: "contacts", name: "contacts",
component: () => import("../views/ContactsView.vue"), component: () => import("../views/ContactsView.vue"),
}, },
{
path: "/did/:did?",
name: "did",
component: () => import("../views/DIDView.vue"),
},
{ {
path: "/discover", path: "/discover",
name: "discover", name: "discover",
@@ -179,11 +164,6 @@ const routes: Array<RouteRecordRaw> = [
name: "seed-backup", name: "seed-backup",
component: () => import("../views/SeedBackupView.vue"), component: () => import("../views/SeedBackupView.vue"),
}, },
{
path: "/shared-photo",
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
{ {
path: "/start", path: "/start",
name: "start", name: "start",

View File

@@ -6,9 +6,6 @@ import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto"; import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
/**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid.
*/
export async function testServerRegisterUser() { export async function testServerRegisterUser() {
const testUser0Mnem = const testUser0Mnem =
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control"; "seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +0,0 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
Raw Claim
</h1>
</div>
<div class="flex">
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea>
</div>
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="submitClaim()"
>
Sign &amp; Send
</button>
</section>
</template>
<script lang="ts">
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import * as serverUtil from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
@Component({
components: { GiftedDialog, QuickNav },
})
export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
apiServer = "";
claimStr = "";
async mounted() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.claimStr = this.$route.query.claim;
try {
this.veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(this.veriClaim, null, 2);
} catch (e) {
// ignore a parse
}
}
async submitClaim() {
const fullClaim = JSON.parse(this.claimStr);
const result = await serverUtil.createAndSubmitClaim(
fullClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Claim submitted.",
},
5000,
);
} else {
console.error("Got error submitting the claim:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the claim. See logs for more info.",
},
-1,
);
}
}
}
</script>

View File

@@ -10,7 +10,7 @@
@click="$router.go(-1)" @click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
> >
<fa icon="chevron-left" class="fa-fw" /> <fa icon="chevron-left" class="fa-fw"></fa>
</button> </button>
Verifiable Claim Details Verifiable Claim Details
</h1> </h1>
@@ -35,16 +35,16 @@
" "
class="ml-2 mr-2" class="ml-2 mr-2"
> >
<fa icon="copy" class="text-slate-400 fa-fw" /> <fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button> </button>
<span v-show="showIdCopy">Copied ID</span> <span v-show="showIdCopy">Copied ID</span>
</div> </div>
<div> <div>
<fa icon="message" class="fa-fw text-slate-400" /> <fa icon="message" class="fa-fw text-slate-400"></fa>
{{ veriClaim.claim?.description }} {{ veriClaim.claim?.description }}
</div> </div>
<div> <div>
<fa icon="user" class="fa-fw text-slate-400" /> <fa icon="user" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuer }} {{ veriClaim.issuer }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
<button <button
@@ -56,13 +56,13 @@
" "
class="ml-2 mr-2" class="ml-2 mr-2"
> >
<fa icon="copy" class="text-slate-400 fa-fw" /> <fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button> </button>
<span v-show="showDidCopy">Copied DID</span> <span v-show="showDidCopy">Copied DID</span>
</span> </span>
</div> </div>
<div> <div>
<fa icon="calendar" class="fa-fw text-slate-400" /> <fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }} {{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div> </div>
@@ -121,7 +121,7 @@
</div> </div>
</div> </div>
<div class="flex columns-3"> <div class="columns-3">
<button <button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if=" v-if="
@@ -131,22 +131,12 @@
confirmerIdList, confirmerIdList,
) )
" "
@click="confirmConfirmClaim()" @click="confirmClaim()"
> >
Confirm Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> <fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button> </button>
<span class="px-4 py-2">
<router-link
v-if="libsUtil.isGiveAction(veriClaim)"
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
class="col-span-1 text-blue-500"
>
Confirmation Details...
</router-link>
</span>
<button <button
v-if="libsUtil.canFulfillOffer(veriClaim)" v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()" @click="openFulfillGiftDialog()"
@@ -156,9 +146,9 @@
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" /> <fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
</button> </button>
</div> </div>
<GiftedDialog ref="customGiveDialog" /> <GiftedDialog ref="customGiveDialog" message="Offer fulfilled by" />
<div v-if="libsUtil.isGiveAction(veriClaim)"> <div v-if="libsUtil.giveIsConfirmable(veriClaim)">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2> <h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span> <span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
@@ -329,7 +319,7 @@
class="list-disc p-4" class="list-disc p-4"
> >
<div class="text-sm"> <div class="text-sm">
<fa icon="minus" class="fa-fw" /> <fa icon="minus" class="fa-fw"></fa>
The {{ visibleDidPath }} is visible to: The {{ visibleDidPath }} is visible to:
</div> </div>
<div class="ml-12 p-1"> <div class="ml-12 p-1">
@@ -351,7 +341,8 @@
</span> </span>
<span v-if="veriClaim.publicUrls?.[visDid]" <span v-if="veriClaim.publicUrls?.[visDid]"
>, found at >, found at
<fa icon="globe" class="fa-fw text-slate-400" />&nbsp;<a <fa icon="globe" class="fa-fw text-slate-400"></fa
>&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]" :href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500" class="text-blue-500"
>{{ >{{
@@ -407,7 +398,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
@@ -415,22 +406,26 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { GiverReceiverInputInfo } from "@/libs/endorserServer"; import { GiverInputInfo } from "@/libs/endorserServer";
@Component({ @Component({
components: { GiftedDialog, QuickNav }, components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
}) })
export default class ClaimView extends Vue { export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = ""; activeDid = "";
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
@@ -481,14 +476,17 @@ export default class ClaimView extends Vue {
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
const accountsArr: Array<Account> = await accounts?.toArray(); const accountsArr = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did); this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
this.accountIdentityStr = account?.identity || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring("/claim/".length); const pathParam = window.location.pathname.substring("/claim/".length);
let claimId; let claimId;
if (pathParam) { if (pathParam) {
claimId = decodeURIComponent(pathParam); claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, this.activeDid); await this.loadClaim(claimId, identity);
} else { } else {
this.$notify( this.$notify(
{ {
@@ -522,6 +520,33 @@ export default class ClaimView extends Vue {
); );
} }
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
// Isn't there a better way to make this available to the template? // Isn't there a better way to make this available to the template?
didInfo(did: string) { didInfo(did: string) {
return serverUtil.didInfo( return serverUtil.didInfo(
@@ -532,12 +557,12 @@ export default class ClaimView extends Vue {
); );
} }
async loadClaim(claimId: string, userDid: string) { async loadClaim(claimId: string, identity: IIdentifier) {
const urlPath = libsUtil.isGlobalUri(claimId) const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/" ? "/api/claim/byHandle/"
: "/api/claim/"; : "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId); const url = this.apiServer + urlPath + encodeURIComponent(claimId);
const headers = await serverUtil.getHeaders(userDid); const headers = await this.getHeaders(identity);
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
@@ -569,7 +594,7 @@ export default class ClaimView extends Vue {
this.apiServer + this.apiServer +
"/api/v2/report/gives?handleId=" + "/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string); encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await serverUtil.getHeaders(userDid); const giveHeaders = await this.getHeaders(identity);
const giveResp = await this.axios.get(giveUrl, { const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders, headers: giveHeaders,
}); });
@@ -583,7 +608,7 @@ export default class ClaimView extends Vue {
this.apiServer + this.apiServer +
"/api/v2/report/offers?handleId=" + "/api/v2/report/offers?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string); encodeURIComponent(this.veriClaim.handleId as string);
const offerHeaders = await serverUtil.getHeaders(userDid); const offerHeaders = await this.getHeaders(identity);
const offerResp = await this.axios.get(offerUrl, { const offerResp = await this.axios.get(offerUrl, {
headers: offerHeaders, headers: offerHeaders,
}); });
@@ -599,14 +624,12 @@ export default class ClaimView extends Vue {
this.apiServer + this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" + "/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await serverUtil.getHeaders(userDid); const confirmHeaders = await this.getHeaders(identity);
const response = await this.axios.get(confirmUrl, { const response = await this.axios.get(confirmUrl, {
headers: confirmHeaders, headers: confirmHeaders,
}); });
if (response.status === 200) { if (response.status === 200) {
const resultList1 = response.data.result || []; const resultList1 = response.data.result || [];
//const publicUrls = resultList.publicUrls || [];
delete resultList1.publicUrls;
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
const resultList3 = R.reject( const resultList3 = R.reject(
(did: string) => did === this.veriClaim.issuer, (did: string) => did === this.veriClaim.issuer,
@@ -641,9 +664,15 @@ export default class ClaimView extends Vue {
} }
async showFullClaim(claimId: string) { async showFullClaim(claimId: string) {
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr: Account[] = await accounts?.toArray();
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const url = const url =
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId); this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
const headers = await serverUtil.getHeaders(this.activeDid); const headers = await this.getHeaders(identity);
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
@@ -688,65 +717,52 @@ export default class ClaimView extends Vue {
} }
} }
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView // similar code is found in ProjectViewView
async confirmClaim() { async confirmClaim() {
// similar logic is found in endorser-mobile if (confirm("Do you personally confirm that this is true?")) {
const goodClaim = serverUtil.removeSchemaContext( // similar logic is found in endorser-mobile
serverUtil.removeVisibleToDids( const goodClaim = serverUtil.removeSchemaContext(
serverUtil.addLastClaimOrHandleAsIdIfMissing( serverUtil.removeVisibleToDids(
this.veriClaim.claim, serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.id, this.veriClaim.claim,
this.veriClaim.handleId, this.veriClaim.id,
this.veriClaim.handleId,
),
), ),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
); );
} else { const confirmationClaim: serverUtil.GenericVerifiableCredential = {
console.error("Got error submitting the confirmation:", result); "@context": "https://schema.org",
this.$notify( "@type": "AgreeAction",
{ object: goodClaim,
group: "alert", };
type: "danger", const result = await serverUtil.createAndSubmitClaim(
title: "Error", confirmationClaim,
text: "There was a problem submitting the confirmation. See logs for more info.", await this.getIdentity(this.activeDid),
}, this.apiServer,
-1, this.axios,
); );
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
-1,
);
}
} }
} }
@@ -756,19 +772,17 @@ export default class ClaimView extends Vue {
}; };
this.$router.push(route).then(async () => { this.$router.push(route).then(async () => {
this.resetThisValues(); this.resetThisValues();
await this.loadClaim(claimId, this.activeDid); await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
}); });
} }
openFulfillGiftDialog() { openFulfillGiftDialog() {
const giver: GiverReceiverInputInfo = { const giver: GiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim), did: libsUtil.offerGiverDid(this.veriClaim),
}; };
(this.$refs.customGiveDialog as GiftedDialog).open( (this.$refs.customGiveDialog as GiftedDialog).open(
giver, giver,
undefined,
this.veriClaim.handleId, this.veriClaim.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
); );
} }

View File

@@ -1,859 +0,0 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
<span
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
>
Do you agree?
</span>
<span v-else> Details </span>
</h1>
</div>
<div v-if="giveDetails && !isLoading">
<div class="flex justify-center">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<button
v-else
@click="notifyWhyCannotConfirm()"
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<a
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md"
:href="urlForNewGive"
>
Record a Similar One
</a>
</div>
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<div class="text-sm">
<div>
<fa icon="arrow-down" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
<div v-if="giveDetails.amount">
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" />
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
</div>
<div v-if="giveDetails.description">
<fa icon="message" class="fa-fw text-slate-400" />
{{ giveDetails.amount ? "and:" : "" }}
{{ giveDetails.description }}
</div>
<div class="ml-6">to</div>
<div>
<fa icon="arrow-up" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
on
{{ giveDetails.issuedAt.substring(0, 10) }}
</div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills a bigger plan
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails.fulfillsType,
)
}}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="mt-2">
<fa icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person has confirmed this.
</span>
<span v-else>
{{ totalConfirmers() }} people have confirmed this.
</span>
<div v-if="totalConfirmers() > 0">
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
"
>
Nobody that you know confirmed this claim, nor do they have any
confirmers in their network.
</div>
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0
"
>
<!-- Only show if this person has links to confirmers (below). -->
Nobody that you know has issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people have issued or confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
:key="confirmerId"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!--
Never need to show this message:
"Nobody that you know can see someone who has confirmed this claim."
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
If there is somebody in the confirmerIdList then that's all they need to show.
-->
<!-- Now show anyone linked to confirmers. -->
<div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who have issued or
confirmed this claim.
<ul class="ml-4">
<li
v-for="confsVisibleTo in confsVisibleToIdList"
:key="confsVisibleTo"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"
>
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim.
</div>
<div v-else-if="giveDetails.agentDid == activeDid">
You cannot confirm this because you issued this claim, so you already
count as confirming it.
</div>
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because it contains hidden identifiers.
</div>
</div>
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
@click="showDetails = !showDetails"
>
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
<span v-if="!showDetails"><fa icon="chevron-down" /></span>
<span v-else><fa icon="chevron-up" /></span>
</h2>
<div v-if="showDetails">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN".
They are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction.
</span>
<span v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>share this page with them</a
>
and see if they are willing to make an introduction.
</span>
</div>
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
Some of the details are not visible to you but they are visible to
some of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />
<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
}}
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
</div>
</div>
<div v-else-if="!isLoading">This does not have details to confirm.</div>
<div class="mt-4" v-if="!isLoading">
<a
@click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" class="pl-2" />
All Generic Info
</a>
</div>
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import * as serverUtil from "@/libs/endorserServer";
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { isGiveAction } from "@/libs/util";
@Component({
methods: { displayAmount },
components: { GiftedDialog, QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
canShare = false;
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails = null;
giverName = "";
issuerName = "";
isLoading = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showDetails = false;
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location;
R = R;
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = null;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
async mounted() {
this.isLoading = true;
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr: Array<Account> = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const pathParam = window.location.pathname.substring(
"/confirm-gift/".length,
);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, this.activeDid);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
3000,
);
}
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
this.isLoading = false;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
}
totalConfirmers() {
return (
this.numConfsNotVisible +
this.confirmerIdList.length +
this.confsVisibleToIdList.length
);
}
// Isn't there a better way to make this available to the template?
didInfo(did: string | undefined) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
async loadClaim(claimId: string, userDid: string) {
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
try {
const headers = await serverUtil.getHeaders(userDid);
const resp = await this.axios.get(url, { headers });
// resp.data is:
// - a Jwt from https://api.endorser.ch/api-docs/
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3
if (resp.status === 200) {
this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
true,
);
} else {
// actually, axios typically throws an error so we never get here
console.error("Error getting claim:", resp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem retrieving that claim.",
},
3000,
);
return;
}
// retrieve more details on Give, Offer, or Plan
if (this.veriClaim.claimType !== "GiveAction") {
// no need to go further... this page is for gifts
return;
}
this.issuerName = this.didInfo(this.veriClaim.issuer);
// use give record when possible since it may include edits
const giveUrl =
this.apiServer +
"/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await serverUtil.getHeaders(userDid);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
// giveResp.data is a Give from https://api.endorser.ch/api-docs/
if (giveResp.status === 200) {
this.giveDetails = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gift data.",
},
3000,
);
}
this.urlForNewGive = "/gifted-details?";
if (this.giveDetails.amount) {
this.urlForNewGive +=
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount));
}
if (this.giveDetails.unit) {
this.urlForNewGive +=
"&unitCode=" + encodeURIComponent(this.giveDetails.unit);
}
if (this.giveDetails.description) {
this.urlForNewGive +=
"&description=" + encodeURIComponent(this.giveDetails.description);
}
this.giverName = this.didInfo(this.giveDetails.agentDid);
if (this.giveDetails.agentDid) {
this.urlForNewGive +=
"&giverDid=" +
encodeURIComponent(this.giveDetails.agentDid) +
"&giverName=" +
encodeURIComponent(this.giverName);
}
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
if (this.giveDetails.recipientDid) {
this.urlForNewGive +=
"&recipientDid=" +
encodeURIComponent(this.giveDetails.recipientDid) +
"&recipientName=" +
encodeURIComponent(this.recipientName);
}
if (this.giveDetails.fullClaim.image) {
this.urlForNewGive +=
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image);
}
if (
this.giveDetails.type == "Offer" &&
this.giveDetails.fulfillsHandleId
) {
this.urlForNewGive +=
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId);
}
if (this.giveDetails.fulfillsPlanHandleId) {
this.urlForNewGive +=
"&projectId=" +
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
}
// retrieve the list of confirmers
const confirmUrl =
this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await serverUtil.getHeaders(userDid);
const response = await this.axios.get(confirmUrl, {
headers: confirmHeaders,
});
if (response.status === 200) {
const resultList1 = response.data.result || [];
//const publicUrls = resultList.publicUrls || [];
delete resultList1.publicUrls;
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
const resultList3 = R.reject(
(did: string) => did === this.giveDetails.agentDid,
resultList2,
);
this.confirmerIdList = resultList3;
this.numConfsNotVisible = resultList1.length - resultList2.length;
if (resultList3.length === resultList2.length) {
// the issuer was not in the "visible" list so they must be hidden
// so subtract them from the non-visible confirmers count
this.numConfsNotVisible = this.numConfsNotVisible - 1;
}
this.confsVisibleToIdList =
response.data.result.resultVisibleToDids || [];
} else {
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations.";
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving claim data.",
},
3000,
);
}
}
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView
async confirmClaim() {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
3000,
);
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
5000,
);
}
}
showClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
this.$router.push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
});
}
openFulfillGiftDialog() {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.giveDetails.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
notifyWhyCannotConfirm() {
if (!isGiveAction(this.veriClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not A Give",
text: "This is not a giving action to confirm.",
},
3000,
);
} else if (this.confirmerIdList.includes(this.activeDid)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You have already confirmed this claim.",
},
3000,
);
} else if (this.giveDetails.agentDid == this.activeDid) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because you issued this claim.",
},
3000,
);
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because it contains hidden identifiers.",
},
3000,
);
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim.",
},
3000,
);
}
}
onClickShareClaim() {
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation.href,
});
}
}
</script>

View File

@@ -55,9 +55,9 @@
{{ new Date(record.issuedAt).toLocaleString() }} {{ new Date(record.issuedAt).toLocaleString() }}
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid == contact?.did"> <span v-if="record.agentDid == contact.did">
<div class="font-bold"> <div class="font-bold">
{{ displayAmount(record.unit, record.amount) }} {{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" title="Confirmed"> <span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" /> <fa icon="circle-check" class="text-green-600 fa-fw" />
</span> </span>
@@ -71,7 +71,7 @@
</span> </span>
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid == contact?.did"> <span v-if="record.agentDid == contact.did">
<fa icon="arrow-left" class="text-slate-400 fa-fw" /> <fa icon="arrow-left" class="text-slate-400 fa-fw" />
</span> </span>
<span v-else> <span v-else>
@@ -79,9 +79,9 @@
</span> </span>
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid != contact?.did"> <span v-if="record.agentDid != contact.did">
<div class="font-bold"> <div class="font-bold">
{{ displayAmount(record.unit, record.amount) }} {{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" title="Confirmed"> <span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" /> <fa icon="circle-check" class="text-green-600 fa-fw" />
</span> </span>
@@ -105,8 +105,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError, AxiosRequestHeaders } from "axios"; import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@@ -114,11 +116,9 @@ import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { import {
AgreeVerifiableCredential, AgreeVerifiableCredential,
createEndorserJwtVcFromClaim,
displayAmount,
getHeaders,
GiveSummaryRecord, GiveSummaryRecord,
GiveVerifiableCredential, GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT, SCHEMA_ORG_CONTEXT,
@@ -134,13 +134,36 @@ export default class ContactAmountssView extends Vue {
giveRecords: Array<GiveSummaryRecord> = []; giveRecords: Array<GiveSummaryRecord> = [];
numAccounts = 0; numAccounts = 0;
displayAmount = displayAmount;
async beforeCreate() { async beforeCreate() {
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
} }
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() { async created() {
try { try {
await db.open(); await db.open();
@@ -148,8 +171,8 @@ export default class ContactAmountssView extends Vue {
this.contact = (await db.contacts.get(contactDid)) || null; this.contact = (await db.contacts.get(contactDid)) || null;
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = (settings?.apiServer as string) || ""; this.apiServer = settings?.apiServer || "";
if (this.activeDid && this.contact) { if (this.activeDid && this.contact) {
this.loadGives(this.activeDid, this.contact); this.loadGives(this.activeDid, this.contact);
@@ -173,14 +196,15 @@ export default class ContactAmountssView extends Vue {
async loadGives(activeDid: string, contact: Contact) { async loadGives(activeDid: string, contact: Contact) {
try { try {
const identity = await this.getIdentity(this.activeDid);
let result: Array<GiveSummaryRecord> = []; let result: Array<GiveSummaryRecord> = [];
const url = const url =
this.apiServer + this.apiServer +
"/api/v2/report/gives?agentDid=" + "/api/v2/report/gives?agentDid=" +
encodeURIComponent(this.activeDid) + encodeURIComponent(identity.did) +
"&recipientDid=" + "&recipientDid=" +
encodeURIComponent(contact.did); encodeURIComponent(contact.did);
const headers = await getHeaders(activeDid); const headers = await this.getHeaders(identity);
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
result = resp.data.data; result = resp.data.data;
@@ -206,8 +230,8 @@ export default class ContactAmountssView extends Vue {
"/api/v2/report/gives?agentDid=" + "/api/v2/report/gives?agentDid=" +
encodeURIComponent(contact.did) + encodeURIComponent(contact.did) +
"&recipientDid=" + "&recipientDid=" +
encodeURIComponent(this.activeDid); encodeURIComponent(identity.did);
const headers2 = await getHeaders(activeDid); const headers2 = await this.getHeaders(identity);
const resp2 = await this.axios.get(url2, { headers: headers2 }); const resp2 = await this.axios.get(url2, { headers: headers2 });
if (resp2.status === 200) { if (resp2.status === 200) {
result = R.concat(result, resp2.data.data); result = R.concat(result, resp2.data.data);
@@ -262,44 +286,66 @@ export default class ContactAmountssView extends Vue {
object: origClaim, object: origClaim,
}; };
const vcJwt: string = await createEndorserJwtVcFromClaim( // Make a payload for the claim
this.activeDid, const vcPayload = {
vcClaim, vc: {
); "@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Make the xhr request payload // Create a signature using private key of identity
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const identity = await this.getIdentity(this.activeDid);
const url = this.apiServer + "/api/v2/claim"; if (identity.keys[0].privateKeyHex !== null) {
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders; // 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,
});
try { // Make the xhr request payload
const resp = await this.axios.post(url, payload, { headers }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
if (resp.data?.success) { const url = this.apiServer + "/api/v2/claim";
record.amountConfirmed = const token = await accessToken(identity);
(origClaim.object?.amountOfThisGood as number) || 1; const headers = {
} "Content-Type": "application/json",
} catch (error) { Authorization: "Bearer " + token,
let userMessage = "There was an error. See logs for more info."; };
const serverError = error as AxiosError;
if (serverError) { try {
if (serverError.message) { const resp = await this.axios.post(url, payload, { headers });
userMessage = serverError.message; // Info for the user if (resp.data?.success) {
} else { record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
userMessage = JSON.stringify(serverError.toJSON());
} }
} else { } catch (error) {
userMessage = error as string; let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
-1,
);
} }
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
-1,
);
} }
} }

View File

@@ -26,7 +26,7 @@
width="32" width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/> />
Unnamed/Unknown Anonymous/Unnamed
</span> </span>
<span class="text-right"> <span class="text-right">
<button <button
@@ -47,7 +47,7 @@
<h2 class="text-base flex gap-4 items-center"> <h2 class="text-base flex gap-4 items-center">
<span class="grow font-semibold"> <span class="grow font-semibold">
<EntityIcon <EntityIcon
:contact="contact" :entityId="contact.did"
:iconSize="32" :iconSize="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/> />
@@ -66,22 +66,28 @@
</li> </li>
</ul> </ul>
<GiftedDialog ref="customDialog" :projectId="projectId" /> <GiftedDialog
ref="customDialog"
message="Received from"
:projectId="projectId"
/>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { IIdentifier } from "@veramo/core";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts"; import { Account, AccountsSchema } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { GiverReceiverInputInfo } from "@/libs/endorserServer"; import { accessToken } from "@/libs/crypto";
import { GiverInputInfo } from "@/libs/endorserServer";
@Component({ @Component({
components: { GiftedDialog, QuickNav, EntityIcon }, components: { GiftedDialog, QuickNav, EntityIcon },
@@ -93,10 +99,12 @@ export default class ContactGiftingView extends Vue {
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
apiServer = ""; apiServer = "";
accounts: typeof AccountsSchema; accounts: typeof AccountsSchema;
numAccounts = 0;
projectId = localStorage.getItem("projectId") || ""; projectId = localStorage.getItem("projectId") || "";
async beforeCreate() { async beforeCreate() {
accountsDB.open(); accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
} }
async created() { async created() {
@@ -105,13 +113,7 @@ export default class ContactGiftingView extends Vue {
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.orderBy("name").toArray();
// .orderBy("name") wouldn't retrieve any entries with a blank name
// .toCollection.sortBy("name") didn't sort in an order I understood
const baseContacts = await db.contacts.toArray();
this.allContacts = baseContacts.sort((a, b) =>
(a.name || "").localeCompare(b.name || ""),
);
localStorage.removeItem("projectId"); localStorage.removeItem("projectId");
@@ -132,16 +134,33 @@ export default class ContactGiftingView extends Vue {
} }
} }
openDialog(giver?: GiverReceiverInputInfo) { public async getIdentity(activeDid: string) {
const recipient = this.projectId await accountsDB.open();
? undefined const account = (await accountsDB.accounts
: { did: this.activeDid, name: "you" }; .where("did")
(this.$refs.customDialog as GiftedDialog).open( .equals(activeDid)
giver, .first()) as Account;
recipient, const identity = JSON.parse(account?.identity || "null");
undefined,
"Given by " + (giver?.name || "someone not named"), if (!identity) {
); throw new Error(
"Attempted to load Give records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
} }
} }
</script> </script>

View File

@@ -24,7 +24,6 @@
> >
<span class="text-red">Beware!</span> <span class="text-red">Beware!</span>
You aren't sharing your name, so quickly You aren't sharing your name, so quickly
<br />
<router-link <router-link
:to="{ name: 'new-edit-account' }" :to="{ name: 'new-edit-account' }"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md"
@@ -34,11 +33,7 @@
</p> </p>
</div> </div>
<div <div @click="onCopyToClipboard()" v-if="activeDid" class="text-center">
@click="onCopyUrlToClipboard()"
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="text-center"
>
<!-- <!--
Play with display options: https://qr-code-styling.com/ Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3 See docs: https://www.npmjs.com/package/qr-code-generator-vue3
@@ -49,18 +44,7 @@
:dotsOptions="{ type: 'square' }" :dotsOptions="{ type: 'square' }"
class="flex justify-center" class="flex justify-center"
/> />
<span> <span> Click QR to copy your contact URL to your clipboard. </span>
Click this or QR code to copy your contact URL to your clipboard.
</span>
</div>
<div v-else-if="activeDid" class="text-center">
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
<span @click="onCopyDidToClipboard()" class="text-blue-500">
Click here to copy your DID to your clipboard.
</span>
<span>
Then give it to them so they can paste it in their list of People.
</span>
</div> </div>
<div class="text-center" v-else> <div class="text-center" v-else>
You have no identitifiers yet, so You have no identitifiers yet, so
@@ -86,8 +70,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import * as didJwt from "did-jwt";
import { Buffer } from "buffer/";
import { sha256 } from "ethereum-cryptography/sha256.js"; import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import * as R from "ramda"; import * as R from "ramda";
@@ -95,25 +78,17 @@ import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader"; import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { import { deriveAddress, nextDerivationPath, SimpleSigner } from "@/libs/crypto";
deriveAddress, import QuickNav from "@/components/QuickNav.vue";
getContactPayloadFromJwtUrl, import { Account } from "@/db/tables/accounts";
nextDerivationPath,
} from "@/libs/crypto";
import { import {
CONTACT_URL_PREFIX, CONTACT_URL_PREFIX,
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION, ENDORSER_JWT_URL_LOCATION,
isDid,
register,
setVisibilityUtil,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc"; import { Buffer } from "buffer/";
@Component({ @Component({
components: { components: {
@@ -128,29 +103,47 @@ export default class ContactQRScanShow extends Vue {
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
givenName = ""; givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = ""; qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX; public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identifier available.",
);
}
return identity;
}
async created() { async created() {
await db.open(); await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = (settings?.apiServer as string) || ""; this.apiServer = settings?.apiServer || "";
this.givenName = (settings?.firstName as string) || ""; this.givenName = settings?.firstName || "";
this.hideRegisterPromptOnNewContact =
!!settings?.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open(); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts); const account = R.find((acc) => acc.did === this.activeDid, accounts);
if (account) { if (account) {
const publicKeyHex = account.publicKeyHex; const identity = await this.getIdentity(this.activeDid);
const publicKeyHex = identity.keys[0].publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64"); const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
const contactInfo = { const contactInfo = {
iat: Date.now(), iat: Date.now(),
iss: this.activeDid, iss: this.activeDid,
@@ -159,259 +152,43 @@ export default class ContactQRScanShow extends Vue {
(settings?.firstName || "") + (settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3 (settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey, publicEncKey,
profileImageUrl: settings?.profileImageUrl, nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
registered: settings?.isRegistered,
}, },
}; };
if (account?.mnemonic && account?.derivationPath) { const alg = undefined;
const newDerivPath = nextDerivationPath( const privateKeyHex: string = identity.keys[0].privateKeyHex;
account.derivationPath as string, const signer = await SimpleSigner(privateKeyHex);
); // create a JWT for the request
const nextPublicHex = deriveAddress( const vcJwt: string = await didJwt.createJWT(contactInfo, {
account.mnemonic as string, alg: alg,
newDerivPath, issuer: identity.did,
)[2]; signer: signer,
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex"); });
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
}
const vcJwt = await createEndorserJwtForDid(this.activeDid, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt; this.qrValue = viewPrefix + vcJwt;
} }
} }
danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
timeout,
);
}
/** /**
* *
* @param content is the result of a QR scan, an array with one item with a rawValue property * @param content is the result of a QR scan, an array with one item with a rawValue property
*/ */
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet. // Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
async onScanDetect(content: any) { onScanDetect(content: any) {
const url = content[0]?.rawValue; if (content[0]?.rawValue) {
if (url) { localStorage.setItem("contactEndorserUrl", content[0].rawValue);
let newContact: Contact; this.$router.push({ name: "contacts" });
try {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
}
newContact = {
did: payload.iss as string,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
};
if (!newContact.did) {
this.danger("There is no DID.", "Incomplete Contact");
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
return;
}
} catch (e) {
console.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
return;
}
try {
await db.open();
await db.contacts.add(newContact);
let addedMessage;
if (this.activeDid) {
await this.setVisibility(newContact, true);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage =
"They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
}
} catch (e) {
console.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact info. Check if it already exists.",
},
5000,
);
}
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "warning",
title: "Invalid Contact QR Code", title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.", text: "No QR code detected with contact information.",
}, },
5000, -1,
);
}
}
async setVisibility(contact: Contact, visibility: boolean) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
console.error("Got strange result from setting visibility:", result);
}
}
async register(contact: Contact) {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
); );
} }
} }
@@ -422,16 +199,15 @@ export default class ContactQRScanShow extends Vue {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "warning",
title: "Invalid Scan", title: "Invalid Scan",
text: "The scan was invalid.", text: "The scan was invalid.",
}, },
5000, -1,
); );
} }
onCopyUrlToClipboard() { onCopyToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard() useClipboard()
.copy(this.qrValue) .copy(this.qrValue)
.then(() => { .then(() => {
@@ -447,22 +223,5 @@ export default class ContactQRScanShow extends Vue {
); );
}); });
} }
onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.activeDid)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it on their 'People' screen to add you.",
},
10000,
);
});
}
} }
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,313 +0,0 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Identifier Details
</h1>
</div>
<!-- Identity Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<h2 class="text-xl font-semibold">
{{
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
.displayName
}}
</h2>
<span class="mt-2 text-xl font-semibold break-words">
{{ viewingDid }}
</span>
</div>
<div class="flex justify-center mt-4">
<span v-if="contact?.profileImageUrl" class="flex justify-between">
<EntityIcon
:icon-size="96"
:profileImageUrl="contact?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = contact?.profileImageUrl"
/>
</span>
</div>
<div class="mt-4">
<div class="flex justify-center">Auto-Generated Icon:</div>
<div class="flex justify-center">
<EntityIcon
:entityId="viewingDid"
:iconSize="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = viewingDid"
/>
</div>
</div>
<div
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
class="fixed z-[100] top-0 inset-x-0 w-full"
>
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:entityId="showLargeIdenticonId"
:iconSize="512"
:profileImageUrl="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="
showLargeIdenticonId = undefined;
showLargeIdenticonUrl = undefined;
"
/>
</div>
</div>
</div>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
<div class="text-l font-bold text-center">Claims That Involve Them</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
v-for="claim in claims"
:key="claim.handleId"
>
<div class="grid grid-cols-12 gap-4">
<span class="col-span-2">
{{ claim.issuedAt.substring(0, 10) }}
</span>
<span class="col-span-2">
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }}
</span>
<span class="col-span-2">
{{ claimAmount(claim) }}
</span>
<span class="col-span-5">
{{ claimDescription(claim) }}
</span>
<span class="col-span-1">
<a
@click="onClickLoadClaim(claim.handleId)"
class="cursor-pointer"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</span>
</div>
</li>
</ul>
</InfiniteScroll>
<div
v-if="!isLoading && claims.length === 0"
class="flex justify-center mt-4"
>
<span>They Are in No Claims Visible to You</span>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
getHeaders,
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
TopMessage,
},
})
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claims: Array<GenericCredWrapper> = [];
contact?: Contact;
hitEnd = false;
isLoading = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
viewingDid?: string;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
const pathParam = window.location.pathname.substring("/did/".length);
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid);
await this.loadClaimsAbout();
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
-1,
);
}
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.claims.length > 0 && !this.hitEnd && payload) {
this.loadClaimsAbout();
}
}
public async loadClaimsAbout() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
return;
}
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid);
let postfix = "";
if (this.claims.length > 0) {
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id;
}
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server. Try again later.`,
},
5000,
);
return;
}
const results = await response.json();
this.claims = this.claims.concat(results.data);
this.hitEnd = !results.hitLimit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
-1,
);
} finally {
this.isLoading = false;
}
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveVerifiableCredential;
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
return displayAmount(
giveClaim.object.unitCode,
giveClaim.object.amountOfThisGood,
);
} else {
return "";
}
} else if (claim.claimType === "Offer") {
const offerClaim = claim.claim as OfferVerifiableCredential;
if (
offerClaim.includesObject?.unitCode &&
offerClaim.includesObject?.amountOfThisGood
) {
return displayAmount(
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
} else {
return "";
}
}
return "";
}
claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || "";
}
}
</script>

View File

@@ -6,7 +6,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Discover Projects Discover
</h1> </h1>
<!-- Quick Search --> <!-- Quick Search -->
@@ -37,7 +37,7 @@
isRemoteActive = false; isRemoteActive = false;
searchLocal(); searchLocal();
" "
v-bind:class="computedLocalTabStyleClassNames()" v-bind:class="computedLocalTabClassNames()"
> >
Nearby Nearby
<span <span
@@ -57,7 +57,7 @@
isLocalActive = false; isLocalActive = false;
searchAll(); searchAll();
" "
v-bind:class="computedRemoteTabStyleClassNames()" v-bind:class="computedRemoteTabClassNames()"
> >
Anywhere Anywhere
<span <span
@@ -74,7 +74,7 @@
<div v-if="isLocalActive"> <div v-if="isLocalActive">
<div> <div>
<button <button
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500" class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })" @click="$router.push({ name: 'search-area' })"
> >
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
@@ -100,15 +100,14 @@
> >
<a <a
@click="onClickLoadProject(project.handleId)" @click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer" class="block py-4 flex gap-4"
> >
<div> <div class="w-12">
<ProjectIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
:imageUrl="project.image" class="block border border-slate-300 rounded-md"
class="block border border-slate-300 rounded-md max-h-12 max-w-12" ></ProjectIcon>
/>
</div> </div>
<div class="grow"> <div class="grow">
@@ -132,16 +131,19 @@ import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import ProjectIcon from "@/components/ProjectIcon.vue"; import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer"; import { accessToken } from "@/libs/crypto";
import { didInfo, PlanData } from "@/libs/endorserServer";
@Component({ @Component({
components: { components: {
EntityIcon,
InfiniteScroll, InfiniteScroll,
ProjectIcon, ProjectIcon,
QuickNav, QuickNav,
@@ -202,6 +204,30 @@ export default class DiscoverView extends Vue {
} }
} }
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
public async searchAll(beforeId?: string) { public async searchAll(beforeId?: string) {
this.resetCounts(); this.resetCounts();
@@ -222,7 +248,7 @@ export default class DiscoverView extends Vue {
this.apiServer + "/api/v2/report/plans?" + queryParams, this.apiServer + "/api/v2/report/plans?" + queryParams,
{ {
method: "GET", method: "GET",
headers: await getHeaders(this.activeDid), headers: await this.buildHeaders(),
}, },
); );
@@ -247,15 +273,8 @@ export default class DiscoverView extends Vue {
const plans: PlanData[] = results.data; const plans: PlanData[] = results.data;
if (plans) { if (plans) {
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan; const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({ this.projects.push({ name, description, handleId, issuerDid, rowid });
name,
description,
handleId,
image,
issuerDid,
rowid,
});
} }
this.remoteCount = this.projects.length; this.remoteCount = this.projects.length;
} else { } else {
@@ -312,7 +331,7 @@ export default class DiscoverView extends Vue {
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams, this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
{ {
method: "GET", method: "GET",
headers: await getHeaders(this.activeDid), headers: await this.buildHeaders(),
}, },
); );
@@ -397,7 +416,7 @@ export default class DiscoverView extends Vue {
this.$router.push(route); this.$router.push(route);
} }
public computedLocalTabStyleClassNames() { public computedLocalTabClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,
@@ -415,7 +434,7 @@ export default class DiscoverView extends Vue {
}; };
} }
public computedRemoteTabStyleClassNames() { public computedRemoteTabClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,

View File

@@ -5,13 +5,10 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <!-- Back -->
<div <div class="text-lg text-center font-light relative px-7">
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1 <h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()" @click="cancel()"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw"></fa>
</h1> </h1>
@@ -21,17 +18,7 @@
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1> <h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4"> <h1 class="text-xl font-bold text-center mb-4">
<span>From {{ giverName }}</span> {{ message }} {{ giverName || "somebody not named" }}
<span>
to
{{
givenToProject
? projectName
: givenToRecipient
? recipientName
: "someone unidentified"
}}</span
>
</h1> </h1>
<textarea <textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
@@ -43,7 +30,7 @@
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20" class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()" @click="changeUnitCode()"
> >
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }} {{ libsUtil.UNIT_SHORT[unitCode] }}
</span> </span>
<div <div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2" class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@@ -79,73 +66,28 @@
<fa <fa
icon="camera" icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog" @click="openPhotoDialog"
/> />
</span> </span>
</div> </div>
<ImageMethodDialog ref="imageDialog" /> <GiftedPhotoDialog ref="photoDialog" />
<div class="h-7 mt-4 flex"> <div v-if="projectId" class="mt-4">
<input
v-if="projectId && !givenToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<fa <fa
v-else icon="check"
icon="square" class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/> />
<label class="text-sm mt-1"> <label class="text-sm">This is given to a project</label>
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
</div> </div>
<div class="h-7 mt-4 flex"> <div v-if="!projectId" class="mt-4">
<input <input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" />
v-if="recipientDid && !givenToProject" <label class="text-sm">Given to you</label>
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToRecipient"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfRecipient()"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName
: "No recipient was chosen."
}}
</label>
</div> </div>
<div class="mt-4 flex"> <div class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" /> <input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm mt-1">This was a trade (not a gift)</label> <label class="text-sm">Trade (not a gift)</label>
</div>
<div class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructGiveParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</div> </div>
<p class="text-center mb-2 mt-6 italic"> <p class="text-center mb-2 mt-6 italic">
@@ -176,25 +118,21 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { db } from "@/db/index";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { import { createAndSubmitGive } from "@/libs/endorserServer";
constructGive,
createAndSubmitGive,
didInfo,
getHeaders,
getPlanFromCache,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts"; import { accessToken } from "@/libs/crypto";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
@Component({ @Component({
components: { components: {
ImageMethodDialog, GiftedDialog,
GiftedPhotoDialog,
QuickNav, QuickNav,
TopMessage, TopMessage,
}, },
@@ -207,58 +145,31 @@ export default class GiftedDetails extends Vue {
amountInput = "0"; amountInput = "0";
description = ""; description = "";
destinationNameAfter = ""; givenToUser = false;
givenToProject = false;
givenToRecipient = false;
giverDid: string | undefined; giverDid: string | undefined;
giverName = ""; giverName = "";
hideBackButton = false;
imageUrl = ""; imageUrl = "";
isTrade = false; isTrade = false;
message = ""; message = "";
offerId = ""; offerId = "";
projectId = ""; projectId = "";
projectName = "a project";
recipientDid = "";
recipientName = "";
unitCode = "HUR"; unitCode = "HUR";
libsUtil = libsUtil; libsUtil = libsUtil;
async mounted() { async mounted() {
this.amountInput = this.amountInput = this.$route.query.amountInput as string;
(this.$route.query.amountInput as string) || this.amountInput; this.description = this.$route.query.description as string;
this.description = (this.$route.query.description as string) || "";
this.destinationNameAfter = this.$route.query
.destinationNameAfter as string;
this.giverDid = this.$route.query.giverDid as string; this.giverDid = this.$route.query.giverDid as string;
this.giverName = (this.$route.query.giverName as string) || ""; this.giverName = this.$route.query.giverName as string;
this.hideBackButton = this.$route.query.hideBackButton === "true"; this.message = this.$route.query.message as string;
this.message = (this.$route.query.message as string) || "";
this.offerId = this.$route.query.offerId as string; this.offerId = this.$route.query.offerId as string;
this.projectId = this.$route.query.projectId as string; this.projectId = this.$route.query.projectId as string;
this.recipientDid = this.$route.query.recipientDid as string; this.unitCode = this.$route.query.unitCode as string;
this.recipientName = (this.$route.query.recipientName as string) || "";
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode;
this.imageUrl = this.imageUrl = localStorage.getItem("imageUrl") || "";
(this.$route.query.imageUrl as string) ||
localStorage.getItem("imageUrl") ||
"";
// this is an endpoint for sharing project info to highlight something given this.givenToUser = !this.projectId;
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if (this.$route.query.shareTitle) {
this.description = this.$route.query.shareTitle as string;
}
if (this.$route.query.shareText) {
this.description =
(this.description ? this.description + " " : "") +
(this.$route.query.shareText as string);
}
if (this.$route.query.shareUrl) {
this.imageUrl = this.$route.query.shareUrl as string;
}
try { try {
await db.open(); await db.open();
@@ -266,37 +177,6 @@ export default class GiftedDetails extends Vue {
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
let allContacts: Contact[] = [];
let allMyDids: string[] = [];
if (
(this.giverDid && !this.giverName) ||
(this.recipientDid && !this.recipientName)
) {
allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
if (this.giverDid && !this.giverName) {
this.giverName = didInfo(
this.giverDid,
this.activeDid,
allMyDids,
allContacts,
);
}
if (this.recipientDid && !this.recipientName) {
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
}
this.givenToProject = !!this.projectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
console.error("Error retrieving settings from database:", err); console.error("Error retrieving settings from database:", err);
@@ -310,19 +190,6 @@ export default class GiftedDetails extends Vue {
-1, -1,
); );
} }
if (this.projectId) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache(
this.projectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.projectName = project?.name
? "the project: " + project.name
: "a project";
}
} }
changeUnitCode() { changeUnitCode() {
@@ -343,23 +210,14 @@ export default class GiftedDetails extends Vue {
} }
cancel() { cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately this.deleteImage(); // not awaiting, so they'll go back immediately
this.$router.back(); this.$router.back();
} }
openImageDialog() { openPhotoDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => { (this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
this.imageUrl = imgUrl; this.imageUrl = imgUrl;
}, "GiveAction"); });
} }
confirmDeleteImage() { confirmDeleteImage() {
@@ -380,18 +238,23 @@ export default class GiftedDetails extends Vue {
return; return;
} }
try { try {
const headers = await getHeaders(this.activeDid); const identity = await libsUtil.getIdentity(this.activeDid);
const token = await accessToken(identity);
const response = await this.axios.delete( const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER + DEFAULT_IMAGE_API_SERVER +
"/image/" + "/image/" +
encodeURIComponent(this.imageUrl), encodeURIComponent(this.imageUrl),
{ headers }, {
headers: {
Authorization: `Bearer ${token}`,
},
},
); );
if (response.status === 204) { if (response.status === 204) {
// don't bother with a notification // don't bother with a notification
// (either they'll simply continue or they're canceling and going back) // (either they'll simply continue or they're canceling and going back)
} else { } else {
console.error("Problem deleting image:", response); console.error("Non-success deleting image:", response);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -485,56 +348,6 @@ export default class GiftedDetails extends Vue {
await this.recordGive(); await this.recordGive();
} }
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this dialog through a project.",
},
3000,
);
} else {
// must be because givenToRecipient is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a recipient, you must open this dialog from a contact.",
},
3000,
);
} else {
// must be because givenToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
);
}
}
/** /**
* *
* @param giverDid may be null * @param giverDid may be null
@@ -544,20 +357,17 @@ export default class GiftedDetails extends Vue {
*/ */
public async recordGive() { public async recordGive() {
try { try {
const recipientDid = this.givenToRecipient const identity = await libsUtil.getIdentity(this.activeDid);
? this.recipientDid
: undefined;
const projectId = this.givenToProject ? this.projectId : undefined;
const result = await createAndSubmitGive( const result = await createAndSubmitGive(
this.axios, this.axios,
this.apiServer, this.apiServer,
this.activeDid, identity,
this.giverDid, this.giverDid,
recipientDid, this.givenToUser ? this.activeDid : undefined,
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.unitCode, this.unitCode,
projectId, this.projectId,
this.offerId, this.offerId,
this.isTrade, this.isTrade,
this.imageUrl, this.imageUrl,
@@ -589,16 +399,12 @@ export default class GiftedDetails extends Vue {
5000, 5000,
); );
localStorage.removeItem("imageUrl"); localStorage.removeItem("imageUrl");
if (this.destinationNameAfter) { this.$router.back();
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
console.error("Error with give recordation caught:", error); console.error("Error with give recordation caught:", error);
const errorMessage = const message =
error.userMessage || error.userMessage ||
error.response?.data?.error?.message || error.response?.data?.error?.message ||
"There was an error recording the give."; "There was an error recording the give.";
@@ -607,31 +413,13 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: errorMessage, text: message,
}, },
-1, -1,
); );
} }
} }
constructGiveParam() {
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const projectId = this.givenToProject ? this.projectId : undefined;
const giveClaim = constructGive(
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
const claimStr = JSON.stringify(giveClaim);
return claimStr;
}
// Helper functions for readability // Helper functions for readability
/** /**

View File

@@ -24,15 +24,16 @@
<!-- eslint-disable prettier/prettier --> <!-- eslint-disable prettier/prettier -->
<div> <div>
<p> <p>
This app focuses on gifts & gratitude, using them to build cool things with your network. This app is a window into data that you and your friends own, focused on
gifts and collaboration.
</p> </p>
<h2 class="text-xl font-semibold">What is the idea here?</h2> <h2 class="text-xl font-semibold">What is the idea here?</h2>
<p> <p>
We are building networks of people who want to grow a giving society. We are building networks of people who want to grow a giving society.
First of all, let's build gratitude: see what people have given, and recognize First of all, you can see what people have given, and also recognize
gifts you've seen. This is done in a way that leaves a permanent record -- one that gifts you've seen, in a way that leaves a permanent record -- one that
came from you, and that the recipient can prove it was for them. This is came from you, and the recipient can prove it was for them. This is
personally gratifying, but it extends to broader work: volunteers get personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and selectively show off their contributions confirmation of activity, and selectively show off their contributions
and network. and network.
@@ -75,26 +76,32 @@
<p> <p>
Go Go
<router-link class="text-blue-500" to="/import-account">import your identifier</router-link>. <router-link class="text-blue-500" to="/import-account">import your identifier</router-link>.
If you don't want the old one, click "Advanced" and check the box to erase it.
(The erase option only shows if you have exactly one identifier.
For more in-depth surgery, you'll have to erase data from the browser or reinstall.)
</p> </p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2> <h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p> <p>
<a href="/help-onboarding" target="_blank" class="text-blue-500"> <a href="/help-onboarding" target="_blank" class="text-blue-500">
Use these instructions. Click here to show an alert with the steps.
</a> </a>
To start scanning, go to the To start scanning, go
<router-link class="text-blue-500" to="/contact-qr">contact-scanning page.</router-link> <router-link class="text-blue-500" to="/contact-qr">here.</router-link>
</p> </p>
<p> <p>
If they are not nearby to scan QR codes, you each can tap on the QR code If they are not nearby to scan QR codes, tell them to copy their ID from
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page. their Identity <fa icon="circle-user" class="fa-fw" /> page, which
typically starts with "did:ethr:...", and send it to you. Go to the
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
top form. To add a name, put a comma and then their name; to add their
public key, put another comma followed by the key.
</p> </p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2> <h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p> <p>
There are three sets of data to backup: the identifier secrets; There are two sets of data to backup: the identifier secrets and the
the non-public textual data that isn't quite a secret such as settings and contacts; other data that isn't quite a secret such as settings, contacts, etc.
the non-public image for yourself; and the data that you have sent to the public.
</p> </p>
<div class="px-4"> <div class="px-4">
@@ -115,7 +122,7 @@
</ul> </ul>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I backup my other private text data like settings & contacts? How do I backup my other (non-identifier-secret) data?
</h2> </h2>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
@@ -127,27 +134,6 @@
won't lose it. won't lose it.
</li> </li>
</ul> </ul>
<h2 class="text-xl font-semibold">
How do I backup my profile image?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
tap on your image, and save it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup other data I've posted?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
This requires use of the API, so investigate the endpoints
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
(particularly the "claim" endpoints).
</li>
</ul>
</div> </div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2> <h2 class="text-xl font-semibold">How do I restore my data?</h2>
@@ -176,7 +162,6 @@
<li> <li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page, Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for the Contacts & Settings Database "Import". click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li> </li>
</ul> </ul>
</div> </div>
@@ -193,7 +178,8 @@
<h2 class="text-xl font-semibold">How do I erase my data?</h2> <h2 class="text-xl font-semibold">How do I erase my data?</h2>
<p> <p>
Before doing this, you may want to back up your data with the instructions above. Before doing this, note the two kinds of data to backup: identity data,
and other data for contacts and settings (see instructions above).
</p> </p>
<ul> <ul>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
@@ -212,11 +198,11 @@
<ul> <ul>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Chrome: Chrome:
Clear at "chrome://settings/content/all" and Clear at chrome://settings/content/all and
also clear under dev tools Application also clear under dev tools Application
</li> </li>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Firefox: Navigate to "about:preferences", Manage Data, Firefox: <a href="about:preferences">go here</a>, Manage Data,
find timesafari.app and select, hit Remove Selected, then Save find timesafari.app and select, hit Remove Selected, then Save
Changes Changes
</li> </li>
@@ -246,7 +232,7 @@
<p> <p>
There is a even more functionality in a mobile app (and more There is a even more functionality in a mobile app (and more
documentation) at documentation) at
<a href="https://endorser.ch" target="_blank" class="text-blue-500"> <a href="https://endorser.ch" class="text-blue-500">
EndorserSearch.com EndorserSearch.com
</a> </a>
</p> </p>
@@ -299,11 +285,6 @@
and you may have to close all your tabs. In addition, it may be running as an and you may have to close all your tabs. In addition, it may be running as an
installed app, so look for any Time Safari app that may be running outside a browser. installed app, so look for any Time Safari app that may be running outside a browser.
</li> </li>
<li>
There may be a problem with your identity. Go to the Identity
<fa icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
and you may see helpful info there. If it shows a problem, try adding your identifier again.
</li>
<li> <li>
It can help to reregister the service worker: It can help to reregister the service worker:
<ul> <ul>
@@ -317,7 +298,7 @@
find "timesafari.app", and click "Unregister". find "timesafari.app", and click "Unregister".
</li> </li>
<li> <li>
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a> <a href="https://duckduckgo.com/?q=unregister+service+worker" class="text-blue-500">Search</a>
for instructions for other browsers.</li> for instructions for other browsers.</li>
</ul> </ul>
Then reload Time Safari. Then reload Time Safari.
@@ -337,7 +318,7 @@
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2> <h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center"> <p style="display:inline; align-items: center">
This work is public domain. If you like rules, reference This work is public domain, governed by
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer"> <a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span> <span class="text-blue-500 mr-1">CC0 1.0</span>
<img <img
@@ -358,35 +339,15 @@
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page. by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
<br /> <br />
For all other claim data, For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500"> <a href="https://endorser.ch/privacy-policy" class="text-blue-500">
the Endorser Service has this Privacy Policy. the Endorser Service has this Privacy Policy.
</a> </a>
</p> </p>
<h2 class="text-xl font-semibold">How can I contribute?</h2>
<p>
If you have skills, contact us below.
If you have Bitcoin, donate to
<button
@click="
doCopyTwoSecRedo(
'bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma',
() => (showDidCopy = !showDidCopy)
)
"
class="text-blue-500 ml-2"
>
bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied</span>
For other donations, contact us.
</p>
<h2 class="text-xl font-semibold">Where can I read more?</h2> <h2 class="text-xl font-semibold">Where can I read more?</h2>
<p> <p>
This is part of the This is part of the
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500"> <a href="https://livesofgiving.org" class="text-blue-500">
Lives of Giving Lives of Giving
</a> </a>
initiative. initiative.
@@ -396,7 +357,7 @@
<p>{{ package.version }} ({{ commitHash }})</p> <p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
For any other questions, like getting a new account or removing all your data from the public ledger: For any other questions, including removing your data:
</h2> </h2>
<p> <p>
Contact us at Contact us at
@@ -411,7 +372,6 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import * as Package from "../../package.json"; import * as Package from "../../package.json";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@@ -423,14 +383,5 @@ export default class Help extends Vue {
package = Package; package = Package;
commitHash = import.meta.env.VITE_GIT_HASH; commitHash = import.meta.env.VITE_GIT_HASH;
showDidCopy = false;
// call fn, copy text to the clipboard, then redo fn after 2 seconds
doCopyTwoSecRedo(text: string, fn: () => void) {
fn();
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
} }
</script> </script>

View File

@@ -5,7 +5,7 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-2 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-2 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-8"> <h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-8">
{{ AppString.APP_NAME }} Time Safari
</h1> </h1>
<!-- prompt to install notifications --> <!-- prompt to install notifications -->
@@ -62,106 +62,117 @@
<div v-if="showShortcutBvc" class="mb-4"> <div v-if="showShortcutBvc" class="mb-4">
<router-link <router-link
:to="{ name: 'quick-action-bvc' }" :to="{ name: 'quick-action-bvc' }"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Bountiful Voluntaryist Community Actions</router-link
> >
Bountiful Voluntaryist Community Actions
</router-link>
</div> </div>
<!-- show the actions for recognizing a give -->
<div class="mb-8"> <div class="mb-8">
<div v-if="isCreatingIdentifier"> <div v-if="isCreatingIdentifier">
<p class="text-slate-500 text-center italic mt-4 mb-4"> <p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip; <fa icon="spinner" class="fa-spin-pulse"></fa> Loading&hellip;
</p> </p>
</div> </div>
<div
v-if="!activeDid && !isCreatingIdentifier"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<p class="text-lg mb-3">
Want to connect with your contacts, or share contributions or
projects?
</p>
<router-link
:to="{ name: 'start' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Create An Identifier</router-link
>
</div>
<div
v-else-if="!isRegistered"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
Someone must register your identifier before you can record anyone's
giving.
<router-link
:to="{ name: 'contact-qr' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Show Them Your Identifier Info</router-link
>
<br />
To double-check that you're registered,
<br />
<router-link :to="{ name: 'account' }" class="text-blue-500">
see your Usage Limits on the Account
<fa icon="circle-user" /> page.</router-link
>
</div>
<div v-else> <div v-else>
<!-- !isCreatingIdentifier --> <!-- activeDid && isRegistered -->
<!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
<div class="mb-4"> <div class="mb-4">
<div <h2 class="text-xl font-bold">Record Something Given By:</h2>
v-if="!isRegistered" </div>
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
>
<li @click="openDialog()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous/Unnamed
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 7)"
:key="contact.did"
@click="openDialog(contact)"
> >
<!-- activeDid && !isRegistered --> <EntityIcon
To share, someone must register you. :entityId="contact.did"
<router-link :iconSize="64"
:to="{ name: 'contact-qr' }" class="mx-auto border border-slate-300 rounded-md mb-1"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md" />
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
> >
Show Them Default Identifier Info {{ contact.name || contact.did }}
</router-link> </h3>
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full"> </li>
<router-link </ul>
:to="{ name: 'start' }"
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
See all your options first
</router-link>
</div>
</div>
<div v-else> <div class="flex justify-between">
<!-- activeDid && isRegistered --> <router-link
v-if="allContacts.length >= 7"
<!-- show the actions for recognizing a give --> :to="{ name: 'contact-gives' }"
<div class="mb-4"> class="block text-center text-md font-bold uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
<h2 class="text-xl font-bold">Record Something Given By:</h2> >
</div> Choose From All Contacts
</router-link>
<ul <button
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5" @click="openGiftedPrompts()"
> class="block text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
<li @click="openDialog()"> >
<img Ideas...
src="../assets/blank-square.svg" </button>
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 7)"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || contact.did }}
</h3>
</li>
</ul>
<div class="flex justify-between">
<router-link
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gift' }"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Choose From All Contacts
</router-link>
<button
@click="openGiftedPrompts()"
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Ideas...
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<GiftedDialog ref="customDialog" /> <GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
<GiftedPrompts ref="giftedPrompts" /> <GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" /> <FeedFilters ref="feedFilters" />
@@ -170,7 +181,7 @@
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<h2 class="text-xl font-bold">Latest Activity</h2> <h2 class="text-xl font-bold">Latest Activity</h2>
<button @click="openFeedFilters()" class="block text-center ml-auto"> <button @click="openFeedFilters()" class="block text-center ml-auto">
<span class="text-sm text-white"> <span class="text-sm uppercase text-white">
<span <span
v-if="resultsAreFiltered()" v-if="resultsAreFiltered()"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
@@ -194,65 +205,24 @@
:key="record.jwtId" :key="record.jwtId"
> >
<div <div
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm" class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
v-if="record.jwtId == feedLastViewedClaimId" v-if="record.jwtId == feedLastViewedClaimId"
> >
You've already seen all the following You've already seen all the following
</div> </div>
<div class="grid grid-cols-12"> <div class="grid grid-cols-12">
<span class="pt-1 col-span-1 justify-self-start"> <span class="col-span-1 justify-self-start">
<span> <span>
<fa <fa
v-if="record.giver.known || record.receiver.known"
icon="circle-user" icon="circle-user"
:class=" class="pt-1 text-slate-500"
computeKnownPersonIconStyleClassNames(
record.giver.known || record.receiver.known,
)
"
@click="toastUser('This involves your contacts.')"
/>
<fa
icon="gift"
class="pl-3 text-slate-500"
@click="toastUser('This is a gift.')"
/> />
<fa v-else icon="gift" class="pt-1 pl-3 text-slate-500" />
</span> </span>
</span> </span>
<span class="col-span-10 justify-self-stretch"> <span class="col-span-10 justify-self-stretch">
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
<span
v-if="
record.giver.profileImageUrl ||
record.receiver.profileImageUrl
"
>
<EntityIcon
v-if="record.agentDid !== activeDid"
:icon-size="32"
:profile-image-url="record.giver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
<fa
v-if="
record.agentDid !== activeDid &&
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
icon="ellipsis"
class="text-slate"
/>
<EntityIcon
v-if="
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
:iconSize="32"
:profile-image-url="record.receiver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
/>
</span>
-->
<span class="pl-2"> <span class="pl-2">
{{ giveDescription(record) }} {{ giveDescription(record) }}
</span> </span>
@@ -260,7 +230,7 @@
<fa <fa
icon="file-lines" icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer" class="pl-2 text-blue-500 cursor-pointer"
/> ></fa>
</a> </a>
</span> </span>
<span class="col-span-1 justify-self-end"> <span class="col-span-1 justify-self-end">
@@ -271,7 +241,7 @@
encodeURIComponent(record.fulfillsPlanHandleId) encodeURIComponent(record.fulfillsPlanHandleId)
" "
> >
<fa icon="hammer" class="text-blue-500" /> <fa icon="hammer" class="text-blue-500"></fa>
</router-link> </router-link>
</span> </span>
</div> </div>
@@ -285,7 +255,7 @@
</InfiniteScroll> </InfiniteScroll>
<div v-if="isFeedLoading"> <div v-if="isFeedLoading">
<p class="text-slate-500 text-center italic mt-4 mb-4"> <p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip; <fa icon="spinner" class="fa-spin-pulse"></fa> Loading&hellip;
</p> </p>
</div> </div>
<div v-if="!isFeedLoading && feedData.length === 0"> <div v-if="!isFeedLoading && feedData.length === 0">
@@ -298,11 +268,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import * as R from "ramda";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import App from "../App.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPrompts from "@/components/GiftedPrompts.vue"; import GiftedPrompts from "@/components/GiftedPrompts.vue";
@@ -310,12 +280,9 @@ import FeedFilters from "@/components/FeedFilters.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { import { NotificationIface } from "@/constants/app";
AppString,
NotificationIface,
PASSKEYS_ENABLED,
} from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { db, accountsDB } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { import {
BoundingBox, BoundingBox,
@@ -323,42 +290,31 @@ import {
MASTER_SETTINGS_KEY, MASTER_SETTINGS_KEY,
Settings, Settings,
} from "@/db/tables/settings"; } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { import {
contactForDid, contactForDid,
containsNonHiddenDid, containsNonHiddenDid,
didInfoForContact, didInfoForContact,
fetchEndorserRateLimits,
getHeaders,
getPlanFromCache, getPlanFromCache,
GiverReceiverInputInfo, GiverInputInfo,
GiveSummaryRecord, GiveSummaryRecord,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { import { generateSaveAndActivateIdentity } from "@/libs/util";
generateSaveAndActivateIdentity,
registerSaveAndActivatePasskey,
} from "@/libs/util";
interface GiveRecordWithContactInfo extends GiveSummaryRecord { interface GiveRecordWithContactInfo extends GiveSummaryRecord {
giver: { giver: {
displayName: string; displayName: string;
known: boolean; known: boolean;
profileImageUrl?: string;
}; };
image?: string; image: string;
recipientProjectName?: string; recipientProjectName: string | undefined;
receiver: { receiver: {
displayName: string; displayName: string;
known: boolean; known: boolean;
profileImageUrl?: string;
}; };
} }
@Component({ @Component({
computed: {
App() {
return App;
},
},
components: { components: {
GiftedDialog, GiftedDialog,
GiftedPrompts, GiftedPrompts,
@@ -372,9 +328,6 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
export default class HomeView extends Vue { export default class HomeView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
@@ -382,7 +335,6 @@ export default class HomeView extends Vue {
feedData: GiveRecordWithContactInfo[] = []; feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string; feedPreviousOldestId?: string;
feedLastViewedClaimId?: string; feedLastViewedClaimId?: string;
givenName = "";
isAnyFeedFilterOn: boolean; isAnyFeedFilterOn: boolean;
isCreatingIdentifier = false; isCreatingIdentifier = false;
isFeedFilteredByVisible = false; isFeedFilteredByVisible = false;
@@ -396,18 +348,30 @@ export default class HomeView extends Vue {
showShortcutBvc = false; showShortcutBvc = false;
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
return identity; // may be null
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async mounted() { async mounted() {
try { try {
await accountsDB.open(); await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray();
if (allAccounts.length > 0) { this.allMyDids = allAccounts.map((acc) => acc.did);
this.allMyDids = allAccounts.map((acc) => acc.did);
} else {
this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity();
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
}
await db.open(); await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
@@ -415,7 +379,6 @@ export default class HomeView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.feedLastViewedClaimId = settings?.lastViewedClaimId; this.feedLastViewedClaimId = settings?.lastViewedClaimId;
this.givenName = settings?.firstName || "";
this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible; this.isFeedFilteredByVisible = !!settings?.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby; this.isFeedFilteredByNearby = !!settings?.filterFeedByNearby;
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
@@ -424,29 +387,15 @@ export default class HomeView extends Vue {
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
if (this.allMyDids.length === 0) {
// someone may have have registered after sharing contact info, so recheck this.isCreatingIdentifier = true;
if (!this.isRegistered && this.activeDid) { this.activeDid = await generateSaveAndActivateIdentity();
try { this.allMyDids = [this.activeDid];
const resp = await fetchEndorserRateLimits( this.isCreatingIdentifier = false;
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
// we just needed to know that they're registered
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true,
});
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
} }
// this returns a Promise but we don't need to wait for it // this returns a Promise but we don't need to wait for it
await this.updateAllFeed(); await this.updateAllFeed();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -466,15 +415,6 @@ export default class HomeView extends Vue {
} }
} }
async generatePasskeyIdentifier() {
this.isCreatingIdentifier = true;
const account = await registerSaveAndActivatePasskey(
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""),
);
this.activeDid = account.did;
this.allMyDids = this.allMyDids.concat(this.activeDid);
this.isCreatingIdentifier = false;
}
resultsAreFiltered() { resultsAreFiltered() {
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby; return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
} }
@@ -483,6 +423,26 @@ export default class HomeView extends Vue {
return "Notification" in window; return "Notification" in window;
} }
public async buildHeaders() {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (this.activeDid) {
if (identity) {
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
// only called when a setting was changed // only called when a setting was changed
async reloadFeedOnChange() { async reloadFeedOnChange() {
await db.open(); await db.open();
@@ -500,11 +460,8 @@ export default class HomeView extends Vue {
* Data loader used by infinite scroller * Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load * @param payload is the flag from the InfiniteScroll indicating if it should load
**/ **/
async loadMoreGives(payload: boolean) { public async loadMoreGives(payload: boolean) {
// Since feed now loads projects along the way, it takes longer if (payload) {
// and the InfiniteScroll component triggers a load before finished.
// One alternative is to totally separate the project link loading.
if (payload && !this.isFeedLoading) {
this.updateAllFeed(); this.updateAllFeed();
} }
} }
@@ -522,7 +479,7 @@ export default class HomeView extends Vue {
} }
} }
async updateAllFeed() { public async updateAllFeed() {
this.isFeedLoading = true; this.isFeedLoading = true;
let endOfResults = true; let endOfResults = true;
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId) await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
@@ -530,66 +487,71 @@ export default class HomeView extends Vue {
if (results.data.length > 0) { if (results.data.length > 0) {
endOfResults = false; endOfResults = false;
// include the descriptions of the giver and receiver // include the descriptions of the giver and receiver
for (const record: GiveSummaryRecord of results.data) { const identity = await this.getIdentity(this.activeDid);
// similar code is in endorser-mobile utility.ts const newFeedData: Array<Promise<GiveRecordWithContactInfo>> =
// claim.claim happen for some claims wrapped in a Verifiable Credential results.data.map(async (record: GiveSummaryRecord) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // similar code is in endorser-mobile utility.ts
const claim = (record.fullClaim as any).claim || record.fullClaim; // claim.claim happen for some claims wrapped in a Verifiable Credential
// agent.did is for legacy data, before March 2023 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const giverDid = const claim = (record.fullClaim as any).claim || record.fullClaim;
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any // agent.did is for legacy data, before March 2023
// recipient.did is for legacy data, before March 2023 const giverDid =
const recipientDid = claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any // recipient.did is for legacy data, before March 2023
const recipientDid =
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
const plan = await getPlanFromCache(
record.fulfillsPlanHandleId,
identity,
this.axios,
this.apiServer,
);
// This has indeed proven problematic. See loadMoreGives // check if the record should be filtered out
// We should display it immediately and then get the plan later. let anyMatch = false;
const plan = await getPlanFromCache( if (
record.fulfillsPlanHandleId, this.isFeedFilteredByVisible &&
this.axios, containsNonHiddenDid(record)
this.apiServer, ) {
this.activeDid, // has a visible DID so it's a keeper
); anyMatch = true;
}
// check if the record should be filtered out if (!anyMatch && this.isFeedFilteredByNearby) {
let anyMatch = false; // check if the associated project has a location inside user's search box
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) { if (record.fulfillsPlanHandleId) {
// has a visible DID so it's a keeper if (plan?.locLat && plan?.locLon) {
anyMatch = true; if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) {
} anyMatch = true;
if (!anyMatch && this.isFeedFilteredByNearby) { }
// check if the associated project has a location inside user's search box
if (record.fulfillsPlanHandleId) {
if (plan?.locLat && plan?.locLon) {
if (this.latLongInAnySearchBox(plan.locLat, plan.locLon)) {
anyMatch = true;
} }
} }
} }
} if (this.isAnyFeedFilterOn && !anyMatch) {
if (this.isAnyFeedFilterOn && !anyMatch) { return null;
continue; }
}
const newRecord: GiveRecordWithContactInfo = { return {
...record, ...record,
giver: didInfoForContact( giver: didInfoForContact(
giverDid, giverDid,
this.activeDid, this.activeDid,
contactForDid(giverDid, this.allContacts), contactForDid(giverDid, this.allContacts),
this.allMyDids, this.allMyDids,
), ),
image: claim.image, image: claim.image,
recipientProjectName: plan?.name as string, recipientProjectName: plan?.name,
receiver: didInfoForContact( receiver: didInfoForContact(
recipientDid, recipientDid,
this.activeDid, this.activeDid,
contactForDid(recipientDid, this.allContacts), contactForDid(recipientDid, this.allContacts),
this.allMyDids, this.allMyDids,
), ),
}; };
this.feedData.push(newRecord); });
} const allNewFeedData: GiveRecordWithContactInfo[] =
await Promise.all(newFeedData);
const filteredFeedData = allNewFeedData.filter(R.isNotNil);
this.feedData = this.feedData.concat(filteredFeedData);
this.feedPreviousOldestId = this.feedPreviousOldestId =
results.data[results.data.length - 1].jwtId; results.data[results.data.length - 1].jwtId;
// The following update is only done on the first load. // The following update is only done on the first load.
@@ -629,15 +591,15 @@ export default class HomeView extends Vue {
* @param beforeId the earliest ID (of previous searches) to search earlier * @param beforeId the earliest ID (of previous searches) to search earlier
* @return claims in reverse chronological order * @return claims in reverse chronological order
*/ */
async retrieveGives(endorserApiServer: string, beforeId?: string) { public async retrieveGives(endorserApiServer: string, beforeId?: string) {
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId; const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const response = await fetch( const response = await fetch(
endorserApiServer + endorserApiServer +
"/api/v2/report/gives?giftNotTrade=true" + "/api/v2/report/gives?giftNotTrade=true&" +
beforeQuery, beforeQuery,
{ {
method: "GET", method: "GET",
headers: await getHeaders(this.activeDid), headers: await this.buildHeaders(),
}, },
); );
@@ -722,7 +684,7 @@ export default class HomeView extends Vue {
const route = { const route = {
path: "/claim/" + encodeURIComponent(jwtId), path: "/claim/" + encodeURIComponent(jwtId),
}; };
(this.$router as Router).push(route); this.$router.push(route);
} }
displayAmount(code: string, amt: number) { displayAmount(code: string, amt: number) {
@@ -733,16 +695,8 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
} }
openDialog(giver?: GiverReceiverInputInfo) { openDialog(giver?: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open( (this.$refs.customDialog as GiftedDialog).open(giver);
giver,
{
did: this.activeDid,
name: "you",
},
undefined,
"Given by " + (giver?.name || "someone not named"),
);
} }
openGiftedPrompts() { openGiftedPrompts() {
@@ -752,21 +706,5 @@ export default class HomeView extends Vue {
openFeedFilters() { openFeedFilters() {
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange); (this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
} }
toastUser(message) {
this.$notify(
{
group: "alert",
type: "toast",
title: "FYI",
text: message,
},
2000,
);
}
computeKnownPersonIconStyleClassNames(known: boolean) {
return known ? "text-slate-500" : "text-slate-100";
}
} }
</script> </script>

View File

@@ -39,43 +39,24 @@
<!-- Other Identity/ies --> <!-- Other Identity/ies -->
<ul class="mb-4"> <ul class="mb-4">
<li v-for="ident in otherIdentities" :key="ident.did"> <li
<div class="flex items-center justify-between mb-2"> class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
<div v-for="ident in otherIdentities"
class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer" :key="ident.did"
@click="switchAccount(ident.did)" @click="switchAccount(ident.did)"
> >
<fa <fa
v-if="ident.did === activeDid" v-if="ident.did === activeDid"
icon="circle-check" icon="circle-check"
class="fa-fw text-blue-600 text-xl mr-3" class="fa-fw text-blue-600 text-xl mr-3"
/> />
<fa <fa v-else icon="circle" class="fa-fw text-slate-400 text-xl mr-3" />
v-else <span class="overflow-hidden">
icon="circle" <h2 class="text-xl font-semibold mb-0"></h2>
class="fa-fw text-slate-400 text-xl mr-3" <div class="text-sm text-slate-500 truncate">
/> <b>ID:</b> <code>{{ ident.did }}</code>
<span class="flex-grow overflow-hidden">
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ ident.did }}</code>
</div>
</span>
</div> </div>
<div> </span>
<fa
v-if="ident.did === activeDid"
icon="trash-can"
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
@click="notifyCannotDelete()"
/>
<fa
v-else
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteAccount(ident.id)"
/>
</div>
</div>
</li> </li>
</ul> </ul>
@@ -100,8 +81,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@@ -109,11 +91,14 @@ import QuickNav from "@/components/QuickNav.vue";
export default class IdentitySwitcherView extends Vue { export default class IdentitySwitcherView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
Constants = AppString;
public accounts: typeof AccountsSchema;
public activeDid = ""; public activeDid = "";
public activeDidInIdentities = false; public activeDidInIdentities = false;
public apiServer = ""; public apiServer = "";
public apiServerInput = ""; public apiServerInput = "";
public otherIdentities: Array<{ id: string; did: string }> = []; public otherIdentities: Array<{ did: string }> = [];
public showContactGives = false;
async created() { async created() {
try { try {
@@ -122,13 +107,14 @@ export default class IdentitySwitcherView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || ""; this.apiServerInput = settings?.apiServer || "";
this.showContactGives = !!settings?.showContactGivesInline;
await accountsDB.open(); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) { for (let n = 0; n < accounts.length; n++) {
const acct = accounts[n]; const did = JSON.parse(accounts[n].identity)["did"];
this.otherIdentities.push({ id: acct.id as string, did: acct.did }); this.otherIdentities.push({ did: did });
if (acct.did && this.activeDid === acct.did) { if (did && this.activeDid === did) {
this.activeDidInIdentities = true; this.activeDidInIdentities = true;
} }
} }
@@ -157,36 +143,5 @@ export default class IdentitySwitcherView extends Vue {
}); });
this.$router.push({ name: "account" }); this.$router.push({ name: "account" });
} }
async deleteAccount(id: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
await accountsDB.open();
await accountsDB.accounts.delete(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
},
},
-1,
);
}
notifyCannotDelete() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Cannot Delete",
text: "You cannot delete the active identity.",
},
3000,
);
}
} }
</script> </script>

View File

@@ -18,7 +18,7 @@
Enter your seed phrase below to import your identifier on this device. Enter your seed phrase below to import your identifier on this device.
</p> </p>
<!-- id used by puppeteer test script --> <!-- id used by puppeteer test script -->
<textarea <input
id="seed-input" id="seed-input"
type="text" type="text"
placeholder="Seed Phrase" placeholder="Seed Phrase"

View File

@@ -17,7 +17,7 @@
<div> <div>
<p class="text-center text-xl mb-4 font-light"> <p class="text-center text-xl mb-4 font-light">
Will increment the maximum known derivation path from the existing seed. Will increment the maximum derivation path from the existing seed.
</p> </p>
<p v-if="didArrays.length > 1"> <p v-if="didArrays.length > 1">
@@ -75,7 +75,7 @@ import {
deriveAddress, deriveAddress,
newIdentifier, newIdentifier,
nextDerivationPath, nextDerivationPath,
} from "@/libs/crypto"; } from "../libs/crypto";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";

View File

@@ -33,7 +33,7 @@
<!-- SHOW ME instead while processing saving changes --> <!-- SHOW ME instead while processing saving changes -->
<button <button
type="button" type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onClickCancel()" @click="onClickCancel()"
> >
Cancel Cancel
@@ -68,6 +68,8 @@ export default class NewEditAccountView extends Vue {
firstName: this.givenName, firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3 lastName: "", // deprecated, pre v 0.1.3
}); });
localStorage.setItem("firstName", this.givenName as string);
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
this.$router.back(); this.$router.back();
} }

View File

@@ -29,31 +29,10 @@
v-model="fullClaim.name" v-model="fullClaim.name"
/> />
<div class="flex justify-center mt-4">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<input <input
type="text" type="text"
placeholder="Other Authorized Representative" placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2" class="block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid" v-model="agentDid"
/> />
<div class="mb-4"> <div class="mb-4">
@@ -85,23 +64,6 @@
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/> />
<div class="flex mb-4 columns-3 w-full">
<input
v-model="startDateInput"
placeholder="Start Date"
type="date"
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time"
type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/>
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
</div>
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<input <input
type="checkbox" type="checkbox"
@@ -137,7 +99,7 @@
<l-marker <l-marker
v-if="latitude && longitude" v-if="latitude && longitude"
:lat-lng="[latitude, longitude]" :lat-lng="[latitude, longitude]"
@click="confirmEraseLatLong()" @click="maybeEraseLatLong()"
/> />
</l-map> </l-map>
</div> </div>
@@ -173,34 +135,25 @@
<script lang="ts"> <script lang="ts">
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios"; import { AxiosError } from "axios";
import { DateTime } from "luxon"; import * as didJwt from "did-jwt";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { import { accessToken, SimpleSigner } from "@/libs/crypto";
createEndorserJwtVcFromClaim,
getHeaders,
PlanVerifiableCredential,
} from "@/libs/endorserServer";
import { useAppStore } from "@/store/app"; import { useAppStore } from "@/store/app";
import { IIdentifier } from "@veramo/core";
import { PlanVerifiableCredential } from "@/libs/endorserServer";
@Component({ @Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav }, components: { LMap, LMarker, LTileLayer, QuickNav },
}) })
export default class NewEditProjectView extends Vue { export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
activeDid = ""; activeDid = "";
agentDid = ""; agentDid = "";
@@ -212,7 +165,6 @@ export default class NewEditProjectView extends Vue {
name: "", name: "",
description: "", description: "",
}; // this default is only to avoid errors before plan is loaded }; // this default is only to avoid errors before plan is loaded
imageUrl = "";
includeLocation = false; includeLocation = false;
isHiddenSave = false; isHiddenSave = false;
isHiddenSpinner = true; isHiddenSpinner = true;
@@ -222,15 +174,39 @@ export default class NewEditProjectView extends Vue {
numAccounts = 0; numAccounts = 0;
projectId = localStorage.getItem("projectId") || ""; projectId = localStorage.getItem("projectId") || "";
projectIssuerDid = ""; projectIssuerDid = "";
startDateInput?: string;
startTimeInput?: string;
zoneName = DateTime.local().zoneName;
zoom = 2; zoom = 2;
async mounted() { async beforeCreate() {
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() {
await db.open(); await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || ""; this.activeDid = (settings?.activeDid as string) || "";
@@ -238,26 +214,35 @@ export default class NewEditProjectView extends Vue {
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
this.errNote("There was a problem loading your account info."); console.error("Error: no account was found.");
} else { } else {
this.loadProject(this.activeDid); const identity = await this.getIdentity(this.activeDid);
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
this.loadProject(identity);
} }
} }
} }
async loadProject(userDid: string) { async loadProject(identity: IIdentifier) {
const url = const url =
this.apiServer + this.apiServer +
"/api/claim/byHandle/" + "/api/claim/byHandle/" +
encodeURIComponent(this.projectId); encodeURIComponent(this.projectId);
const headers = await getHeaders(userDid); const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
this.projectIssuerDid = resp.data.issuer; this.projectIssuerDid = resp.data.issuer;
this.fullClaim = resp.data.claim; this.fullClaim = resp.data.claim;
this.imageUrl = resp.data.claim.image || "";
this.lastClaimJwtId = resp.data.id; this.lastClaimJwtId = resp.data.id;
if (this.fullClaim?.location) { if (this.fullClaim?.location) {
this.includeLocation = true; this.includeLocation = true;
@@ -267,93 +252,13 @@ export default class NewEditProjectView extends Vue {
if (this.fullClaim?.agent?.identifier) { if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier; this.agentDid = this.fullClaim.agent.identifier;
} }
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
).toLocal();
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm");
}
} }
} catch (error) { } catch (error) {
console.error("Got error retrieving that project", error); console.error("Got error retrieving that project", error);
this.errNote("There was an error retrieving that project.");
} }
} }
openImageDialog() { private async saveProject(identity: IIdentifier) {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "PlanAction");
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{ headers },
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
return;
}
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error deleting the image.",
},
5000,
);
}
}
}
private async saveProject(issuerDid: string) {
// Make a claim // Make a claim
const vcClaim: PlanVerifiableCredential = this.fullClaim; const vcClaim: PlanVerifiableCredential = this.fullClaim;
if (this.projectId) { if (this.projectId) {
@@ -366,11 +271,6 @@ export default class NewEditProjectView extends Vue {
} else { } else {
delete vcClaim.agent; delete vcClaim.agent;
} }
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
delete vcClaim.image;
}
if (this.includeLocation) { if (this.includeLocation) {
vcClaim.location = { vcClaim.location = {
geo: { geo: {
@@ -382,106 +282,110 @@ export default class NewEditProjectView extends Vue {
} else { } else {
delete vcClaim.location; delete vcClaim.location;
} }
if (this.startDateInput) { // Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// create a signature using private key of identity
if (identity.keys[0].privateKeyHex != null) {
const privateKeyHex: string = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try { try {
const startTimeFull = this.startTimeInput || "00:00:00"; const resp = await this.axios.post(url, payload, { headers });
const fullTimeString = this.startDateInput + " " + startTimeFull; if (resp.data?.success?.handleId) {
// throw an error on an invalid date or time string this.errorMessage = "";
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.startTime;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "The date was invalid so it was not set.",
},
5000,
);
}
} else {
delete vcClaim.startTime;
}
const vcJwt = await createEndorserJwtVcFromClaim(issuerDid, vcClaim);
// Make the xhr request payload useAppStore()
.setProjectId(resp.data.success.handleId)
const payload = JSON.stringify({ jwtEncoded: vcJwt }); .then(() => {
const url = this.apiServer + "/api/v2/claim"; this.$router.push({ name: "project" });
const headers = await getHeaders(issuerDid); });
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) {
this.errorMessage = "";
useAppStore()
.setProjectId(resp.data.success.handleId)
.then(() => {
this.$router.push({ name: "project" });
});
} else {
console.error(
"Got unexpected 'data' inside response from server",
resp,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Saving Idea",
text: "Server did not save the idea. Try again.",
},
-1,
);
}
} catch (error) {
let userMessage = "There was an error saving the project.";
const serverError = error as AxiosError<{
error?: { message?: string };
}>;
if (serverError) {
console.error("Got error from server", serverError);
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
userMessage =
(serverError.response?.data?.error?.message as string) ||
userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "User Message",
text: userMessage,
},
-1,
);
} else { } else {
console.error(
"Got unexpected 'data' inside response from server",
resp,
);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Server Message", title: "Error Saving Idea",
text: JSON.stringify(serverError.toJSON()), text: "Server did not save the idea. Try again.",
}, },
-1, -1,
); );
} }
} else { } catch (error) {
console.error("Here's the full error trying to save the claim:", error); let userMessage = "There was an error saving the project.";
this.$notify( const serverError = error as AxiosError<{
{ error?: { message?: string };
group: "alert", }>;
type: "danger", if (serverError) {
title: "Claim Error", console.error("Got error from server", serverError);
text: error as string, if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
}, userMessage =
-1, (serverError.response?.data?.error?.message as string) ||
); userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "User Message",
text: userMessage,
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Message",
text: JSON.stringify(serverError.toJSON()),
},
-1,
);
}
} else {
console.error(
"Here's the full error trying to save the claim:",
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Claim Error",
text: error as string,
},
-1,
);
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
} }
// Now set that error for the user to see.
this.errorMessage = userMessage;
} }
} }
@@ -492,29 +396,17 @@ export default class NewEditProjectView extends Vue {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("Error: there is no account."); console.error("Error: there is no account.");
} else { } else {
this.saveProject(this.activeDid); const identity = await this.getIdentity(this.activeDid);
this.saveProject(identity);
} }
} }
confirmEraseLatLong() { public maybeEraseLatLong() {
this.$notify( if (window.confirm("Are you sure you don't want to mark a location?")) {
{ this.latitude = 0;
group: "modal", this.longitude = 0;
type: "confirm", this.includeLocation = false;
title: "Erase Marker", }
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: async () => {
this.eraseLatLong();
},
},
-1,
);
}
public eraseLatLong() {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
} }
public onCancelClick() { public onCancelClick() {

View File

@@ -5,7 +5,7 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div id="ViewBreadcrumb"> <div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="text-lg text-center font-light relative px-7">
<!-- Back --> <!-- Back -->
<button <button
@@ -15,25 +15,23 @@
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw"></fa>
</button> </button>
Idea Idea
<h2 class="text-xl font-semibold">{{ name }}</h2>
</h1> </h1>
</div> </div>
<!-- Project Details --> <!-- Project Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div> <div>
<div class="pb-4 flex gap-4"> <div class="block pb-4 flex gap-4">
<div class="pt-1"> <div class="flex-none w-16 pt-1">
<ProjectIcon <ProjectIcon
:entityId="projectId" :entityId="projectId"
:iconSize="64" :iconSize="64"
:imageUrl="imageUrl" class="block border border-slate-300 rounded-md"
:linkToFull="true" ></ProjectIcon>
class="block border border-slate-300 rounded-md max-h-16 max-w-16"
/>
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<h2 class="text-xl font-semibold">{{ name }}</h2>
<div class="text-sm mb-3"> <div class="text-sm mb-3">
<div class="truncate"> <div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa> <fa icon="user" class="fa-fw text-slate-400"></fa>
@@ -55,9 +53,9 @@
<span v-show="showDidCopy">Copied DID</span> <span v-show="showDidCopy">Copied DID</span>
</span> </span>
</div> </div>
<div v-if="startTime"> <div v-if="timeSince">
<fa icon="calendar" class="fa-fw text-slate-400"></fa> <fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ startTime }} {{ timeSince }}
</div> </div>
<div v-if="latitude || longitude"> <div v-if="latitude || longitude">
<fa icon="location-dot" class="fa-fw text-slate-400"></fa> <fa icon="location-dot" class="fa-fw text-slate-400"></fa>
@@ -71,9 +69,8 @@
</div> </div>
<div v-if="url"> <div v-if="url">
<fa icon="globe" class="fa-fw text-slate-400"></fa> <fa icon="globe" class="fa-fw text-slate-400"></fa>
<a :href="addScheme(url)" target="_blank" class="underline"> <a :href="addScheme(url)" target="_blank" class="underline"
{{ domainForWebsite(this.url) }} >{{ domainForWebsite(this.url) }}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a> </a>
</div> </div>
</div> </div>
@@ -115,11 +112,11 @@
</button> </button>
</div> </div>
<div v-if="activeDid" class="mt-4"> <div v-if="activeDid" class="mb-4">
<div class="text-center"> <div class="text-center">
<button <button
@click="openOfferDialog()" @click="openOfferDialog()"
class="block w-full text-lg font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" class="block w-full text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
> >
Offer (maybe with conditions)... Offer (maybe with conditions)...
</button> </button>
@@ -128,8 +125,14 @@
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" /> <OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<div v-if="activeDid"> <div v-if="activeDid">
<GiftedDialog
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<div class="text-center"> <div class="text-center">
<p class="mt-2 mt-4 text-center">Record a contribution from:</p> <p class="mt-2 mb-4 text-center">Record a contribution from:</p>
</div> </div>
<ul <ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5" class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
@@ -150,7 +153,7 @@
<h3 <h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden" class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
> >
Unnamed/Unknown Anonymous/Unnamed
</h3> </h3>
</li> </li>
<li <li
@@ -159,9 +162,9 @@
@click="openGiftDialog(contact)" @click="openGiftDialog(contact)"
> >
<EntityIcon <EntityIcon
:contact="contact" :entityId="contact.did"
:iconSize="64" :iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer" class="mx-auto border border-slate-300 rounded-md mb-1"
/> />
<h3 <h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden" class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
@@ -175,18 +178,18 @@
<a <a
v-if="allContacts.length >= 7" v-if="allContacts.length >= 7"
@click="onClickAllContactsGifting()" @click="onClickAllContactsGifting()"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" class="block text-center text-md font-bold uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
> >
Show More Contacts&hellip; Show More Contacts&hellip;
</a> </a>
<GiftedDialog ref="customGiveDialog" :projectId="this.projectId" />
</div> </div>
<!-- Offers & Gifts to & from this --> <!-- Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> <div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-slate-100 px-4 py-3 rounded-md"> <div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm font-semibold mb-3">Offered To This Idea</h3> <h3 class="text-sm uppercase font-semibold mb-3">
Offered To This Idea
</h3>
<div v-if="offersToThis.length === 0"> <div v-if="offersToThis.length === 0">
(None yet. Wanna (None yet. Wanna
@@ -243,20 +246,16 @@
</div> </div>
</li> </li>
</ul> </ul>
<div v-if="offersHitLimit" class="text-center text-blue-500">
<button @click="loadOffers()">Load More</button>
</div>
</div> </div>
<div class="bg-slate-100 px-4 py-3 rounded-md"> <div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm font-semibold mb-3">Given To This Idea</h3> <h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
<div v-if="givesToThis.length === 0"> <div v-if="givesToThis.length === 0">
(None yet. If you've seen something, say something by clicking a (None yet. If you've seen something, say something by clicking a
contact above.) contact above.)
</div> </div>
<!-- similar to gift display below -->
<ul v-else class="text-sm border-t border-slate-300"> <ul v-else class="text-sm border-t border-slate-300">
<li <li
v-for="give in givesToThis" v-for="give in givesToThis"
@@ -264,8 +263,8 @@
class="py-1.5 border-b border-slate-300" class="py-1.5 border-b border-slate-300"
> >
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<span> <span
<fa icon="user" class="fa-fw text-slate-400" /> ><fa icon="user" class="fa-fw text-slate-400"></fa>
{{ {{
serverUtil.didInfo( serverUtil.didInfo(
give.agentDid, give.agentDid,
@@ -294,77 +293,21 @@
<a @click="onClickLoadClaim(give.jwtId)"> <a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" /> <fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a> </a>
<a <a v-if="checkIsConfirmable(give)" @click="confirmClaim(give)">
v-if="checkIsConfirmable(give)"
@click="confirmConfirmClaim(give)"
>
<fa icon="circle-check" class="text-blue-500 cursor-pointer" /> <fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a> </a>
</div> </div>
</li> </li>
</ul> </ul>
<div v-if="givesHitLimit" class="text-center text-blue-500">
<button @click="loadGives()">Load More</button>
</div>
</div> </div>
<div class="grid items-start grid-cols-1 gap-4"> <div class="grid items-start grid-cols-1 gap-4">
<div
v-if="givesProvidedByThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mb-3 border-b">
Individuals Getting Contributions From This
</h3>
<!-- similar to gift display above -->
<ul class="text-sm border-t border-slate-300">
<li
v-for="give in givesProvidedByThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
</li>
</ul>
<div v-if="givesProvidedByHitLimit" class="text-center">
<button @click="loadGivesProvidedBy()">Load More</button>
</div>
</div>
<div <div
v-if="fulfillersToThis.length > 0" v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md" class="bg-slate-100 px-4 py-3 rounded-md"
> >
<h3 class="text-sm uppercase font-semibold mb-3"> <h3 class="text-sm uppercase font-semibold mb-3">
Projects That Contribute To This Contributions To This Idea
</h3> </h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" --> <!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center"> <div class="text-center">
@@ -376,15 +319,12 @@
{{ plan.name }} {{ plan.name }}
</button> </button>
</div> </div>
<div v-if="fulfillersToHitLimit" class="text-center">
<button @click="loadPlanFulfillersTo()">Load More</button>
</div>
</div> </div>
</div> </div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md"> <div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3"> <h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This Contributions From This Idea
</h3> </h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" --> <!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center"> <div class="text-center">
@@ -402,7 +342,9 @@
</template> </template>
<script lang="ts"> <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 { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
@@ -416,12 +358,12 @@ import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { import {
BLANK_GENERIC_SERVER_RECORD, BLANK_GENERIC_SERVER_RECORD,
GenericCredWrapper, GenericCredWrapper,
getHeaders, GiverInputInfo,
GiverReceiverInputInfo,
GiveSummaryRecord, GiveSummaryRecord,
OfferSummaryRecord, OfferSummaryRecord,
PlanSummaryRecord, PlanSummaryRecord,
@@ -450,21 +392,15 @@ export default class ProjectViewView extends Vue {
expanded = false; expanded = false;
fulfilledByThis: PlanSummaryRecord | null = null; fulfilledByThis: PlanSummaryRecord | null = null;
fulfillersToThis: Array<PlanSummaryRecord> = []; fulfillersToThis: Array<PlanSummaryRecord> = [];
fulfillersToHitLimit = false;
givesToThis: Array<GiveSummaryRecord> = []; givesToThis: Array<GiveSummaryRecord> = [];
givesHitLimit = false;
givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false;
imageUrl = "";
issuer = ""; issuer = "";
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
name = ""; name = "";
offersToThis: Array<OfferSummaryRecord> = []; offersToThis: Array<OfferSummaryRecord> = [];
offersHitLimit = false;
projectId = localStorage.getItem("projectId") || ""; // handle ID projectId = localStorage.getItem("projectId") || ""; // handle ID
showDidCopy = false; showDidCopy = false;
startTime = ""; timeSince = "";
truncatedDesc = ""; truncatedDesc = "";
truncateLength = 40; truncateLength = 40;
url = ""; url = "";
@@ -483,12 +419,24 @@ export default class ProjectViewView extends Vue {
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
const accountsArr: Account[] = await accounts?.toArray(); const accountsArr: Account[] = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did); this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const pathParam = window.location.pathname.substring("/project/".length); const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) { if (pathParam) {
this.projectId = decodeURIComponent(pathParam); this.projectId = decodeURIComponent(pathParam);
} }
this.loadProject(this.projectId, this.activeDid); this.loadProject(this.projectId, identity);
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
return identity;
} }
onEditClick() { onEditClick() {
@@ -508,26 +456,29 @@ export default class ProjectViewView extends Vue {
this.expanded = false; this.expanded = false;
} }
async loadProject(projectId: string, userDid: string) { async loadProject(projectId: string, identity: IIdentifier) {
this.projectId = projectId; this.projectId = projectId;
const url = const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId); this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers = await getHeaders(userDid); const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
const startTime = resp.data.claim?.startTime; const startTime = resp.data.startTime;
if (startTime != null) { if (startTime != null) {
const startDateTime = new Date(startTime); const eventDate = new Date(startTime);
this.startTime = const now = moment.now();
startDateTime.toLocaleDateString() + this.timeSince = moment.utc(now).to(eventDate);
" " +
startDateTime.toLocaleTimeString();
} }
this.agentDid = resp.data.claim?.agent?.identifier; this.agentDid = resp.data.claim?.agent?.identifier;
this.imageUrl = resp.data.claim?.image;
this.issuer = resp.data.issuer; this.issuer = resp.data.issuer;
this.name = resp.data.claim?.name || "(no name)"; this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)"; this.description = resp.data.claim?.description || "(no description)";
@@ -545,7 +496,7 @@ export default class ProjectViewView extends Vue {
title: "Error", title: "Error",
text: "There was a problem getting that project. See logs for more info.", text: "There was a problem getting that project. See logs for more info.",
}, },
5000, -1,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -559,7 +510,7 @@ export default class ProjectViewView extends Vue {
title: "Error", title: "Error",
text: "That project does not exist.", text: "That project does not exist.",
}, },
5000, -1,
); );
} else { } else {
this.$notify( this.$notify(
@@ -569,18 +520,82 @@ export default class ProjectViewView extends Vue {
title: "Error", title: "Error",
text: "Something went wrong retrieving that project. See logs for more info.", text: "Something went wrong retrieving that project. See logs for more info.",
}, },
5000, -1,
); );
} }
} }
this.loadGives(); const givesInUrl =
this.apiServer +
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives to this project.",
},
-1,
);
console.error(
"Error retrieving gives to this project:",
serverError.message,
);
}
this.loadGivesProvidedBy(); const offersToUrl =
this.apiServer +
this.loadOffers(); "/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
this.loadPlanFulfillersTo(); try {
const resp = await this.axios.get(offersToUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.offersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve offers to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving offers to this project.",
},
-1,
);
console.error(
"Error retrieving offers to this project:",
serverError.message,
);
}
const fulfilledByUrl = const fulfilledByUrl =
this.apiServer + this.apiServer +
@@ -598,7 +613,7 @@ export default class ProjectViewView extends Vue {
title: "Error", title: "Error",
text: "Failed to retrieve plans fulfilled by this project.", text: "Failed to retrieve plans fulfilled by this project.",
}, },
5000, -1,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -610,137 +625,31 @@ export default class ProjectViewView extends Vue {
title: "Error", title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.", text: "Something went wrong retrieving plans fulfilled by this project.",
}, },
5000, -1,
); );
console.error( console.error(
"Error retrieving plans fulfilled by this project:", "Error retrieving plans fulfilled by this project:",
serverError.message, serverError.message,
); );
} }
}
async loadGives() { const fulfillersToUrl =
const givesUrl =
this.apiServer +
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
let postfix = "";
if (this.givesToThis.length > 0) {
postfix =
"&beforeId=" + this.givesToThis[this.givesToThis.length - 1].jwtId;
}
const givesInUrl = givesUrl + postfix;
const headers = await getHeaders(this.activeDid);
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesToThis = this.givesToThis.concat(resp.data.data);
this.givesHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve more gives to this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving more gives to this project.",
},
5000,
);
console.error(
"Something went wrong retrieving more gives to this project:",
serverError.message,
);
}
}
async loadOffers() {
const offersUrl =
this.apiServer +
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
let postfix = "";
if (this.offersToThis.length > 0) {
postfix =
"&beforeId=" + this.offersToThis[this.offersToThis.length - 1].jwtId;
}
const offersInUrl = offersUrl + postfix;
const headers = await getHeaders(this.activeDid);
try {
const resp = await this.axios.get(offersInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.offersToThis = this.offersToThis.concat(resp.data.data);
this.offersHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve more offers to this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving more offers to this project.",
},
5000,
);
console.error(
"Something went wrong retrieving more offers to this project:",
serverError.message,
);
}
}
async loadPlanFulfillersTo() {
const fulfillsUrl =
this.apiServer + this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" + "/api/v2/report/planFulfillersToPlan?planHandleId=" +
encodeURIComponent(this.projectId); encodeURIComponent(projectId);
let postfix = "";
if (this.fulfillersToThis.length > 0) {
postfix =
"&beforeId=" +
this.fulfillersToThis[this.fulfillersToThis.length - 1].jwtId;
}
const fulfillsInUrl = fulfillsUrl + postfix;
const headers = await getHeaders(this.activeDid);
try { try {
const resp = await this.axios.get(fulfillsInUrl, { headers }); const resp = await this.axios.get(fulfillersToUrl, { headers });
if (resp.status === 200) { if (resp.status === 200) {
this.fulfillersToThis = this.fulfillersToThis.concat(resp.data.data); this.fulfillersToThis = resp.data.data;
this.fulfillersToHitLimit = resp.data.hitLimit;
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Failed to retrieve more plans that fullfill this project.", text: "Failed to retrieve plan fulfillers to this project.",
}, },
5000, -1,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -750,62 +659,12 @@ export default class ProjectViewView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Something went wrong retrieving more plans that fulfull this project.", text: "Something went wrong retrieving plan fulfillers to this project.",
}, },
5000, -1,
); );
console.error( console.error(
"Something went wrong retrieving more plans that fulfill this project:", "Error retrieving plan fulfillers to this project:",
serverError.message,
);
}
}
async loadGivesProvidedBy() {
const providedByUrl =
this.apiServer +
"/api/v2/report/givesProvidedBy?providerId=" +
encodeURIComponent(this.projectId);
let postfix = "";
if (this.givesProvidedByThis.length > 0) {
postfix =
"&beforeId=" +
this.givesProvidedByThis[this.givesProvidedByThis.length - 1].jwtId;
}
const providedByFullUrl = providedByUrl + postfix;
const headers = await getHeaders(this.activeDid);
try {
const resp = await this.axios.get(providedByFullUrl, { headers });
if (resp.status === 200) {
this.givesProvidedByThis = this.givesProvidedByThis.concat(
resp.data.data,
);
this.givesProvidedByHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives that were provided by this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives that were provided by this project.",
},
5000,
);
console.error(
"Something went wrong retrieving gives that were provided by this project:",
serverError.message, serverError.message,
); );
} }
@@ -821,7 +680,7 @@ export default class ProjectViewView extends Vue {
path: "/project/" + encodeURIComponent(projectId), path: "/project/" + encodeURIComponent(projectId),
}; };
this.$router.push(route); this.$router.push(route);
this.loadProject(projectId, this.activeDid); this.loadProject(projectId, await this.getIdentity(this.activeDid));
} }
getOpenStreetMapUrl() { getOpenStreetMapUrl() {
@@ -838,13 +697,8 @@ export default class ProjectViewView extends Vue {
); );
} }
openGiftDialog(contact?: GiverReceiverInputInfo) { openGiftDialog(contact?: GiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open( (this.$refs.customGiveDialog as GiftedDialog).open(contact);
contact,
undefined,
undefined,
"Given by " + (contact?.name || "someone not named"),
);
} }
openOfferDialog() { openOfferDialog() {
@@ -854,7 +708,7 @@ export default class ProjectViewView extends Vue {
onClickAllContactsGifting() { onClickAllContactsGifting() {
localStorage.setItem("projectId", this.projectId); localStorage.setItem("projectId", this.projectId);
const route = { const route = {
name: "contact-gift", name: "contact-gives",
}; };
this.$router.push(route); this.$router.push(route);
} }
@@ -882,15 +736,10 @@ export default class ProjectViewView extends Vue {
claim: offer.fullClaim, claim: offer.fullClaim,
issuer: offer.offeredByDid, issuer: offer.offeredByDid,
}; };
const giver: GiverReceiverInputInfo = { const giver: GiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord), did: libsUtil.offerGiverDid(offerRecord),
}; };
(this.$refs.customGiveDialog as GiftedDialog).open( (this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId);
giver,
undefined,
offer.handleId,
"Given by " + (giver?.name || "someone not named"),
);
} }
// return an HTTPS URL if it's not a global URL // return an HTTPS URL if it's not a global URL
@@ -931,68 +780,55 @@ export default class ProjectViewView extends Vue {
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid); return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
} }
confirmConfirmClaim(give: GiveSummaryRecord) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim(give);
},
},
-1,
);
}
// similar code is found in ClaimView // similar code is found in ClaimView
async confirmClaim(give: GiveSummaryRecord) { async confirmClaim(give: GiveSummaryRecord) {
// similar logic is found in endorser-mobile if (confirm("Do you personally confirm that this is true?")) {
const goodClaim = serverUtil.removeSchemaContext( // similar logic is found in endorser-mobile
serverUtil.removeVisibleToDids( const goodClaim = serverUtil.removeSchemaContext(
serverUtil.addLastClaimOrHandleAsIdIfMissing( serverUtil.removeVisibleToDids(
give.fullClaim, serverUtil.addLastClaimOrHandleAsIdIfMissing(
give.jwtId, give.fullClaim,
give.handleId, give.jwtId,
give.handleId,
),
), ),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
); );
} else { const confirmationClaim: serverUtil.GenericVerifiableCredential = {
console.error("Got error submitting the confirmation:", result); "@context": "https://schema.org",
const message = "@type": "AgreeAction",
(result.error?.error as string) || object: goodClaim,
"There was a problem submitting the confirmation. See logs for more info."; };
this.$notify( const result = await serverUtil.createAndSubmitClaim(
{ confirmationClaim,
group: "alert", await this.getIdentity(this.activeDid),
type: "danger", this.apiServer,
title: "Error", this.axios,
text: message,
},
5000,
); );
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation. See logs for more info.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
} }
} }
} }

View File

@@ -62,8 +62,8 @@
<!-- New Project --> <!-- New Project -->
<button <button
v-if="isRegistered && showProjects" v-if="showProjects"
class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full" class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()" @click="onClickNewProject()"
> >
<fa icon="plus" class="fa-fw"></fa> <fa icon="plus" class="fa-fw"></fa>
@@ -79,13 +79,6 @@
<!-- Offer Results List --> <!-- Offer Results List -->
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData"> <InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
<div v-if="offers.length === 0" class="text-center py-4">
You have not offered anything.
<br />
<router-link to="/discover" class="text-blue-600">
Look for projects worth some of your time.
</router-link>
</div>
<ul class="border-t border-slate-300"> <ul class="border-t border-slate-300">
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
@@ -93,19 +86,19 @@
:key="offer.handleId" :key="offer.handleId"
> >
<div class="block py-4 flex gap-4"> <div class="block py-4 flex gap-4">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none"> <div v-if="offer.fulfillsPlanHandleId" class="flex-none w-12">
<ProjectIcon <ProjectIcon
:entityId="offer.fulfillsPlanHandleId" :entityId="offer.fulfillsPlanHandleId"
:iconSize="48" :iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12" class="inline-block align-middle border border-slate-300 rounded-md"
/> ></ProjectIcon>
</div> </div>
<div v-if="offer.recipientDid" class="flex-none w-12"> <div v-if="offer.recipientDid" class="flex-none w-12">
<EntityIcon <EntityIcon
:entityId="offer.recipientDid" :entityId="offer.recipientDid"
:iconSize="48" :iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md" class="inline-block align-middle border border-slate-300 rounded-md"
/> ></EntityIcon>
</div> </div>
<div> <div>
@@ -184,16 +177,8 @@
</li> </li>
</ul> </ul>
</InfiniteScroll> </InfiniteScroll>
<!-- Project Results List --> <!-- Project Results List -->
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData"> <InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
<div v-if="projects.length === 0" class="text-center py-4">
You have not announced any projects.
<br />
Hit the big
<fa icon="plus" class="bg-blue-600 text-white px-1 py-1 rounded-full" />
button. You'll never know until you try.
</div>
<ul class="border-t border-slate-300"> <ul class="border-t border-slate-300">
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
@@ -204,13 +189,12 @@
@click="onClickLoadProject(project.handleId)" @click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4" class="block py-4 flex gap-4"
> >
<div class="flex-none"> <div class="flex-none w-12">
<ProjectIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
:imageUrl="project.image" class="inline-block align-middle border border-slate-300 rounded-md"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12" ></ProjectIcon>
/>
</div> </div>
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
@@ -227,22 +211,19 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosRequestConfig } from "axios";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { IIdentifier } from "@veramo/core";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import ProjectIcon from "@/components/ProjectIcon.vue"; import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { import { OfferSummaryRecord, PlanData } from "@/libs/endorserServer";
getHeaders,
OfferSummaryRecord,
PlanData,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
@Component({ @Component({
@@ -250,18 +231,11 @@ import EntityIcon from "@/components/EntityIcon.vue";
}) })
export default class ProjectsView extends Vue { export default class ProjectsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
activeDid = "";
apiServer = ""; apiServer = "";
projects: PlanData[] = []; projects: PlanData[] = [];
currentIid: IIdentifier;
isLoading = false; isLoading = false;
isRegistered = false;
numAccounts = 0; numAccounts = 0;
offers: OfferSummaryRecord[] = []; offers: OfferSummaryRecord[] = [];
showOffers = true; showOffers = true;
@@ -269,25 +243,44 @@ export default class ProjectsView extends Vue {
libsUtil = libsUtil; libsUtil = libsUtil;
async mounted() { /**
* 'created' hook runs when the Vue instance is first created
**/
async created() {
try { try {
await db.open(); await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || ""; const activeDid: string = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || ""; this.apiServer = (settings?.apiServer as string) || "";
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("No accounts found."); console.error("No accounts found.");
this.errNote("You need an identifier to load your projects."); this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You need an identifier to load your projects.",
},
-1,
);
} else { } else {
this.currentIid = await this.getIdentity(activeDid);
await this.loadOffers(); await this.loadOffers();
} }
} catch (err) { } catch (err) {
console.error("Error initializing:", err); console.error("Error initializing:", err);
this.errNote("Something went wrong loading your projects."); this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
} }
} }
@@ -296,23 +289,20 @@ export default class ProjectsView extends Vue {
* @param url the url used to fetch the data * @param url the url used to fetch the data
* @param token Authorization token * @param token Authorization token
**/ **/
async projectDataLoader(url: string) { async projectDataLoader(url: string, token: string) {
const headers: { [key: string]: string } = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
try { try {
const headers = await getHeaders(this.activeDid);
this.isLoading = true; this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 || !resp.data.data) {
const plans: PlanData[] = resp.data.data; const plans: PlanData[] = resp.data.data;
for (const plan of plans) { for (const plan of plans) {
const { name, description, handleId, image, issuerDid, rowid } = plan; const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({ this.projects.push({ name, description, handleId, issuerDid, rowid });
name,
description,
image,
handleId,
issuerDid,
rowid,
});
} }
} else { } else {
console.error( console.error(
@@ -320,12 +310,28 @@ export default class ProjectsView extends Vue {
resp.status, resp.status,
resp.data, resp.data,
); );
this.errNote("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,
);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
console.error("Got error loading plans:", error.message || error); console.error("Got error loading plans:", error.message || error);
this.errNote("Got an error loading projects."); this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading projects.",
},
-1,
);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
@@ -339,7 +345,7 @@ export default class ProjectsView extends Vue {
if (this.projects.length > 0 && payload) { if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1]; const latestProject = this.projects[this.projects.length - 1];
await this.loadProjects( await this.loadProjects(
this.activeDid, this.currentIid,
`beforeId=${latestProject.rowid}`, `beforeId=${latestProject.rowid}`,
); );
} }
@@ -347,12 +353,30 @@ export default class ProjectsView extends Vue {
/** /**
* Load projects initially * Load projects initially
* @param issuerDid of the user * @param identifier of the user
* @param urlExtra additional url parameters in a string * @param urlExtra additional url parameters in a string
**/ **/
async loadProjects(activeDid?: string, urlExtra: string = "") { async loadProjects(identifier?: IIdentifier, urlExtra: string = "") {
const identity = identifier || this.currentIid;
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`; const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
await this.projectDataLoader(url); const token: string = await accessToken(identity);
await this.projectDataLoader(url, token);
}
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
} }
/** /**
@@ -390,13 +414,16 @@ export default class ProjectsView extends Vue {
* @param url the url used to fetch the data * @param url the url used to fetch the data
* @param token Authorization token * @param token Authorization token
**/ **/
async offerDataLoader(url: string) { async offerDataLoader(url: string, token: string) {
const headers = getHeaders(this.activeDid); const headers: { [key: string]: string } = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
try { try {
this.isLoading = true; this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 || !resp.data.data) {
this.offers = this.offers.concat(resp.data.data); this.offers = this.offers.concat(resp.data.data);
} else { } else {
console.error( console.error(
@@ -438,18 +465,20 @@ export default class ProjectsView extends Vue {
async loadMoreOfferData(payload: boolean) { async loadMoreOfferData(payload: boolean) {
if (this.offers.length > 0 && payload) { if (this.offers.length > 0 && payload) {
const latestOffer = this.offers[this.offers.length - 1]; const latestOffer = this.offers[this.offers.length - 1];
await this.loadOffers(this.activeDid, `&beforeId=${latestOffer.jwtId}`); await this.loadOffers(this.currentIid, `&beforeId=${latestOffer.jwtId}`);
} }
} }
/** /**
* Load offers initially * Load offers initially
* @param issuerDid of the user * @param identifier of the user
* @param urlExtra additional url parameters in a string * @param urlExtra additional url parameters in a string
**/ **/
async loadOffers(issuerDid?: string, urlExtra: string = "") { async loadOffers(identifier?: IIdentifier, urlExtra: string = "") {
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${issuerDid}${urlExtra}`; const identity = identifier || this.currentIid;
await this.offerDataLoader(url); const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${identity.did}${urlExtra}`;
const token: string = await accessToken(identity);
await this.offerDataLoader(url, token);
} }
public computedOfferTabClassNames() { public computedOfferTabClassNames() {

View File

@@ -97,7 +97,6 @@ export default class QuickActionBvcBeginView extends Vue {
todayOrPreviousStartDate = ""; todayOrPreviousStartDate = "";
async mounted() { async mounted() {
// use the time zone for Bountiful
let currentOrPreviousSat = DateTime.now().setZone("America/Denver"); let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
if (currentOrPreviousSat.weekday < 6) { if (currentOrPreviousSat.weekday < 6) {
// it's not Saturday or Sunday, // it's not Saturday or Sunday,
@@ -124,8 +123,7 @@ export default class QuickActionBvcBeginView extends Vue {
try { try {
const hoursNum = libsUtil.numberOrZero(this.hoursStr); const hoursNum = libsUtil.numberOrZero(this.hoursStr);
const identity = await libsUtil.getIdentity(activeDid);
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
// first send the claim for time given // first send the claim for time given
let timeSuccess = false; let timeSuccess = false;
@@ -133,7 +131,7 @@ export default class QuickActionBvcBeginView extends Vue {
const timeResult = await createAndSubmitGive( const timeResult = await createAndSubmitGive(
axios, axios,
apiServer, apiServer,
activeDid, identity,
activeDid, activeDid,
undefined, undefined,
undefined, undefined,
@@ -154,7 +152,7 @@ export default class QuickActionBvcBeginView extends Vue {
timeResult?.error?.userMessage || timeResult?.error?.userMessage ||
"There was an error sending the time.", "There was an error sending the time.",
}, },
5000, -1,
); );
} }
} }
@@ -164,7 +162,7 @@ export default class QuickActionBvcBeginView extends Vue {
if (this.attended) { if (this.attended) {
const attendResult = await createAndSubmitClaim( const attendResult = await createAndSubmitClaim(
bvcMeetingJoinClaim(activeDid, this.todayOrPreviousStartDate), bvcMeetingJoinClaim(activeDid, this.todayOrPreviousStartDate),
activeDid, identity,
apiServer, apiServer,
axios, axios,
); );
@@ -181,7 +179,7 @@ export default class QuickActionBvcBeginView extends Vue {
attendResult?.error?.userMessage || attendResult?.error?.userMessage ||
"There was an error sending the attendance.", "There was an error sending the attendance.",
}, },
5000, -1,
); );
} }
} }
@@ -200,7 +198,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Success", title: "Success",
text: actions, text: actions,
}, },
3000, -1,
); );
} }
@@ -212,9 +210,9 @@ export default class QuickActionBvcBeginView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: error.userMessage || "There was an error sending the claims.", text: error.userMessage || "There was an error sending claims.",
}, },
5000, -1,
); );
} }
} }

View File

@@ -82,16 +82,6 @@
page. page.
</span> </span>
</div> </div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
<span>
{{
claimCountByUser === 1
? "There is 1 other claim by you"
: `There are ${claimCountByUser} other claims by you`
}}
which you don't need to confirm.
</span>
</div>
<div> <div>
<h2 class="text-2xl m-2">Anything else?</h2> <h2 class="text-2xl m-2">Anything else?</h2>
@@ -138,25 +128,28 @@
import axios from "axios"; import axios from "axios";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { import {
BVC_MEETUPS_PROJECT_CLAIM_ID, BVC_MEETUPS_PROJECT_CLAIM_ID,
claimSpecialDescription, claimSpecialDescription,
containsHiddenDid, containsHiddenDid,
createAndSubmitConfirmation, createAndSubmitConfirmation,
createAndSubmitGive, createAndSubmitGive,
ErrorResult,
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
getHeaders,
ErrorResult,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
@Component({ @Component({
methods: { claimSpecialDescription }, methods: { claimSpecialDescription },
@@ -172,7 +165,6 @@ export default class QuickActionBvcBeginView extends Vue {
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
claimCountByUser = 0;
claimCountWithHidden = 0; claimCountWithHidden = 0;
claimsToConfirm: GenericCredWrapper[] = []; claimsToConfirm: GenericCredWrapper[] = [];
claimsToConfirmSelected: string[] = []; claimsToConfirmSelected: string[] = [];
@@ -210,7 +202,16 @@ export default class QuickActionBvcBeginView extends Vue {
await accountsDB.open(); await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did); this.allMyDids = allAccounts.map((acc) => acc.did);
const headers = await getHeaders(this.activeDid); const account: Account | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
const identity: IIdentifier = JSON.parse(
(account?.identity as string) || "null",
);
const headers = {
Authorization: "Bearer " + (await accessToken(identity)),
};
try { try {
const response = await fetch( const response = await fetch(
this.apiServer + this.apiServer +
@@ -222,7 +223,7 @@ export default class QuickActionBvcBeginView extends Vue {
); );
if (!response.ok) { if (!response.ok) {
console.error("Bad response", response); console.log("Bad response", response);
throw new Error("Bad response when retrieving claims."); throw new Error("Bad response when retrieving claims.");
} }
await response.json().then((data) => { await response.json().then((data) => {
@@ -235,7 +236,6 @@ export default class QuickActionBvcBeginView extends Vue {
dataByOthers, dataByOthers,
); );
this.claimsToConfirm = dataByOthersWithoutHidden; this.claimsToConfirm = dataByOthersWithoutHidden;
this.claimCountByUser = data.length - dataByOthers.length;
this.claimCountWithHidden = this.claimCountWithHidden =
dataByOthers.length - dataByOthersWithoutHidden.length; dataByOthers.length - dataByOthersWithoutHidden.length;
}); });
@@ -248,7 +248,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Error", title: "Error",
text: "There was an error retrieving today's claims to confirm.", text: "There was an error retrieving today's claims to confirm.",
}, },
5000, -1,
); );
} }
this.loadingConfirms = false; this.loadingConfirms = false;
@@ -263,7 +263,7 @@ export default class QuickActionBvcBeginView extends Vue {
async record() { async record() {
try { try {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); const identity = await libsUtil.getIdentity(this.activeDid);
// in parallel, make a confirmation for each selected claim and send them all to the server // in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults = await Promise.allSettled( const confirmResults = await Promise.allSettled(
@@ -274,8 +274,9 @@ export default class QuickActionBvcBeginView extends Vue {
if (!record) { if (!record) {
return { type: "error", error: "Record not found." }; return { type: "error", error: "Record not found." };
} }
const identity = await libsUtil.getIdentity(this.activeDid);
return createAndSubmitConfirmation( return createAndSubmitConfirmation(
this.activeDid, identity,
record.claim as GenericVerifiableCredential, record.claim as GenericVerifiableCredential,
record.id, record.id,
record.handleId, record.handleId,
@@ -299,7 +300,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Error", title: "Error",
text: `There was an error sending ${howMany} of the confirmations.`, text: `There was an error sending ${howMany} of the confirmations.`,
}, },
5000, -1,
); );
} }
@@ -309,7 +310,7 @@ export default class QuickActionBvcBeginView extends Vue {
const giveResult = await createAndSubmitGive( const giveResult = await createAndSubmitGive(
axios, axios,
this.apiServer, this.apiServer,
this.activeDid, identity,
undefined, undefined,
this.activeDid, this.activeDid,
this.description, this.description,
@@ -329,7 +330,7 @@ export default class QuickActionBvcBeginView extends Vue {
(giveResult as ErrorResult)?.error?.userMessage || (giveResult as ErrorResult)?.error?.userMessage ||
"There was an error sending that give.", "There was an error sending that give.",
}, },
5000, -1,
); );
} }
} }
@@ -354,7 +355,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Success", title: "Success",
text: actions, text: actions,
}, },
3000, -1,
); );
} }
@@ -368,7 +369,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Error", title: "Error",
text: error.userMessage || "There was an error sending claims.", text: error.userMessage || "There was an error sending claims.",
}, },
5000, -1,
); );
} }
} }

View File

@@ -22,8 +22,8 @@
</div> </div>
<div class="px-2 py-4"> <div class="px-2 py-4">
This location is only stored on your device. It is sometimes sent from This location is only stored on your device. It is used to show you more
your device to run searches but it is not stored on our servers. appropriate projects but is not stored on any servers.
</div> </div>
<div> <div>

View File

@@ -70,9 +70,12 @@ import * as R from "ramda";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
interface Account {
mnemonic: string;
}
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class SeedBackupView extends Vue { export default class SeedBackupView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;

View File

@@ -1,204 +0,0 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Image
</h1>
<div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
<div class="text-center mb-4">Choose how to use this image</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
@click="recordGift"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="gift" class="fa-fw" />
Record a Gift
</button>
<button
@click="recordProfile"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="circle-user" class="fa-fw" />
Save as Profile Image
</button>
<button
@click="cancel"
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="ban" class="fa-fw" />
Cancel
</button>
</div>
<PhotoDialog ref="photoDialog" />
</div>
<div class="flex justify-center">
<img
:src="URL.createObjectURL(imageBlob)"
alt="Shared Image"
class="rounded mt-4"
/>
</div>
</div>
<div v-else class="text-center mb-4">
<p>No image found.</p>
</div>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "@/components/PhotoDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import {
DEFAULT_IMAGE_API_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getHeaders } from "@/libs/endorserServer";
@Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid: string | undefined = undefined;
imageBlob: Blob | undefined = undefined;
imageFileName: string | undefined = undefined;
uploading = false;
URL = window.URL || window.webkitURL;
// 'created' hook runs when the Vue instance is first created
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
this.imageBlob = temp.blob;
// clear the temp image
db.temp.delete("shared-photo");
this.imageFileName = this.$route.query.fileName as string;
}
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading this data.",
},
-1,
);
}
}
async recordGift() {
await this.sendToImageServer("GiveAction").then((url) => {
if (url) {
this.$router.push({
name: "gifted-details",
query: {
destinationNameAfter: "home",
hideBackButton: true,
imageUrl: url,
recipientDid: this.activeDid,
},
});
}
});
}
recordProfile() {
(this.$refs.photoDialog as PhotoDialog).open(
async (imgUrl) => {
db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl,
});
this.$router.push({ name: "account" });
},
IMAGE_TYPE_PROFILE,
true,
this.imageBlob,
this.imageFileName,
);
}
async cancel() {
this.imageBlob = undefined;
this.imageFileName = undefined;
this.$router.push({ name: "home" });
}
async sendToImageServer(imageType: string) {
this.uploading = true;
let result;
try {
// send the image to the server
const headers = await getHeaders(this.activeDid);
const formData = new FormData();
formData.append(
"image",
this.imageBlob as Blob,
this.imageFileName as string,
);
formData.append("claimType", imageType);
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
if (response?.data?.url) {
this.imageBlob = undefined;
this.imageFileName = undefined;
result = response.data.url as string;
} else {
console.error("Problem uploading the image", response.data);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
"There was a problem saving the picture. " +
(response?.data?.message || ""),
},
5000,
);
}
this.uploading = false;
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture.",
},
5000,
);
this.uploading = false;
}
return result;
}
}
</script>

View File

@@ -17,7 +17,7 @@
<!-- Heading --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Generate an Identity Start Here
</h1> </h1>
</div> </div>
@@ -25,58 +25,33 @@
<div id="start-question" class="mt-8"> <div id="start-question" class="mt-8">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<p class="text-center text-xl font-light"> <p class="text-center text-xl font-light">
How do you want to create this identifier? Do you want a new identifier of your own?
</p> </p>
<p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6"> <p class="text-center font-light">
A <strong>passkey</strong> is easy to manage, though it is less If you haven't used this before, click "Yes" to generate a new
interoperable with other systems for advanced uses. identifier.
<a
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
target="_blank"
>
<fa icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p> </p>
<p class="text-center font-light mt-4"> <p class="text-center mb-4 font-light">
A <strong>new seed</strong> allows you full control over the keys, Only click "No" if you have a seed of 12 or 24 words generated
though you are responsible for backups. elsewhere.
<a
href="https://www.perplexity.ai/search/what-is-a-seed-phrase-OqiP9foVRXidr_2le5OFKA"
target="_blank"
>
<fa icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p> </p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4"> <a
<a @click="onClickYes()"
v-if="PASSKEYS_ENABLED" class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
@click="onClickNewPasskey()" >
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer" Yes, generate one
> </a>
Generate one with a passkey <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
</a>
<a
@click="onClickNewSeed()"
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
>
Generate one with a new seed
</a>
</div>
<p class="text-center font-light mt-4">
You can also import an existing seed or derive a new address from an
existing seed.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<a <a
@click="onClickNo()" @click="onClickNo()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
> >
You have a seed No, I have a seed
</a> </a>
<a <a
v-if="numAccounts > 0" v-if="numAccounts > 0"
@click="onClickDerive()" @click="onClickDerive()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
> >
Derive new address from existing seed Derive new address from existing seed
</a> </a>
@@ -88,41 +63,23 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { accountsDB } from "@/db/index";
import { AppString, PASSKEYS_ENABLED } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { registerSaveAndActivatePasskey } from "@/libs/util";
@Component({ @Component({
components: {}, components: {},
}) })
export default class StartView extends Vue { export default class StartView extends Vue {
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
givenName = "";
numAccounts = 0; numAccounts = 0;
async mounted() { async mounted() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.givenName = settings?.firstName || "";
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
} }
public onClickNewSeed() { public onClickYes() {
this.$router.push({ name: "new-identifier" }); this.$router.push({ name: "new-identifier" });
} }
public async onClickNewPasskey() {
const keyName =
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : "");
await registerSaveAndActivatePasskey(keyName);
this.$router.push({ name: "account" });
}
public onClickNo() { public onClickNo() {
this.$router.push({ name: "import-account" }); this.$router.push({ name: "import-account" });
} }

View File

@@ -21,8 +21,8 @@
</h1> </h1>
</div> </div>
<div> <div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2> <h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
<button <button
@click=" @click="
@@ -153,307 +153,13 @@
Notif OFF Notif OFF
</button> </button>
</div> </div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" />
<router-link
v-if="showFileNextStep()"
:to="{
name: 'shared-photo',
query: { fileName },
}"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Go to Shared Page
</router-link>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
See console for results.
<br />
See existing passkeys in Chrome at: chrome://settings/passkeys
<br />
Active DID: {{ activeDid || "nothing, which" }}
{{ credIdHex ? "has a passkey ID" : "has no passkey ID" }}
<div>
Register Passkey
<button
@click="register()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
</div>
<div>
Create JWT
<button
@click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Navigator
</button>
</div>
<div v-if="jwt">
Verify New JWT
<button
@click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
p256 - broken
</button>
</div>
<div v-else>Verify New JWT -- requires creation first</div>
<button
@click="verifyMyJwt()"
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
>
Verify Hard-Coded JWT
</button>
</div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Buffer } from "buffer/";
import { Base64URLString } from "@simplewebauthn/types";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { AppString, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as vcLib from "@/libs/crypto/vc";
import {
PeerSetup,
verifyJwtP256,
verifyJwtSimplewebauthn,
verifyJwtWebCrypto,
} from "@/libs/crypto/vc/passkeyDidPeer";
import {AccountKeyInfo, getAccount, registerAndSavePasskey} from "@/libs/util";
const inputFileNameRef = ref<Blob>();
const TEST_PAYLOAD = {
vc: {
credentialSubject: {
"@context": "https://schema.org",
"@type": "GiveAction",
description: "pizza",
},
},
};
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class Help extends Vue { export default class Help extends Vue {}
$notify!: (notification: NotificationIface, timeout?: number) => void;
// for file import
fileName?: string;
// for passkeys
credIdHex?: string;
activeDid?: string;
jwt?: string;
peerSetup?: PeerSetup;
userName?: string;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.userName = settings?.firstName as string;
await accountsDB.open();
const account: { identity?: string } | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
if (this.activeDid) {
if (account) {
this.credIdHex = account.passkeyCredIdHex as string;
} else {
alert("No account found for DID " + this.activeDid);
}
}
}
async uploadFile(event: Event) {
inputFileNameRef.value = event.target?.["files"][0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.fileName = file.name as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
await db.temp.update("shared-photo", { blob });
} else {
await db.temp.add({ id: "shared-photo", blob });
}
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
showFileNextStep() {
return !!inputFileNameRef.value;
}
public async register() {
const DEFAULT_USERNAME = AppString.APP_NAME + " Tester";
if (!this.userName) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "No Name",
text: "You should have a name to attach to this passkey. Would you like to enter your own name first?",
onNo: async () => {
this.userName = DEFAULT_USERNAME;
},
onYes: async () => {
this.$router.push({ name: "new-edit-account" });
},
noText: "try again and use " + DEFAULT_USERNAME,
},
-1,
);
return;
}
const account = await registerAndSavePasskey(
AppString.APP_NAME + " - " + this.userName,
);
this.activeDid = account.did;
this.credIdHex = account.passkeyCredIdHex;
}
public async createJwtSimplewebauthn() {
const account: AccountKeyInfo | undefined = await getAccount(
this.activeDid || "",
);
if (!vcLib.isFromPasskey(account)) {
alert(`The DID ${this.activeDid} is not passkey-enabled.`);
return;
}
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtSimplewebauthn(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("simple jwt4url", this.jwt);
}
public async createJwtNavigator() {
const account: AccountKeyInfo | undefined = await getAccount(
this.activeDid || "",
);
if (!vcLib.isFromPasskey(account)) {
alert(`The DID ${this.activeDid} is not passkey-enabled.`);
return;
}
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtNavigator(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("lower jwt4url", this.jwt);
}
public async verifyP256() {
const decoded = await verifyJwtP256(
this.credIdHex as string,
this.activeDid as string,
this.peerSetup?.authenticatorData as ArrayBuffer,
this.peerSetup?.challenge as Uint8Array,
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup?.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifySimplewebauthn() {
const decoded = await verifyJwtSimplewebauthn(
this.credIdHex as string,
this.activeDid as string,
this.peerSetup?.authenticatorData as ArrayBuffer,
this.peerSetup?.challenge as Uint8Array,
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup?.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyWebCrypto() {
const decoded = await verifyJwtWebCrypto(
this.credIdHex as string,
this.activeDid as string,
this.peerSetup?.authenticatorData as ArrayBuffer,
this.peerSetup?.challenge as Uint8Array,
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup?.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyMyJwt() {
const did =
"did:peer:0zKMFjvUgYrM1hXwDciYHiA9MxXtJPXnRLJvqoMNAKoDLX9pKMWLb3VDsgua1p2zW1xXRsjZSTNsfvMnNyMS7dB4k7NAhFwL3pXBrBXgyYJ9ri";
const jwt =
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
const pieces = jwt.split(".");
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
const clientJSON = Buffer.from(
payload["ClientDataJSONB64URL"],
"base64",
).toString();
const clientData = JSON.parse(clientJSON);
const challenge = clientData.challenge;
const signatureB64URL = pieces[2];
const decoded = await verifyJwtWebCrypto(
this.credIdHex as string,
did,
authData,
challenge,
payload["ClientDataJSONB64URL"],
signatureB64URL,
);
console.log("decoded", decoded);
}
}
</script> </script>

View File

@@ -81,7 +81,7 @@ self.addEventListener("push", function (event) {
} else { } else {
title = payload.title || "Update"; title = payload.title || "Update";
} }
// getNotificationCount is injected from safari-notifications.js at build time by the sw_combine.js script // getNotificationCount is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
message = await getNotificationCount(); message = await getNotificationCount();
} }
@@ -112,51 +112,8 @@ self.addEventListener("message", (event) => {
logConsoleAndDb("Service worker posted a message."); logConsoleAndDb("Service worker posted a message.");
}); });
self.addEventListener("notificationclick", (event) => {
logConsoleAndDb("Notification got clicked.", event);
event.notification.close();
// from https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event
// ... though I don't see any benefit over just "clients.openWindow"
event.waitUntil(
clients
.matchAll({
type: "window",
})
.then((clientList) => {
for (const client of clientList) {
if (client.url === "/" && "focus" in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow("/");
}),
);
});
// This is invoked when the user chooses this as a share_target, mapped to share-target in the manifest.
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
logConsoleAndDb("Service worker got fetch event.", event); logConsoleAndDb("Service worker got fetch event.", event);
// Bypass any regular requests not related to Web Share Target
// and also requests that are not exactly to the timesafari.app
// (note that Chrome will send subdomain requests like image-api.timesafari.app through this service worker).
if (
event.request.method !== "POST" ||
!event.request.url.endsWith("/share-target")
) {
event.respondWith(fetch(event.request));
return;
}
// Requests related to Web Share share-target Target.
event.respondWith(
(async () => {
const formData = await event.request.formData();
const photo = formData.get("photo");
// savePhoto is injected from safari-notifications.js at build time by the sw_combine.js script
// eslint-disable-next-line no-undef
await savePhoto(photo);
return Response.redirect("/shared-photo", 303);
})(),
);
}); });
self.addEventListener("error", (event) => { self.addEventListener("error", (event) => {

View File

@@ -566,20 +566,6 @@ async function getNotificationCount() {
return result; return result;
} }
// Store the image blob and go immediate to a page to upload it.
// @param photo - image Blob to store for later retrieval after redirect
async function savePhoto(photo) {
try {
const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("temp", "readwrite");
const store = transaction.objectStore("temp");
await updateRecord(store, { id: "shared-photo", blob: photo });
transaction.oncomplete = () => db.close();
} catch (error) {
console.error("safari-notifications logMessage IndexedDB error", error);
}
}
self.appendDailyLog = appendDailyLog; self.appendDailyLog = appendDailyLog;
self.getNotificationCount = getNotificationCount; self.getNotificationCount = getNotificationCount;
self.decodeBase64 = decodeBase64; self.decodeBase64 = decodeBase64;

View File

@@ -1,51 +1,16 @@
import * as path from 'path'; import { defineConfig } from "vite";
import { defineConfig } from 'vite'; import vue from "@vitejs/plugin-vue";
import { VitePWA } from 'vite-plugin-pwa'; import * as path from "path";
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
server: { server: {
port: 8080 port: 8080
}, },
plugins: [ plugins: [ vue() ],
vue(),
VitePWA({
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: '.',
filename: 'sw_scripts-combined.js',
manifest: {
// This is used for the app name. It doesn't include a space, because iOS complains if i recall correctly.
// There is a name with spaces in the constants/app.js file for use internally.
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
// 192x192 and 512x512 are important for Chrome to show that it's installable
"icons":[
{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},
{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},
{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},
{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}
],
share_target: {
action: '/share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
files: [
{
name: 'photo',
accept: ['image/*'],
},
],
},
},
},
}),
],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), "@": path.resolve(__dirname, "./src"),
buffer: path.resolve(__dirname, 'node_modules', 'buffer'), buffer: path.resolve(__dirname, 'node_modules', 'buffer'),
'dexie-export-import/dist/import': 'dexie-export-import/dist/import/index.js', 'dexie-export-import/dist/import': 'dexie-export-import/dist/import/index.js',
}, },

17
vue.config.js Normal file
View File

@@ -0,0 +1,17 @@
const TIME_SAFARI_APP_TITLE =
import.meta.env.VITE_TIME_SAFARI_APP_TITLE || require("./package.json").name;
module.exports = defineConfig({
pwa: {
name: TIME_SAFARI_APP_TITLE,
iconPaths: {
faviconSVG: "img/icons/safari-pinned-tab.svg",
},
workboxPluginMode: "InjectManifest",
workboxOptions: {
// this script will be checked for linting (sw_scripts/* files generate about 1000 linting errors)
swSrc: "./sw_scripts-combined.js",
},
},
});