Compare commits

...

86 Commits

Author SHA1 Message Date
4270374a67 create an identifier by default, while letting them choose if passkeys are enabled 2024-07-19 20:49:43 -06:00
b2ebc2992b cache the passkey JWANT access token for multiple signatures 2024-07-19 12:44:54 -06:00
cd0a31e6f5 remove remaining getIdentity calls & fix QR code for did:peer 2024-07-15 20:47:10 -06:00
f7f38789d2 reword some things in help 2024-07-15 19:11:12 -06:00
f4f762b31c add BTC donation address 2024-07-15 17:18:22 -06:00
f6338c05ee move low-level DID-related create & decode into separate folder (#120)
Co-authored-by: Trent Larson <trent@trentlarson.com>
Reviewed-on: #120
Co-authored-by: trentlarson <trent@trentlarson.com>
Co-committed-by: trentlarson <trent@trentlarson.com>
2024-07-13 13:24:54 -04:00
d1d6bf51b8 Merge pull request 'Refactor JWT-creation calls through single function' (#119) from passkey-all into master
Reviewed-on: #119
2024-07-11 22:32:30 -04:00
f46a60b5dd change first page back to prompts without passkey 2024-07-11 19:54:20 -06:00
11163dfad9 consolidate getIdentity & remove dups 2024-07-11 19:43:56 -06:00
7cb9e2aa52 replace remaining didJwt.createJwt calls with one that checks for did:peer 2024-07-11 19:35:17 -06:00
145a1da37e linting cleanup 2024-07-09 19:42:55 -06:00
bce003e508 change accessToken to take a DID 2024-07-09 19:20:05 -06:00
45f0a14661 add expiration inside JWANT & refactor getHeaders to move toward supporting did:peer 2024-07-09 17:56:48 -06:00
42fde503e3 make a passkey-generator in start & home pages, and make that the default 2024-07-06 19:12:31 -06:00
6b65e31649 misc tweaks and linting clean-up 2024-07-06 13:04:15 -06:00
9677a344c2 misc syntactic & type-checking clean-up 2024-07-06 07:15:46 -06:00
e4a5629cff allow deletion of an identity 2024-07-05 19:37:45 -06:00
c4125822cb show a loading indicator on the claim-confirmation screen 2024-07-01 17:55:21 -06:00
6f2da589b1 fill in the "Load More" links for plan linkages 2024-06-30 20:10:18 -06:00
1ebfc997eb add section for gives provided by a plan 2024-06-30 20:06:47 -06:00
dea3f78173 fix type of the raw claim sent 2024-06-29 13:32:13 -06:00
053ee4a748 add advanced page & flag for editing raw claims, and fix recipient assignment in detail screen 2024-06-29 10:18:56 -06:00
9c7b138d06 modify & explain icons next to feed 2024-06-25 11:04:40 -04:00
b34e7daddf refactor display logic a bit (no flow changes intended) 2024-06-25 11:04:40 -04:00
4cb434fd5d passkey test (#116)
Co-authored-by: Trent Larson <trent@trentlarson.com>
Reviewed-on: #116
Co-authored-by: trentlarson <trent@trentlarson.com>
Co-committed-by: trentlarson <trent@trentlarson.com>
2024-06-24 22:21:24 -04:00
1639e7cf25 move & resize the contact edit & info buttons 2024-06-22 12:34:30 -06:00
8f2bebe8ae bump version and add "-beta" 2024-06-22 12:23:57 -06:00
810f307442 bump to v 0.3.14 2024-06-22 12:23:10 -06:00
a4bdd2e922 fix checkbox verbiage when no project is chosen for a give 2024-06-22 12:06:55 -06:00
08e1ce6486 fix prompt for already-registered contacts (plus some verbiage) 2024-06-22 11:47:10 -06:00
e88eea7f36 add BX currency, add link for user's activity, tweak verbiage 2024-06-21 20:33:44 -06:00
ea156fac13 improve messaging when user has no offers or projects 2024-06-21 19:52:35 -06:00
a95d5db24a fix justification of checkboxes and text so they don't move 2024-06-21 19:25:46 -06:00
453256f874 give-detail page: add more-correct parameters from confirm-give page, and allow toggling of project & user-recipient 2024-06-21 19:13:19 -06:00
7bf488d4fe tweak UI for give-confirmation screen 2024-06-21 16:02:08 -06:00
230773a917 add Confirm Gift screen for simpler confirmation 2024-06-20 20:52:26 -06:00
79d93994c2 fix dependency vulnerabilities 2024-05-24 11:42:36 -06:00
bab4a62540 bump version and add -beta; enhance help 2024-05-24 10:21:08 -06:00
f84a2c2750 bump to verson 0.3.13 2024-05-24 10:19:17 -06:00
2321e1d6e8 allow link to the large version of a project image 2024-05-24 09:11:20 -06:00
af976ba838 add an image to projects (which shows on all ProjectIcons except for offers) 2024-05-23 20:51:40 -06:00
d08541fdae bump version and add -beta 2024-05-20 08:27:12 -06:00
fa92beed27 update CHANGELOG 2024-05-19 20:03:30 -06:00
9e1ae2abe5 bump to version 0.3.12 2024-05-19 19:57:02 -06:00
ad39ea05c2 fix the photo share_target, and tweak other verbiage 2024-05-19 19:56:25 -06:00
151c8154c4 bump version and add -beta 2024-05-19 19:32:38 -06:00
21a6348afc add a global error handler 2024-05-19 16:25:44 -06:00
210605c8e4 bump to version 0.3.11 (and enhance warning on profile deletion) 2024-05-19 08:39:18 -06:00
33a340326f set the correct active camera number when it starts 2024-05-17 20:24:33 -06:00
3f8596aacc bump version and add -beta 2024-05-17 12:16:23 -06:00
fd112bd447 allow any image URL for gifts & profiles 2024-05-12 21:43:18 -06:00
7d6b210ee1 allow file choice for gift, plus other UI fixes 2024-05-12 17:55:54 -06:00
6c28828c0a fix cropping problem where long images go off the screen 2024-05-12 12:39:16 -06:00
6af239378c bump to v 0.3.10, fix image upload on Chrome 2024-05-12 12:12:59 -06:00
4ff7d908d4 Merge pull request 'add a share_target for people to add a photo' (#115) from share-photo into master
Reviewed-on: #115
2024-05-11 20:03:33 -04:00
17c901b1de add file-chooser to the profile image selection 2024-05-11 12:30:10 -06:00
f7b5dbf4ce style the sharing screen (plus other fixes) 2024-05-11 07:09:48 -06:00
7f02ba29a3 add a share_target for people to add a photo 2024-05-10 13:17:20 -06:00
20c4613533 increment version and add "-beta" 2024-04-28 20:10:39 -06:00
a44fc1d6d0 bump to version 0.3.9 2024-04-28 20:09:56 -06:00
b86543b404 disallow new-project page if not registered 2024-04-28 19:16:29 -06:00
7d0007e4d9 remove verbiage on front page that's now extra 2024-04-28 19:04:44 -06:00
ddd32e7f44 show something to indicate claims were sent (mostly in BVC screens) 2024-04-28 18:36:06 -06:00
8a9bb100ea constantly recheck on home screen if not registered 2024-04-28 17:02:31 -06:00
c48b8246f9 add registration inside contact import, with flag to hide it 2024-04-28 16:18:30 -06:00
b32a3d85e9 add 'registered' flag in contact info 2024-04-28 13:12:26 -06:00
8571c78a53 for scan on QR code screen, import and keep on that screen 2024-04-27 20:33:10 -06:00
eba68e2aaa add tweaks to testing instructions 2024-04-27 14:59:23 -06:00
e2df848e96 add page to view all claims about a DID (which we'll have to restrict to visible people soon) 2024-04-26 20:13:44 -06:00
9acba28b85 fix problem with duplicates in feed, plus some other UI tweaks 2024-04-26 17:05:11 -06:00
bef56fce10 allow loading more gives & offers & plans when limits are hit on project view 2024-04-26 15:44:09 -06:00
fccc4edb63 remove some 'uppercase' CSS markers 2024-04-25 20:17:49 -06:00
0a42edf595 put button directly on contacts page to show the given totals 2024-04-24 20:38:34 -06:00
f4f5fc7730 change remainder of "confirm" calls to better UX 2024-04-24 20:11:38 -06:00
eeaacaf202 replace many of the javascript "confirm" calls with the nicer UX version 2024-04-24 19:52:33 -06:00
d9aebfebd3 remove 'moment' library that's no longer used 2024-04-24 18:56:09 -06:00
7078f7b9e6 add choice of a start date for a project 2024-04-23 20:48:38 -06:00
d316f4924b add note about confirming your own, plus other helpful verbiage, plus notify messages that don't linger 2024-04-23 09:13:57 -06:00
1df2d3ed05 remove message confusion, add project name during give-details 2024-04-21 20:31:57 -06:00
4e877c15f6 change the "give" action on contact page to use dialog box 2024-04-21 16:42:22 -06:00
ef95708d02 add 'offer' on contact screen 2024-04-21 07:38:59 -06:00
7cbdc7a099 add code to display profiles in feed, but deactivate it for now 2024-04-20 19:53:11 -06:00
c748869c44 increment version and add "-beta" 2024-04-20 08:14:53 -06:00
60e11e23d4 bump to v 0.3.8 2024-04-20 08:06:34 -06:00
883687f1c3 make so cropping isn't behind header; delete profile image from storage when deleted 2024-04-19 20:13:44 -06:00
4466ceed99 Merge pull request 'profile-pic' (#114) from profile-pic into master
Reviewed-on: #114
2024-04-19 17:36:53 -04:00
57 changed files with 7689 additions and 3409 deletions

View File

@@ -5,12 +5,73 @@ 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 ### Changed in DB or environment
- Nothing - Nothing
## [0.3.7] - 2024.04-10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b ## [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 ### Added
- Filter on home page feed - Filter on home page feed
- Ability to set time of daily notification - Ability to set time of daily notification

View File

@@ -10,44 +10,44 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup ## Setup
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment. We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
``` ```
npm install npm install
``` ```
### Compiles and hot-reloads for development ### Compile and hot-reloads for development
``` ```
npm run dev npm run dev
``` ```
### Builds the production app ### Build the test & production app
``` ```
npm run serve npm run serve
``` ```
### Lints and fixes files ### Lint and fix files
``` ```
npm run lint npm run lint
``` ```
### Compiles and minifies for production ### Compile and minify for test & production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations. * 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 project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`. * Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* 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.) # (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there. # 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 npm run build TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app PASSKEYS_ENABLED=yep npm run build
``` ```
* Production * Production
@@ -95,9 +95,9 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
### Manual walk-through test ### Manual walk-through test
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act.
- Use a mobile user as well as a desktop user.
- Backup seed & data & get a CSV dump from Endorser Mobile. - Backup seed & data & get a CSV dump from Endorser Mobile.
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
- Use a mobile user as well as a desktop user.
- Check that the version is updated. - 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').
@@ -109,15 +109,19 @@ 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 User #0 even without their own ID. - On the contacts page, check that they can add a contact even without their own ID.
- As User #0 in another browser on the test API, add a give & a project. - Install the PWA.
- `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 User 0 in another browser on the test API, add a give & a project.
- With the new user on the home page, see the feed that shows User #0 in network but without the name. - Note that some combinations of desktop with mobile emulation stretch the image.
- As the new user on the contacts page, add User #0 as a contact. - 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`
- On the home page, see the feed that shows User #0 with a name. - Add new user as a contact (which allows them to see User 0).
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
- As the new user, import contacts & identifiers.
- As the new user on the contacts page, add User 0 as a contact.
- On the home page, see the feed that shows User 0 with a name.
- Switch back to the generated identifier. - 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.
@@ -125,6 +129,7 @@ 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

2468
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.8-beta", "version": "0.3.15-beta",
"private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
@@ -17,20 +16,25 @@
"@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",
@@ -45,7 +49,6 @@
"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",
@@ -68,7 +71,9 @@
"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",

View File

@@ -129,7 +129,10 @@
</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
@@ -143,13 +146,19 @@
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"
@@ -158,9 +167,10 @@
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">
<p class="text-lg mb-4"> <span class="font-semibold text-lg">
{{ notification.title }} {{ notification.title }}
</p> </span>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button <button
v-if="notification.onYes" v-if="notification.onYes"
@@ -171,10 +181,55 @@
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
@click="close(notification.id)" v-if="notification.onNo"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No {{ notification.noText ? ", " + notification.noText : "" }}
</button>
<label
v-if="notification.promptToStopAsking && notification.onNo"
for="toggleStopAsking"
class="flex items-center justify-between cursor-pointer my-4"
@click="stopAsking = !stopAsking"
>
<!-- label -->
<span class="ml-2">... and do not ask again.</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
name="stopAsking"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<button
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" 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" }}
@@ -358,6 +413,7 @@ 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

@@ -17,9 +17,16 @@ export default class EntityIcon extends Vue {
generateIcon() { generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl; const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) { if (imageUrl) {
return `<img src="${imageUrl}" alt="avatar" width="${this.iconSize}" height="${this.iconSize}" />`; return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} else { } else {
const identifier = this.contact?.did || this.entityId; const identifier = this.contact?.did || this.entityId;
if (!identifier) {
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = { const options: StyleOptions<object> = {
seed: (identifier as string) || "", seed: (identifier as string) || "",
size: this.iconSize, size: this.iconSize,

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">
{{ message }} {{ giver?.name || "somebody not named" }} {{ customTitle }}
</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 received" placeholder="What was given"
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] }} {{ libsUtil.UNIT_SHORT[unitCode] || 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,15 +45,16 @@
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, ... Photo & Details ...
</router-link> </router-link>
</span> </span>
</div> </div>
@@ -90,7 +91,7 @@ import { NotificationIface } from "@/constants/app";
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
GiverInputInfo, GiverReceiverInputInfo,
} 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";
@@ -101,9 +102,7 @@ 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> = [];
@@ -111,22 +110,32 @@ export default class GiftedDialog extends Vue {
apiServer = ""; apiServer = "";
amountInput = "0"; amountInput = "0";
callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string;
description = ""; description = "";
givenToUser = false; giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
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(giver?: GiverInputInfo, offerId?: string) { async open(
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 {
@@ -141,7 +150,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.name) { if (this.giver && !this.giver.name) {
this.giver.name = didInfo( this.giver.name = didInfo(
this.giver.did, this.giver.did,
this.activeDid, this.activeDid,
@@ -196,7 +205,6 @@ 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";
} }
@@ -254,6 +262,7 @@ 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,
@@ -265,26 +274,27 @@ 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 amountInput may be 0 * @param amount may be 0
* @param unitCode may be omitted, defaults to "HUR" * @param unitCode may be omitted, defaults to "HUR"
*/ */
public async recordGive( async recordGive(
giverDid: string | null, giverDid: string | null,
recipientDid: string | null,
description: string, description: string,
amountInput: number, amount: 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,
identity, this.activeDid,
giverDid, giverDid,
this.givenToUser ? this.activeDid : undefined, this.receiver?.did as string,
description, description,
amountInput, amount,
unitCode, unitCode,
this.projectId, this.projectId,
this.offerId, this.offerId,
@@ -316,11 +326,14 @@ 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 message = const errorMessage =
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.";
@@ -329,7 +342,7 @@ export default class GiftedDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: message, text: errorMessage,
}, },
-1, -1,
); );

View File

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

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="'Date, eg. ' + new Date().toISOString().slice(0, 10)" :placeholder="datePlaceholder()"
v-model="expirationDateInput" v-model="expirationDateInput"
/> />
</div> </div>
@@ -69,6 +69,7 @@
</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";
@@ -81,8 +82,7 @@ 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 message = ""; @Prop projectId? = "";
@Prop projectId = "";
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
@@ -91,16 +91,20 @@ 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() { async open(recipientDid?: string) {
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);
@@ -140,6 +144,12 @@ 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();
@@ -213,15 +223,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,
identity, this.activeDid,
description, description,
amount, amount,
unitCode, unitCode,
expirationDateInput, expirationDateInput,
this.recipientDid,
this.projectId, this.projectId,
); );

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-2 bg-black/50 text-white leading-none" class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
> >
<span v-if="uploading"> Uploading... </span> <span v-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 p-2 leading-none absolute right-0 top-0 text-white" class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<fa icon="xmark" class="w-[1em]"></fa> <fa icon="xmark" class="w-[1em]"></fa>
@@ -29,17 +29,16 @@
<div v-if="crop"> <div v-if="crop">
<VuePictureCropper <VuePictureCropper
:boxStyle="{ :boxStyle="{
width: '100%',
height: '100%',
backgroundColor: '#f8f8f8', backgroundColor: '#f8f8f8',
margin: 'auto', margin: 'auto',
}" }"
:img="URL.createObjectURL(blob)" :img="createBlobURL(blob)"
:options="{ :options="{
viewMode: 1, viewMode: 1,
dragMode: 'crop', dragMode: 'crop',
aspectRatio: 9 / 9, aspectRatio: 9 / 9,
}" }"
class="max-h-[90vh] max-w-[90vw] object-contain"
/> />
<!-- This gives a round cropper. <!-- This gives a round cropper.
:presetMode="{ :presetMode="{
@@ -49,7 +48,10 @@
</div> </div>
<div v-else> <div v-else>
<div class="flex justify-center"> <div class="flex justify-center">
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" /> <img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
/>
</div> </div>
</div> </div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1"> <div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
@@ -60,7 +62,10 @@
<span>Upload</span> <span>Upload</span>
</button> </button>
</div> </div>
<div class="absolute bottom-[1rem] right-[1rem] px-2 py-1"> <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 py-1 px-2 rounded-md"
@@ -121,23 +126,24 @@ import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper"; 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, VuePictureCropper } })
export default class GiftedPhotoDialog extends Vue { export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0; activeDeviceNumber = 0;
activeDid = ""; activeDid = "";
blob: Blob | null = null; blob?: Blob;
claimType = "GiveAction"; claimType = "";
crop = false; crop = false;
fileName?: string;
mirror = false; mirror = false;
numDevices = 0; numDevices = 0;
setImage: (arg: string) => void = () => {}; setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false; uploading = false;
visible = false; visible = false;
@@ -163,15 +169,31 @@ export default class GiftedPhotoDialog extends Vue {
} }
} }
open(setImageFn: (arg: string) => void, crop?: boolean, claimType?: string) { open(
setImageFn: (arg: string) => void,
claimType: string,
crop?: boolean,
blob?: Blob, // for image upload, just to use the cropping function
inputFileName?: string,
) {
this.visible = true; this.visible = true;
this.claimType = claimType;
this.crop = !!crop; this.crop = !!crop;
this.claimType = claimType || "GiveAction";
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.setImage = setImageFn; this.setImageCallback = setImageFn;
if (blob) {
this.blob = blob;
this.fileName = inputFileName;
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false;
} else {
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
}
} }
close() { close() {
@@ -180,7 +202,7 @@ export default class GiftedPhotoDialog extends Vue {
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = ""; bottomNav.style.display = "";
} }
this.blob = null; this.blob = undefined;
} }
async cameraStarted() { async cameraStarted() {
@@ -188,6 +210,12 @@ export default class GiftedPhotoDialog 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,
);
} }
} }
@@ -195,7 +223,9 @@ export default class GiftedPhotoDialog 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"]);
cameraComponent?.changeCamera(devices[this.activeDeviceNumber].deviceId); await cameraComponent?.changeCamera(
devices[this.activeDeviceNumber].deviceId,
);
} }
async takeImage(/* payload: MouseEvent */) { async takeImage(/* payload: MouseEvent */) {
@@ -236,10 +266,13 @@ export default class GiftedPhotoDialog 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 = await cameraComponent?.snapshot({ this.blob =
height: imageHeight, (await cameraComponent?.snapshot({
width: imageWidth, height: imageHeight,
}); // png is default; if that changes, change extension in formData.append width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) { if (!this.blob) {
this.$notify( this.$notify(
{ {
@@ -254,8 +287,12 @@ export default class GiftedPhotoDialog extends Vue {
} }
} }
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() { async retryImage() {
this.blob = null; this.blob = undefined;
} }
/**** /****
@@ -307,11 +344,10 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = true; this.uploading = true;
if (this.crop) { if (this.crop) {
this.blob = await cropper?.getBlob(); this.blob = (await cropper?.getBlob()) || undefined;
} }
const identifier = await getIdentity(this.activeDid); const token = await accessToken(this.activeDid);
const token = await accessToken(identifier);
const headers = { const headers = {
Authorization: "Bearer " + token, Authorization: "Bearer " + token,
}; };
@@ -330,7 +366,7 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = false; this.uploading = false;
return; return;
} }
formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot() formData.append("image", this.blob, this.fileName || "snapshot.png");
formData.append("claimType", this.claimType); formData.append("claimType", this.claimType);
try { try {
const response = await axios.post( const response = await axios.post(
@@ -340,9 +376,8 @@ export default class GiftedPhotoDialog extends Vue {
); );
this.uploading = false; this.uploading = false;
this.visible = false; this.close();
this.blob = null; this.setImageCallback(response.data.url as string);
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(
@@ -350,12 +385,12 @@ export default class GiftedPhotoDialog extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was an error saving the picture. Please try again.", text: "There was an error saving the picture.",
}, },
5000, 5000,
); );
this.uploading = false; this.uploading = false;
this.blob = null; this.blob = undefined;
} }
} }

View File

@@ -1,5 +1,17 @@
<template> <template>
<div v-html="generateIdenticon()" class="w-fit"></div> <a
v-if="linkToFull && imageUrl"
:href="imageUrl"
target="_blank"
class="h-full w-full object-contain"
>
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
</a>
<div
v-else
v-html="generateIdenticon()"
class="h-full w-full object-contain"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from "jdenticon"; import { toSvg } from "jdenticon";
@@ -21,11 +33,17 @@ 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() {
const config = this.entityId ? undefined : BLANK_CONFIG; if (this.imageUrl) {
const svgString = toSvg(this.entityId, this.iconSize, config); return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
return svgString; } else {
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> <fa icon="house-chimney" class="fa-fw" />
</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> <fa icon="magnifying-glass" class="fa-fw" />
</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="folder-open" class="fa-fw"></fa> <fa icon="hand" class="fa-fw" />
</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> <fa icon="users" class="fa-fw" />
</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> <fa icon="circle-user" class="fa-fw" />
</router-link> </router-link>
</li> </li>
</ul> </ul>

View File

@@ -1,12 +1,11 @@
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 { accountsDB, db } from "@/db"; import { db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto"; import { getHeaders } from "@/libs/endorserServer";
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/";
@@ -19,17 +18,7 @@ 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;
await accountsDB.open(); const headers = await getHeaders(activeDid);
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,6 +4,10 @@
* 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",
@@ -30,6 +34,11 @@ 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
@@ -38,6 +47,11 @@ 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,6 +8,7 @@ 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
@@ -16,6 +17,7 @@ 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
@@ -25,14 +27,7 @@ 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 =
@@ -42,11 +37,18 @@ 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 schema for our databases // Define the schemas for our databases
accountsDB.version(1).stores(SensitiveSchemas); // Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
// v1 was contacts & settings accountsDB.version(1).stores(AccountsSchema);
// v2 added logs // v1 also had contacts & settings
db.version(2).stores(NonsensitiveSchemas); // v2 added Log
db.version(2).stores({
...ContactSchema,
...LogSchema,
...SettingsSchema,
});
// v3 added Temp
db.version(3).stores(TempSchema);
// Event handler to initialize the non-sensitive database with default settings // Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => { db.on("populate", () => {

View File

@@ -3,41 +3,46 @@
*/ */
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. * The derivation path for the account, if this is from a mnemonic
*/ */
derivationPath: string; derivationPath?: string;
/** /**
* Decentralized Identifier (DID) for the account. * Decentralized Identifier (DID) for the account
*/ */
did: string; did: string;
/** /**
* Stringified JSON containing underlying key material. * Stringified JSON containing underlying key material, if generated from a mnemonic
* 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 public key in hexadecimal format. * The mnemonic phrase for the account, if this is from a mnemonic
*/
mnemonic?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
/**
* The public key in hexadecimal format
*/ */
publicKeyHex: string; publicKeyHex: string;
/**
* The mnemonic passphrase for the account.
*/
mnemonic: string;
}; };
/** /**

View File

@@ -21,10 +21,12 @@ export type Settings = {
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 full 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;
lastViewedClaimId?: string; lastViewedClaimId?: string;
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
profileImageUrl?: string; 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
@@ -36,7 +38,8 @@ export type Settings = {
}>; }>;
showContactGivesInline?: boolean; // Display contact inline or not showContactGivesInline?: boolean; // Display contact inline or not
showShortcutBvc?: boolean; // Show shortcut for BVC actions showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push 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
@@ -44,7 +47,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);
} }
/** /**
@@ -58,3 +61,5 @@ export const SettingsSchema = {
* Constants. * Constants.
*/ */
export const MASTER_SETTINGS_KEY = 1; export const MASTER_SETTINGS_KEY = 1;
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

13
src/db/tables/temp.ts Normal file
View File

@@ -0,0 +1,13 @@
// 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,14 +3,18 @@ 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 { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer"; import {
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { 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";
/** /**
* *
* *
@@ -31,7 +35,7 @@ export const newIdentifier = (
keys: [ keys: [
{ {
kid: publicHex, kid: publicHex,
kms: "local", kms: LOCAL_KMS_NAME,
meta: { derivationPath: derivationPath }, meta: { derivationPath: derivationPath },
privateKeyHex: privateHex, privateKeyHex: privateHex,
publicKeyHex: publicHex, publicKeyHex: publicHex,
@@ -64,6 +68,10 @@ export const deriveAddress = (
return [address, privateHex, publicHex, derivationPath]; return [address, privateHex, publicHex, derivationPath];
}; };
export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes);
};
/** /**
* *
* *
@@ -77,81 +85,20 @@ export const generateSeed = (): string => {
}; };
/** /**
* Retreive an access token * Retrieve an access token, or "" if no DID is provided.
* *
* @param {IIdentifier} identifier
* @return {*} * @return {*}
*/ */
export const accessToken = async (identifier: IIdentifier) => { export const accessToken = async (did?: string) => {
const did: string = identifier.did; if (did) {
const privateKeyHex: string = identifier.keys[0].privateKeyHex as string; const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + 60; // add one minute
const signer = SimpleSigner(privateKeyHex); const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
return createEndorserJwtForDid(did, tokenPayload);
const nowEpoch = Math.floor(Date.now() / 1000); } else {
const endEpoch = nowEpoch + 60; // add one minute return "";
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:
@@ -169,7 +116,7 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
} }
// JWT format: { header, payload, signature, data } // JWT format: { header, payload, signature, data }
const jwt = didJwt.decodeJWT(jwtText); const jwt = decodeEndorserJwt(jwtText);
return jwt.payload; return jwt.payload;
}; };

View File

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

112
src/libs/crypto/vc/index.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*
* The goal is to make this folder similar across projects, then move it to a library.
* Other projects: endorser-ch, image-api
*
*/
import * as didJwt from "did-jwt";
import { JWTDecoded } from "did-jwt/lib/JWT";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer";
export const ETHR_DID_PREFIX = "did:ethr:";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
/**
* Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
) {
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
return didJwt.createJWT(payload, {
issuer: account.did,
signer: signer,
});
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(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

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

View File

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

View File

@@ -1,11 +1,14 @@
import { Axios, AxiosResponse, RawAxiosRequestHeaders } from "axios"; import { Axios, AxiosRequestConfig, AxiosResponse } 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, SimpleSigner } from "@/libs/crypto"; import { accessToken } 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
@@ -27,14 +30,14 @@ export interface AgreeVerifiableCredential {
object: Record<string, any>; object: Record<string, any>;
} }
export interface GiverInputInfo { export interface GiverReceiverInputInfo {
did?: string; did?: string;
name?: string; name?: string;
} }
export interface GiverOutputInfo { export interface GiverOutputInfo {
action: string; action: string;
giver?: GiverInputInfo; giver?: GiverReceiverInputInfo;
description?: string; description?: string;
amount?: number; amount?: number;
unitCode?: string; unitCode?: string;
@@ -52,7 +55,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;
@@ -64,6 +67,7 @@ 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: "",
@@ -109,6 +113,7 @@ 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;
@@ -143,6 +148,7 @@ 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;
} }
@@ -165,7 +171,7 @@ export interface PlanVerifiableCredential {
* Represents data about a project * Represents data about a project
* *
* @deprecated * @deprecated
* We should use PlanServerRecord instead. * We should use PlanSummaryRecord instead.
**/ **/
export interface PlanData { export interface PlanData {
/** /**
@@ -180,6 +186,7 @@ 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
*/ */
@@ -394,7 +401,8 @@ export function contactForDid(
* @param activeDid * @param activeDid
* @param contact * @param contact
* @param allMyDids * @param allMyDids
* @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" * @return { known: boolean, displayName: string, profileImageUrl?: string }
* where 'known' is true if they are in the contacts
*/ */
export function didInfoForContact( export function didInfoForContact(
did: string | undefined, did: string | undefined,
@@ -402,15 +410,16 @@ 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 } { ): { known: boolean; displayName: string; profileImageUrl?: string } {
if (!did) return { displayName: "Someone Anonymous", known: false }; if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
if (contact) { if (did === activeDid) {
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.name, known: !!contact,
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
@@ -439,29 +448,76 @@ export function didInfo(
return didInfoForContact(did, activeDid, contact, allMyDids).displayName; return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
} }
async function getHeaders(identity: IIdentifier | null) { let passkeyAccessToken: string = "";
const headers: RawAxiosRequestHeaders = { let passkeyTokenExpirationEpochSeconds: number = 0;
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 (identity) { if (did) {
const token = await accessToken(identity); let token;
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 -- which means that "undefined" will be returned * @param handleId nullable, in which case "undefined" will be returned
* @param identity nullable -- which means no private info will be returned * @param requesterDid optional, in which case 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;
} }
@@ -471,14 +527,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(identity); const headers = await getHeaders(requesterDid);
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.log( console.error(
"Failed to load plan with handle", "Failed to load plan with handle",
handleId, handleId,
" Got data:", " Got data:",
@@ -486,7 +542,7 @@ export async function getPlanFromCache(
); );
} }
} catch (error) { } catch (error) {
console.log( console.error(
"Failed to load plan with handle", "Failed to load plan with handle",
handleId, handleId,
" Got error:", " Got error:",
@@ -505,18 +561,9 @@ export async function setPlanInCache(
} }
/** /**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * Construct GiveAction VC for submission to server
*
* @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( export function constructGive(
axios: Axios,
apiServer: string,
identity: IIdentifier,
fromDid?: string | null, fromDid?: string | null,
toDid?: string, toDid?: string,
description?: string, description?: string,
@@ -526,9 +573,9 @@ export async function createAndSubmitGive(
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, isTrade: boolean = false,
imageUrl?: string, imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> { ): GiveVerifiableCredential {
const vcClaim: GiveVerifiableCredential = { const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org", "@context": SCHEMA_ORG_CONTEXT,
"@type": "GiveAction", "@type": "GiveAction",
recipient: toDid ? { identifier: toDid } : undefined, recipient: toDid ? { identifier: toDid } : undefined,
agent: fromDid ? { identifier: fromDid } : undefined, agent: fromDid ? { identifier: fromDid } : undefined,
@@ -555,9 +602,46 @@ export async function createAndSubmitGive(
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,
identity, issuerDid,
apiServer, apiServer,
axios, axios,
); );
@@ -575,17 +659,18 @@ export async function createAndSubmitGive(
export async function createAndSubmitOffer( export async function createAndSubmitOffer(
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
identity: IIdentifier, issuerDid: string,
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": "https://schema.org", "@context": SCHEMA_ORG_CONTEXT,
"@type": "Offer", "@type": "Offer",
offeredBy: { identifier: identity.did }, offeredBy: { identifier: issuerDid },
validThrough: expirationDate || undefined, validThrough: expirationDate || undefined,
}; };
if (amount) { if (amount) {
@@ -597,6 +682,9 @@ 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 = {
@@ -606,7 +694,7 @@ export async function createAndSubmitOffer(
} }
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericCredWrapper, vcClaim as GenericCredWrapper,
identity, issuerDid,
apiServer, apiServer,
axios, axios,
); );
@@ -614,7 +702,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 (
identifier: IIdentifier, issuerDid: string,
claim: GenericVerifiableCredential, claim: GenericVerifiableCredential,
lastClaimId: string, // used to set the lastClaimId lastClaimId: string, // used to set the lastClaimId
handleId: string | undefined, handleId: string | undefined,
@@ -627,16 +715,16 @@ export const createAndSubmitConfirmation = async (
), ),
); );
const confirmationClaim: GenericVerifiableCredential = { const confirmationClaim: GenericVerifiableCredential = {
"@context": "https://schema.org", "@context": SCHEMA_ORG_CONTEXT,
"@type": "AgreeAction", "@type": "AgreeAction",
object: goodClaim, object: goodClaim,
}; };
return createAndSubmitClaim(confirmationClaim, identifier, apiServer, axios); return createAndSubmitClaim(confirmationClaim, issuerDid, apiServer, axios);
}; };
export async function createAndSubmitClaim( export async function createAndSubmitClaim(
vcClaim: GenericVerifiableCredential, vcClaim: GenericVerifiableCredential,
identity: IIdentifier, issuerDid: string,
apiServer: string, apiServer: string,
axios: Axios, axios: Axios,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
@@ -649,34 +737,15 @@ export async function createAndSubmitClaim(
}, },
}; };
// Create a signature using private key of identity const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload);
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}`,
}, },
}); });
@@ -698,6 +767,20 @@ 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 (
@@ -894,3 +977,128 @@ 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,22 +1,27 @@
// 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 { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import {DEFAULT_PASSKEY_EXPIRATION_MINUTES, 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 be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to those you allow."; "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.";
/* 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",
@@ -26,6 +31,7 @@ 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",
@@ -70,7 +76,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 giveIsConfirmable = (veriClaim: GenericCredWrapper) => { export const isGiveAction = (veriClaim: GenericCredWrapper) => {
return veriClaim.claimType === "GiveAction"; return veriClaim.claimType === "GiveAction";
}; };
@@ -91,7 +97,7 @@ export const isGiveRecordTheUserCanConfirm = (
confirmerIdList: string[] = [], confirmerIdList: string[] = [],
) => { ) => {
return ( return (
giveIsConfirmable(veriClaim) && isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) && !confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid && veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim) !containsHiddenDid(veriClaim.claim)
@@ -191,20 +197,17 @@ export function findAllVisibleToDids(
* *
**/ **/
export const getIdentity = async (activeDid: string): Promise<IIdentifier> => { export interface AccountKeyInfo extends Account, KeyMeta {}
export const getAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
await accountsDB.open(); 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;
const identity = JSON.parse(account?.identity || "null"); return account;
if (!identity) {
throw new Error(
`Attempted to load Offer records for DID ${activeDid} but no identifier was found`,
);
}
return identity;
}; };
/** /**
@@ -237,6 +240,47 @@ 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 { createApp } from "vue"; import { App as VueApp, ComponentPublicInstance, 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,9 +11,12 @@ 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,
@@ -34,6 +37,7 @@ import {
faComment, faComment,
faCopy, faCopy,
faDollar, faDollar,
faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEye, faEye,
faEyeSlash, faEyeSlash,
@@ -45,8 +49,10 @@ import {
faGlobe, faGlobe,
faHammer, faHammer,
faHand, faHand,
faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImagePortrait,
faLeftRight, faLeftRight,
faLocationDot, faLocationDot,
faLongArrowAltLeft, faLongArrowAltLeft,
@@ -63,6 +69,7 @@ import {
faRotate, faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquare,
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
@@ -74,9 +81,12 @@ 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,
@@ -97,6 +107,7 @@ library.add(
faComment, faComment,
faCopy, faCopy,
faDollar, faDollar,
faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEye, faEye,
faEyeSlash, faEyeSlash,
@@ -108,8 +119,10 @@ library.add(
faGlobe, faGlobe,
faHammer, faHammer,
faHand, faHand,
faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImagePortrait,
faLeftRight, faLeftRight,
faLocationDot, faLocationDot,
faLongArrowAltLeft, faLongArrowAltLeft,
@@ -126,6 +139,7 @@ library.add(
faRotate, faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquare,
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
@@ -139,11 +153,38 @@ 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";
createApp(App) // Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
function setupGlobalErrorHandler(app: VueApp) {
// @ts-expect-error 'cause we cannot see why config is not defined
app.config.errorHandler = (
err: Error,
instance: ComponentPublicInstance | null,
info: string,
) => {
console.error(
"Ouch! Global Error Handler. Info:",
info,
"Error:",
err,
"Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.
alert(
(err.message || "Something bad happened") +
" - Try reloading or restarting the app.",
);
};
}
const app = createApp(App)
.component("fa", FontAwesomeIcon) .component("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,19 +38,29 @@ 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-gives", path: "/contact-gift",
name: "contact-gives", name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"), component: () => import("../views/ContactGiftingView.vue"),
}, },
{ {
@@ -63,6 +73,11 @@ 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",
@@ -164,6 +179,11 @@ 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,6 +6,9 @@ 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

@@ -0,0 +1,101 @@
<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> <fa icon="chevron-left" class="fa-fw" />
</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> <fa icon="copy" class="text-slate-400 fa-fw" />
</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> <fa icon="message" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }} {{ veriClaim.claim?.description }}
</div> </div>
<div> <div>
<fa icon="user" class="fa-fw text-slate-400"></fa> <fa icon="user" class="fa-fw text-slate-400" />
{{ 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> <fa icon="copy" class="text-slate-400 fa-fw" />
</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> <fa icon="calendar" class="fa-fw text-slate-400" />
{{ 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="columns-3"> <div class="flex 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,12 +131,22 @@
confirmerIdList, confirmerIdList,
) )
" "
@click="confirmClaim()" @click="confirmConfirmClaim()"
> >
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()"
@@ -146,9 +156,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" message="Offer fulfilled by" /> <GiftedDialog ref="customGiveDialog" />
<div v-if="libsUtil.giveIsConfirmable(veriClaim)"> <div v-if="libsUtil.isGiveAction(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>
@@ -319,7 +329,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> <fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to: The {{ visibleDidPath }} is visible to:
</div> </div>
<div class="ml-12 p-1"> <div class="ml-12 p-1">
@@ -341,8 +351,7 @@
</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"></fa <fa icon="globe" class="fa-fw text-slate-400" />&nbsp;<a
>&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]" :href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500" class="text-blue-500"
>{{ >{{
@@ -398,7 +407,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { AxiosError } 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";
@@ -406,25 +415,22 @@ 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 { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { GiverInputInfo } from "@/libs/endorserServer"; import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({ @Component({
components: { GiftedDialog, OfferDialog, QuickNav }, components: { GiftedDialog, 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> = [];
@@ -475,17 +481,14 @@ export default class ClaimView extends Vue {
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray(); const accountsArr: Array<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);
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, identity); await this.loadClaim(claimId, this.activeDid);
} else { } else {
this.$notify( this.$notify(
{ {
@@ -519,33 +522,6 @@ export default class ClaimView extends Vue {
); );
} }
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
// Isn't there a better way to make this available to the template? // 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(
@@ -556,12 +532,12 @@ export default class ClaimView extends Vue {
); );
} }
async loadClaim(claimId: string, identity: IIdentifier) { async loadClaim(claimId: string, userDid: string) {
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 this.getHeaders(identity); const headers = await serverUtil.getHeaders(userDid);
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
@@ -593,7 +569,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 this.getHeaders(identity); const giveHeaders = await serverUtil.getHeaders(userDid);
const giveResp = await this.axios.get(giveUrl, { const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders, headers: giveHeaders,
}); });
@@ -607,7 +583,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 this.getHeaders(identity); const offerHeaders = await serverUtil.getHeaders(userDid);
const offerResp = await this.axios.get(offerUrl, { const offerResp = await this.axios.get(offerUrl, {
headers: offerHeaders, headers: offerHeaders,
}); });
@@ -623,12 +599,14 @@ 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 this.getHeaders(identity); const confirmHeaders = await serverUtil.getHeaders(userDid);
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,
@@ -663,15 +641,9 @@ 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 this.getHeaders(identity); const headers = await serverUtil.getHeaders(this.activeDid);
try { try {
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
@@ -716,52 +688,65 @@ export default class ClaimView extends Vue {
} }
} }
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView // similar code is found in ProjectViewView
async confirmClaim() { async confirmClaim() {
if (confirm("Do you personally confirm that this is true?")) { // similar logic is found in endorser-mobile
// similar logic is found in endorser-mobile const goodClaim = serverUtil.removeSchemaContext(
const goodClaim = serverUtil.removeSchemaContext( serverUtil.removeVisibleToDids(
serverUtil.removeVisibleToDids( serverUtil.addLastClaimOrHandleAsIdIfMissing(
serverUtil.addLastClaimOrHandleAsIdIfMissing( this.veriClaim.claim,
this.veriClaim.claim, this.veriClaim.id,
this.veriClaim.id, this.veriClaim.handleId,
this.veriClaim.handleId,
),
), ),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
); );
const confirmationClaim: serverUtil.GenericVerifiableCredential = { } else {
"@context": "https://schema.org", console.error("Got error submitting the confirmation:", result);
"@type": "AgreeAction", this.$notify(
object: goodClaim, {
}; group: "alert",
const result = await serverUtil.createAndSubmitClaim( type: "danger",
confirmationClaim, title: "Error",
await this.getIdentity(this.activeDid), text: "There was a problem submitting the confirmation. See logs for more info.",
this.apiServer, },
this.axios, -1,
); );
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
-1,
);
}
} }
} }
@@ -771,17 +756,19 @@ 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, JSON.parse(this.accountIdentityStr)); await this.loadClaim(claimId, this.activeDid);
}); });
} }
openFulfillGiftDialog() { openFulfillGiftDialog() {
const giver: GiverInputInfo = { const giver: GiverReceiverInputInfo = {
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

@@ -0,0 +1,859 @@
<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">
{{ record.amount }} {{ record.unit }} {{ displayAmount(record.unit, record.amount) }}
<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">
{{ record.amount }} {{ record.unit }} {{ displayAmount(record.unit, record.amount) }}
<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,10 +105,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError, AxiosRequestHeaders } 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";
@@ -116,9 +114,11 @@ 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,36 +134,13 @@ 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();
@@ -171,8 +148,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 || ""; this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = (settings?.apiServer as string) || "";
if (this.activeDid && this.contact) { if (this.activeDid && this.contact) {
this.loadGives(this.activeDid, this.contact); this.loadGives(this.activeDid, this.contact);
@@ -196,15 +173,14 @@ 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(identity.did) + encodeURIComponent(this.activeDid) +
"&recipientDid=" + "&recipientDid=" +
encodeURIComponent(contact.did); encodeURIComponent(contact.did);
const headers = await this.getHeaders(identity); const headers = await getHeaders(activeDid);
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;
@@ -230,8 +206,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(identity.did); encodeURIComponent(this.activeDid);
const headers2 = await this.getHeaders(identity); const headers2 = await getHeaders(activeDid);
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);
@@ -286,66 +262,44 @@ export default class ContactAmountssView extends Vue {
object: origClaim, object: origClaim,
}; };
// Make a payload for the claim const vcJwt: string = await createEndorserJwtVcFromClaim(
const vcPayload = { this.activeDid,
vc: { vcClaim,
"@context": ["https://www.w3.org/2018/credentials/v1"], );
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity // Make the xhr request payload
const identity = await this.getIdentity(this.activeDid); const payload = JSON.stringify({ jwtEncoded: vcJwt });
if (identity.keys[0].privateKeyHex !== null) { const url = this.apiServer + "/api/v2/claim";
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
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 try {
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const resp = await this.axios.post(url, payload, { headers });
const url = this.apiServer + "/api/v2/claim"; if (resp.data?.success) {
const token = await accessToken(identity); record.amountConfirmed =
const headers = { (origClaim.object?.amountOfThisGood as number) || 1;
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success) {
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
}
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
-1,
);
} }
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
-1,
);
} }
} }

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"
/> />
Anonymous/Unnamed Unnamed/Unknown
</span> </span>
<span class="text-right"> <span class="text-right">
<button <button
@@ -66,28 +66,22 @@
</li> </li>
</ul> </ul>
<GiftedDialog <GiftedDialog ref="customDialog" :projectId="projectId" />
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 { Account, AccountsSchema } from "@/db/tables/accounts"; import { 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 { accessToken } from "@/libs/crypto"; import { GiverReceiverInputInfo } from "@/libs/endorserServer";
import { GiverInputInfo } from "@/libs/endorserServer";
@Component({ @Component({
components: { GiftedDialog, QuickNav, EntityIcon }, components: { GiftedDialog, QuickNav, EntityIcon },
@@ -138,33 +132,16 @@ export default class ContactGiftingView extends Vue {
} }
} }
public async getIdentity(activeDid: string) { openDialog(giver?: GiverReceiverInputInfo) {
await accountsDB.open(); const recipient = this.projectId
const account = (await accountsDB.accounts ? undefined
.where("did") : { did: this.activeDid, name: "you" };
.equals(activeDid) (this.$refs.customDialog as GiftedDialog).open(
.first()) as Account; giver,
const identity = JSON.parse(account?.identity || "null"); recipient,
undefined,
if (!identity) { "Given by " + (giver?.name || "someone not named"),
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,6 +24,7 @@
> >
<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"
@@ -33,7 +34,11 @@
</p> </p>
</div> </div>
<div @click="onCopyToClipboard()" v-if="activeDid" class="text-center"> <div
@click="onCopyUrlToClipboard()"
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="text-center"
>
<!-- <!--
Play with display options: https://qr-code-styling.com/ 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
@@ -44,8 +49,18 @@
:dotsOptions="{ type: 'square' }" :dotsOptions="{ type: 'square' }"
class="flex justify-center" class="flex justify-center"
/> />
<span> Click that QR to copy your contact URL to your clipboard. </span> <span>
<div>Not scanning? Show it in pieces.</div> 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
@@ -71,7 +86,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import * as didJwt from "did-jwt"; import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { sha256 } from "ethereum-cryptography/sha256.js"; import { 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";
@@ -79,17 +95,25 @@ 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 {deriveAddress, getContactPayloadFromJwtUrl, nextDerivationPath, SimpleSigner} from "@/libs/crypto"; import {
import QuickNav from "@/components/QuickNav.vue"; deriveAddress,
import { Account } from "@/db/tables/accounts"; getContactPayloadFromJwtUrl,
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 { Buffer } from "buffer/"; import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
@Component({ @Component({
components: { components: {
@@ -104,47 +128,29 @@ export default class ContactQRScanShow extends Vue {
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
givenName = ""; givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = ""; qrValue = "";
public async getIdentity(activeDid: string) { ETHR_DID_PREFIX = ETHR_DID_PREFIX;
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 || ""; this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = (settings?.apiServer as string) || "";
this.givenName = settings?.firstName || ""; this.givenName = (settings?.firstName as string) || "";
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 identity = await this.getIdentity(this.activeDid); const publicKeyHex = account.publicKeyHex;
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,
@@ -153,46 +159,157 @@ 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,
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
profileImageUrl: settings?.profileImageUrl, profileImageUrl: settings?.profileImageUrl,
registered: settings?.isRegistered,
}, },
}; };
const alg = undefined; if (account?.mnemonic && account?.derivationPath) {
const privateKeyHex: string = identity.keys[0].privateKeyHex; const newDerivPath = nextDerivationPath(
const signer = await SimpleSigner(privateKeyHex); account.derivationPath as string,
// create a JWT for the request );
const vcJwt: string = await didJwt.createJWT(contactInfo, { const nextPublicHex = deriveAddress(
alg: alg, account.mnemonic as string,
issuer: identity.did, newDerivPath,
signer: signer, )[2];
}); const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
}
const vcJwt = await createEndorserJwtForDid(this.activeDid, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; 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
onScanDetect(content: any) { async onScanDetect(content: any) {
const url = content[0]?.rawValue; const url = content[0]?.rawValue;
if (url) { if (url) {
let newContact: Contact;
try { try {
const fullData = getContactPayloadFromJwtUrl(url); const payload = getContactPayloadFromJwtUrl(url);
console.log("fullData", fullData); if (!payload) {
localStorage.setItem("contactEndorserUrl", url); this.$notify(
this.$router.push({ name: "contacts" }); {
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) { } 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( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "success",
title: "Invalid Contact QR Code", title: "Contact Added",
text: "The QR code isn't in the right format.", 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, 5000,
); );
@@ -201,7 +318,7 @@ export default class ContactQRScanShow extends Vue {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "danger",
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.",
}, },
@@ -210,13 +327,102 @@ export default class ContactQRScanShow extends Vue {
} }
} }
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,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) { onScanError(error: any) {
console.error("Scan was invalid:", error); console.error("Scan was invalid:", error);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "danger",
title: "Invalid Scan", title: "Invalid Scan",
text: "The scan was invalid.", text: "The scan was invalid.",
}, },
@@ -224,7 +430,8 @@ export default class ContactQRScanShow extends Vue {
); );
} }
onCopyToClipboard() { onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard() useClipboard()
.copy(this.qrValue) .copy(this.qrValue)
.then(() => { .then(() => {
@@ -240,5 +447,22 @@ export default class ContactQRScanShow extends Vue {
); );
}); });
} }
onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.activeDid)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it on their 'People' screen to add you.",
},
10000,
);
});
}
} }
</script> </script>

File diff suppressed because it is too large Load Diff

313
src/views/DIDView.vue Normal file
View File

@@ -0,0 +1,313 @@
<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 Discover Projects
</h1> </h1>
<!-- Quick Search --> <!-- Quick Search -->
@@ -37,7 +37,7 @@
isRemoteActive = false; isRemoteActive = false;
searchLocal(); searchLocal();
" "
v-bind:class="computedLocalTabClassNames()" v-bind:class="computedLocalTabStyleClassNames()"
> >
Nearby Nearby
<span <span
@@ -57,7 +57,7 @@
isLocalActive = false; isLocalActive = false;
searchAll(); searchAll();
" "
v-bind:class="computedRemoteTabClassNames()" v-bind:class="computedRemoteTabStyleClassNames()"
> >
Anywhere Anywhere
<span <span
@@ -74,7 +74,7 @@
<div v-if="isLocalActive"> <div v-if="isLocalActive">
<div> <div>
<button <button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500" class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })" @click="$router.push({ name: 'search-area' })"
> >
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
@@ -100,14 +100,15 @@
> >
<a <a
@click="onClickLoadProject(project.handleId)" @click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4" class="block py-4 flex gap-4 cursor-pointer"
> >
<div class="w-12"> <div>
<ProjectIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
class="block border border-slate-300 rounded-md" :imageUrl="project.image"
></ProjectIcon> class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div> </div>
<div class="grow"> <div class="grow">
@@ -137,8 +138,7 @@ 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 { accessToken } from "@/libs/crypto"; import { didInfo, getHeaders, PlanData } from "@/libs/endorserServer";
import { didInfo, PlanData } from "@/libs/endorserServer";
@Component({ @Component({
components: { components: {
@@ -202,30 +202,6 @@ export default class DiscoverView extends Vue {
} }
} }
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
public async searchAll(beforeId?: string) { public async searchAll(beforeId?: string) {
this.resetCounts(); this.resetCounts();
@@ -246,7 +222,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 this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );
@@ -271,8 +247,15 @@ 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, issuerDid, rowid } = plan; const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid }); this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
} }
this.remoteCount = this.projects.length; this.remoteCount = this.projects.length;
} else { } else {
@@ -329,7 +312,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 this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );
@@ -414,7 +397,7 @@ export default class DiscoverView extends Vue {
this.$router.push(route); this.$router.push(route);
} }
public computedLocalTabClassNames() { public computedLocalTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,
@@ -432,7 +415,7 @@ export default class DiscoverView extends Vue {
}; };
} }
public computedRemoteTabClassNames() { public computedRemoteTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,

View File

@@ -5,10 +5,13 @@
<!-- 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 class="text-lg text-center font-light relative px-7"> <div
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="cancel()" @click="cancelBack()"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw"></fa>
</h1> </h1>
@@ -18,7 +21,17 @@
<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">
{{ message }} {{ giverName || "somebody not named" }} <span>From {{ giverName }}</span>
<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"
@@ -30,7 +43,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] }} {{ libsUtil.UNIT_SHORT[unitCode] || 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"
@@ -66,28 +79,73 @@
<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="openPhotoDialog" @click="openImageDialog"
/> />
</span> </span>
</div> </div>
<GiftedPhotoDialog ref="photoDialog" /> <ImageMethodDialog ref="imageDialog" />
<div v-if="projectId" class="mt-4"> <div class="h-7 mt-4 flex">
<fa <input
icon="check" v-if="projectId && !givenToRecipient"
class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded" type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/> />
<label class="text-sm">This is given to a project</label> <fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
</div> </div>
<div v-if="!projectId" class="mt-4"> <div class="h-7 mt-4 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" /> <input
<label class="text-sm">Given to you</label> v-if="recipientDid && !givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToRecipient"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfRecipient()"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName
: "No recipient was chosen."
}}
</label>
</div> </div>
<div class="mt-4"> <div class="mt-4 flex">
<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">Trade (not a gift)</label> <label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
<div class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructGiveParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</div> </div>
<p class="text-center mb-2 mt-6 italic"> <p class="text-center mb-2 mt-6 italic">
@@ -118,21 +176,25 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import ImageMethodDialog from "@/components/ImageMethodDialog.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 { db } from "@/db/index"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive } from "@/libs/endorserServer"; import {
constructGive,
createAndSubmitGive,
didInfo,
getHeaders,
getPlanFromCache,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto"; import { Contact } from "@/db/tables/contacts";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
@Component({ @Component({
components: { components: {
GiftedDialog, ImageMethodDialog,
GiftedPhotoDialog,
QuickNav, QuickNav,
TopMessage, TopMessage,
}, },
@@ -145,31 +207,58 @@ export default class GiftedDetails extends Vue {
amountInput = "0"; amountInput = "0";
description = ""; description = "";
givenToUser = false; destinationNameAfter = "";
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.$route.query.amountInput as string; this.amountInput =
this.description = this.$route.query.description as string; (this.$route.query.amountInput as string) || this.amountInput;
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.message = this.$route.query.message as string; this.hideBackButton = this.$route.query.hideBackButton === "true";
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.unitCode = this.$route.query.unitCode as string; this.recipientDid = this.$route.query.recipientDid as string;
this.recipientName = (this.$route.query.recipientName as string) || "";
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode;
this.imageUrl = localStorage.getItem("imageUrl") || ""; this.imageUrl =
(this.$route.query.imageUrl as string) ||
localStorage.getItem("imageUrl") ||
"";
this.givenToUser = !this.projectId; // this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if (this.$route.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();
@@ -177,6 +266,37 @@ 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);
@@ -190,6 +310,19 @@ 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() {
@@ -210,14 +343,23 @@ 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();
} }
openPhotoDialog() { openImageDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => { (this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl; this.imageUrl = imgUrl;
}); }, "GiveAction");
} }
confirmDeleteImage() { confirmDeleteImage() {
@@ -238,23 +380,18 @@ export default class GiftedDetails extends Vue {
return; return;
} }
try { try {
const identity = await libsUtil.getIdentity(this.activeDid); const headers = await getHeaders(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("Non-success deleting image:", response); console.error("Problem deleting image:", response);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -348,6 +485,56 @@ 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
@@ -357,17 +544,20 @@ export default class GiftedDetails extends Vue {
*/ */
public async recordGive() { public async recordGive() {
try { try {
const identity = await libsUtil.getIdentity(this.activeDid); const recipientDid = this.givenToRecipient
? 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,
identity, this.activeDid,
this.giverDid, this.giverDid,
this.givenToUser ? this.activeDid : undefined, recipientDid,
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.unitCode, this.unitCode,
this.projectId, projectId,
this.offerId, this.offerId,
this.isTrade, this.isTrade,
this.imageUrl, this.imageUrl,
@@ -399,12 +589,16 @@ export default class GiftedDetails extends Vue {
5000, 5000,
); );
localStorage.removeItem("imageUrl"); localStorage.removeItem("imageUrl");
this.$router.back(); if (this.destinationNameAfter) {
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 message = const errorMessage =
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.";
@@ -413,13 +607,31 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: message, text: errorMessage,
}, },
-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,16 +24,15 @@
<!-- eslint-disable prettier/prettier --> <!-- eslint-disable prettier/prettier -->
<div> <div>
<p> <p>
This app is a window into data that you and your friends own, focused on This app focuses on gifts & gratitude, using them to build cool things with your network.
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, you can see what people have given, and also recognize First of all, let's build gratitude: see what people have given, and recognize
gifts you've seen, in a way that leaves a permanent record -- one that gifts you've seen. This is done in a way that leaves a permanent record -- one that
came from you, and the recipient can prove it was for them. This is came from you, and that 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.
@@ -76,32 +75,26 @@
<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">
Click here to show an alert with the steps. Use these instructions.
</a> </a>
To start scanning, go To start scanning, go to the
<router-link class="text-blue-500" to="/contact-qr">here.</router-link> <router-link class="text-blue-500" to="/contact-qr">contact-scanning page.</router-link>
</p> </p>
<p> <p>
If they are not nearby to scan QR codes, tell them to copy their ID from If they are not nearby to scan QR codes, you each can tap on the QR code
their Identity <fa icon="circle-user" class="fa-fw" /> page, which and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
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 two sets of data to backup: the identifier secrets and the There are three sets of data to backup: the identifier secrets;
other data that isn't quite a secret such as settings, contacts, etc. the non-public textual data that isn't quite a secret such as settings and contacts;
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">
@@ -122,7 +115,7 @@
</ul> </ul>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data? How do I backup my other private text data like settings & contacts?
</h2> </h2>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
@@ -134,6 +127,27 @@
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>
@@ -162,6 +176,7 @@
<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>
@@ -178,8 +193,7 @@
<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, note the two kinds of data to backup: identity data, Before doing this, you may want to back up your data with the instructions above.
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">
@@ -198,11 +212,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: <a href="about:preferences">go here</a>, Manage Data, Firefox: Navigate to "about:preferences", Manage Data,
find timesafari.app and select, hit Remove Selected, then Save find timesafari.app and select, hit Remove Selected, then Save
Changes Changes
</li> </li>
@@ -232,7 +246,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" class="text-blue-500"> <a href="https://endorser.ch" target="_blank" class="text-blue-500">
EndorserSearch.com EndorserSearch.com
</a> </a>
</p> </p>
@@ -303,7 +317,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" class="text-blue-500">Search</a> <a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
for instructions for other browsers.</li> for instructions for other browsers.</li>
</ul> </ul>
Then reload Time Safari. Then reload Time Safari.
@@ -323,7 +337,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, governed by This work is public domain. If you like rules, reference
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer"> <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
@@ -344,15 +358,35 @@
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" class="text-blue-500"> <a href="https://endorser.ch/privacy-policy" target="_blank" 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" class="text-blue-500"> <a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
Lives of Giving Lives of Giving
</a> </a>
initiative. initiative.
@@ -362,7 +396,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, including removing your data: For any other questions, like getting a new account or removing all your data from the public ledger:
</h2> </h2>
<p> <p>
Contact us at Contact us at
@@ -377,6 +411,7 @@
<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";
@@ -388,5 +423,14 @@ 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">
Time Safari {{ AppString.APP_NAME }}
</h1> </h1>
<!-- prompt to install notifications --> <!-- prompt to install notifications -->
@@ -62,117 +62,106 @@
<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 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" class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Bountiful Voluntaryist Community Actions</router-link
> >
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"></fa> Loading&hellip; <fa icon="spinner" class="fa-spin-pulse" /> 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>
<!-- activeDid && isRegistered --> <!-- !isCreatingIdentifier -->
<!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
<div class="mb-4"> <div class="mb-4">
<h2 class="text-xl font-bold">Record Something Given By:</h2> <div
</div> v-if="!isRegistered"
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" <!-- activeDid && !isRegistered -->
> To share, someone must register you.
<li @click="openDialog()"> <router-link
<img :to="{ name: 'contact-qr' }"
src="../assets/blank-square.svg" 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="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 Show Them Default Identifier Info
</h3> </router-link>
</li> <div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
<li <router-link
v-for="contact in allContacts.slice(0, 7)" :to="{ name: 'start' }"
:key="contact.did" 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"
@click="openDialog(contact)" >
> See all your options first
<EntityIcon </router-link>
:contact="contact" </div>
:iconSize="64" </div>
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || contact.did }}
</h3>
</li>
</ul>
<div class="flex justify-between"> <div v-else>
<router-link <!-- activeDid && isRegistered -->
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gives' }" <!-- show the actions for recognizing a give -->
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" <div class="mb-4">
> <h2 class="text-xl font-bold">Record Something Given By:</h2>
Choose From All Contacts </div>
</router-link>
<button <ul
@click="openGiftedPrompts()" class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
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()">
Ideas... <img
</button> src="../assets/blank-square.svg"
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 7)"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || contact.did }}
</h3>
</li>
</ul>
<div class="flex justify-between">
<router-link
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gift' }"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Choose From All Contacts
</router-link>
<button
@click="openGiftedPrompts()"
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Ideas...
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<GiftedDialog <GiftedDialog ref="customDialog" />
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
<GiftedPrompts ref="giftedPrompts" /> <GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" /> <FeedFilters ref="feedFilters" />
@@ -181,7 +170,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 uppercase text-white"> <span class="text-sm 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"
@@ -205,24 +194,65 @@
: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 uppercase text-sm" class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="record.jwtId == feedLastViewedClaimId" 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="col-span-1 justify-self-start"> <span class="pt-1 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="pt-1 text-slate-500" :class="
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>
@@ -230,7 +260,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">
@@ -241,7 +271,7 @@
encodeURIComponent(record.fulfillsPlanHandleId) encodeURIComponent(record.fulfillsPlanHandleId)
" "
> >
<fa icon="hammer" class="text-blue-500"></fa> <fa icon="hammer" class="text-blue-500" />
</router-link> </router-link>
</span> </span>
</div> </div>
@@ -255,7 +285,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"></fa> Loading&hellip; <fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
</p> </p>
</div> </div>
<div v-if="!isFeedLoading && feedData.length === 0"> <div v-if="!isFeedLoading && feedData.length === 0">
@@ -269,9 +299,10 @@
<script lang="ts"> <script lang="ts">
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";
@@ -279,9 +310,12 @@ 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 { NotificationIface } from "@/constants/app"; import {
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,
@@ -289,31 +323,42 @@ 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,
GiverInputInfo, GiverReceiverInputInfo,
GiveSummaryRecord, GiveSummaryRecord,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { generateSaveAndActivateIdentity } from "@/libs/util"; import {
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;
receiver: { receiver: {
displayName: string; displayName: string;
known: boolean; known: boolean;
profileImageUrl?: string;
}; };
} }
@Component({ @Component({
computed: {
App() {
return App;
},
},
components: { components: {
GiftedDialog, GiftedDialog,
GiftedPrompts, GiftedPrompts,
@@ -327,6 +372,9 @@ 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> = [];
@@ -334,6 +382,7 @@ 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;
@@ -347,30 +396,18 @@ 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();
this.allMyDids = allAccounts.map((acc) => acc.did); if (allAccounts.length > 0) {
this.allMyDids = allAccounts.map((acc) => acc.did);
} else {
this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity();
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
}
await db.open(); 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;
@@ -378,6 +415,7 @@ 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;
@@ -386,15 +424,29 @@ export default class HomeView extends Vue {
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
if (this.allMyDids.length === 0) {
this.isCreatingIdentifier = true; // someone may have have registered after sharing contact info, so recheck
this.activeDid = await generateSaveAndActivateIdentity(); if (!this.isRegistered && this.activeDid) {
this.allMyDids = [this.activeDid]; try {
this.isCreatingIdentifier = false; const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
// we just needed to know that they're registered
await db.open();
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
@@ -414,6 +466,15 @@ 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;
} }
@@ -422,26 +483,6 @@ 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();
@@ -459,8 +500,11 @@ 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
**/ **/
public async loadMoreGives(payload: boolean) { async loadMoreGives(payload: boolean) {
if (payload) { // Since feed now loads projects along the way, it takes longer
// and the InfiniteScroll component triggers a load before finished.
// One alternative is to totally separate the project link loading.
if (payload && !this.isFeedLoading) {
this.updateAllFeed(); this.updateAllFeed();
} }
} }
@@ -478,7 +522,7 @@ export default class HomeView extends Vue {
} }
} }
public async updateAllFeed() { 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)
@@ -486,7 +530,6 @@ 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
const identity = await this.getIdentity(this.activeDid);
for (const record: GiveSummaryRecord of results.data) { for (const record: GiveSummaryRecord of results.data) {
// similar code is in endorser-mobile utility.ts // similar code is in endorser-mobile utility.ts
// claim.claim happen for some claims wrapped in a Verifiable Credential // claim.claim happen for some claims wrapped in a Verifiable Credential
@@ -498,11 +541,14 @@ export default class HomeView extends Vue {
// recipient.did is for legacy data, before March 2023 // recipient.did is for legacy data, before March 2023
const recipientDid = const recipientDid =
claim.recipient?.identifier || (claim.recipient 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
// This has indeed proven problematic. See loadMoreGives
// We should display it immediately and then get the plan later.
const plan = await getPlanFromCache( const plan = await getPlanFromCache(
record.fulfillsPlanHandleId, record.fulfillsPlanHandleId,
identity,
this.axios, this.axios,
this.apiServer, this.apiServer,
this.activeDid,
); );
// check if the record should be filtered out // check if the record should be filtered out
@@ -583,15 +629,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
*/ */
public async retrieveGives(endorserApiServer: string, beforeId?: string) { 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 this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );
@@ -676,7 +722,7 @@ export default class HomeView extends Vue {
const route = { const route = {
path: "/claim/" + encodeURIComponent(jwtId), path: "/claim/" + encodeURIComponent(jwtId),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
displayAmount(code: string, amt: number) { displayAmount(code: string, amt: number) {
@@ -687,8 +733,16 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
} }
openDialog(giver?: GiverInputInfo) { openDialog(giver?: GiverReceiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver); (this.$refs.customDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "you",
},
undefined,
"Given by " + (giver?.name || "someone not named"),
);
} }
openGiftedPrompts() { openGiftedPrompts() {
@@ -698,5 +752,21 @@ 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,24 +39,43 @@
<!-- Other Identity/ies --> <!-- Other Identity/ies -->
<ul class="mb-4"> <ul class="mb-4">
<li <li v-for="ident in otherIdentities" :key="ident.did">
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2" <div class="flex items-center justify-between mb-2">
v-for="ident in otherIdentities" <div
:key="ident.did" class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer"
@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 v-else icon="circle" class="fa-fw text-slate-400 text-xl mr-3" /> <fa
<span class="overflow-hidden"> v-else
<h2 class="text-xl font-semibold mb-0"></h2> icon="circle"
<div class="text-sm text-slate-500 truncate"> class="fa-fw text-slate-400 text-xl mr-3"
<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>
</span> <div>
<fa
v-if="ident.did === activeDid"
icon="trash-can"
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
@click="notifyCannotDelete()"
/>
<fa
v-else
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteAccount(ident.id)"
/>
</div>
</div>
</li> </li>
</ul> </ul>
@@ -81,9 +100,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { AppString, 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 { 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";
@@ -91,14 +109,11 @@ 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<{ did: string }> = []; public otherIdentities: Array<{ id: string; did: string }> = [];
public showContactGives = false;
async created() { async created() {
try { try {
@@ -107,14 +122,13 @@ 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 did = JSON.parse(accounts[n].identity)["did"]; const acct = accounts[n];
this.otherIdentities.push({ did: did }); this.otherIdentities.push({ id: acct.id as string, did: acct.did });
if (did && this.activeDid === did) { if (acct.did && this.activeDid === acct.did) {
this.activeDidInIdentities = true; this.activeDidInIdentities = true;
} }
} }
@@ -143,5 +157,36 @@ 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 -->
<input <textarea
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 derivation path from the existing seed. Will increment the maximum known 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

@@ -68,8 +68,6 @@ 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,10 +29,31 @@
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="block w-full rounded border border-slate-400 px-3 py-2" class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid" v-model="agentDid"
/> />
<div class="mb-4"> <div class="mb-4">
@@ -64,6 +85,23 @@
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"
@@ -99,7 +137,7 @@
<l-marker <l-marker
v-if="latitude && longitude" v-if="latitude && longitude"
:lat-lng="[latitude, longitude]" :lat-lng="[latitude, longitude]"
@click="maybeEraseLatLong()" @click="confirmEraseLatLong()"
/> />
</l-map> </l-map>
</div> </div>
@@ -135,25 +173,34 @@
<script lang="ts"> <script lang="ts">
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios"; import { AxiosError, AxiosRequestHeaders } from "axios";
import * as didJwt from "did-jwt"; import { DateTime } from "luxon";
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 { NotificationIface } from "@/constants/app"; import { DEFAULT_IMAGE_API_SERVER, 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, SimpleSigner } from "@/libs/crypto"; import {
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: { LMap, LMarker, LTileLayer, QuickNav }, components: { ImageMethodDialog, 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 = "";
@@ -165,6 +212,7 @@ 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;
@@ -174,39 +222,15 @@ 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 beforeCreate() { async mounted() {
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) || "";
@@ -214,35 +238,26 @@ export default class NewEditProjectView extends Vue {
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
console.error("Error: no account was found."); this.errNote("There was a problem loading your account info.");
} else { } else {
const identity = await this.getIdentity(this.activeDid); this.loadProject(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(identity: IIdentifier) { async loadProject(userDid: string) {
const url = const url =
this.apiServer + this.apiServer +
"/api/claim/byHandle/" + "/api/claim/byHandle/" +
encodeURIComponent(this.projectId); encodeURIComponent(this.projectId);
const token = await accessToken(identity); const headers = await getHeaders(userDid);
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;
@@ -252,13 +267,93 @@ 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.");
} }
} }
private async saveProject(identity: IIdentifier) { openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "PlanAction");
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const headers = 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) {
@@ -271,6 +366,11 @@ 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: {
@@ -282,110 +382,106 @@ export default class NewEditProjectView extends Vue {
} else { } else {
delete vcClaim.location; delete vcClaim.location;
} }
// Make a payload for the claim if (this.startDateInput) {
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 resp = await this.axios.post(url, payload, { headers }); const startTimeFull = this.startTimeInput || "00:00:00";
if (resp.data?.success?.handleId) { const fullTimeString = this.startDateInput + " " + startTimeFull;
this.errorMessage = ""; // throw an error on an invalid date or time string
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
useAppStore() } catch {
.setProjectId(resp.data.success.handleId) // it's not a valid date so erase it and tell the user
.then(() => { delete vcClaim.startTime;
this.$router.push({ name: "project" }); this.$notify(
}); {
} else { group: "alert",
console.error( type: "danger",
"Got unexpected 'data' inside response from server", title: "Error",
resp, text: "The date was invalid so it was not set.",
); },
this.$notify( 5000,
{ );
group: "alert",
type: "danger",
title: "Error Saving Idea",
text: "Server did not save the idea. Try again.",
},
-1,
);
}
} catch (error) {
let userMessage = "There was an error saving the project.";
const serverError = error as AxiosError<{
error?: { message?: string };
}>;
if (serverError) {
console.error("Got error from server", serverError);
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
userMessage =
(serverError.response?.data?.error?.message as string) ||
userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "User Message",
text: userMessage,
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Message",
text: JSON.stringify(serverError.toJSON()),
},
-1,
);
}
} else {
console.error(
"Here's the full error trying to save the claim:",
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Claim Error",
text: error as string,
},
-1,
);
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
} }
} else {
delete vcClaim.startTime;
}
const vcJwt = await createEndorserJwtVcFromClaim(issuerDid, vcClaim);
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const headers = await getHeaders(issuerDid);
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) {
this.errorMessage = "";
useAppStore()
.setProjectId(resp.data.success.handleId)
.then(() => {
this.$router.push({ name: "project" });
});
} else {
console.error(
"Got unexpected 'data' inside response from server",
resp,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Saving Idea",
text: "Server did not save the idea. Try again.",
},
-1,
);
}
} catch (error) {
let userMessage = "There was an error saving the project.";
const serverError = error as AxiosError<{
error?: { message?: string };
}>;
if (serverError) {
console.error("Got error from server", serverError);
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
userMessage =
(serverError.response?.data?.error?.message as string) ||
userMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "User Message",
text: userMessage,
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Message",
text: JSON.stringify(serverError.toJSON()),
},
-1,
);
}
} else {
console.error("Here's the full error trying to save the claim:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Claim Error",
text: error as string,
},
-1,
);
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
} }
} }
@@ -396,17 +492,29 @@ 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 {
const identity = await this.getIdentity(this.activeDid); this.saveProject(this.activeDid);
this.saveProject(identity);
} }
} }
public maybeEraseLatLong() { confirmEraseLatLong() {
if (window.confirm("Are you sure you don't want to mark a location?")) { this.$notify(
this.latitude = 0; {
this.longitude = 0; group: "modal",
this.includeLocation = false; type: "confirm",
} title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: async () => {
this.eraseLatLong();
},
},
-1,
);
}
public eraseLatLong() {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
} }
public onCancelClick() { 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" class="mb-8"> <div id="ViewBreadcrumb">
<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,23 +15,25 @@
<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 mb-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div> <div>
<div class="block pb-4 flex gap-4"> <div class="pb-4 flex gap-4">
<div class="flex-none w-16 pt-1"> <div class="pt-1">
<ProjectIcon <ProjectIcon
:entityId="projectId" :entityId="projectId"
:iconSize="64" :iconSize="64"
class="block border border-slate-300 rounded-md" :imageUrl="imageUrl"
></ProjectIcon> :linkToFull="true"
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>
@@ -53,9 +55,9 @@
<span v-show="showDidCopy">Copied DID</span> <span v-show="showDidCopy">Copied DID</span>
</span> </span>
</div> </div>
<div v-if="timeSince"> <div v-if="startTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa> <fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ timeSince }} {{ startTime }}
</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>
@@ -69,8 +71,9 @@
</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>
@@ -112,11 +115,11 @@
</button> </button>
</div> </div>
<div v-if="activeDid" class="mb-4"> <div v-if="activeDid" class="mt-4">
<div class="text-center"> <div class="text-center">
<button <button
@click="openOfferDialog()" @click="openOfferDialog()"
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" 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"
> >
Offer (maybe with conditions)... Offer (maybe with conditions)...
</button> </button>
@@ -125,14 +128,8 @@
<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 mb-4 text-center">Record a contribution from:</p> <p class="mt-2 mt-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"
@@ -153,7 +150,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"
> >
Anonymous/Unnamed Unnamed/Unknown
</h3> </h3>
</li> </li>
<li <li
@@ -164,7 +161,7 @@
<EntityIcon <EntityIcon
:contact="contact" :contact="contact"
:iconSize="64" :iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1" class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/> />
<h3 <h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden" class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
@@ -178,18 +175,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 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" 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"
> >
Show More Contacts&hellip; Show More Contacts&hellip;
</a> </a>
<GiftedDialog ref="customGiveDialog" :projectId="this.projectId" />
</div> </div>
<!-- Gifts to & from this --> <!-- Offers & Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-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 uppercase font-semibold mb-3"> <h3 class="text-sm font-semibold mb-3">Offered To This Idea</h3>
Offered To This Idea
</h3>
<div v-if="offersToThis.length === 0"> <div v-if="offersToThis.length === 0">
(None yet. Wanna (None yet. Wanna
@@ -246,16 +243,20 @@
</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 uppercase font-semibold mb-3">Given To This Idea</h3> <h3 class="text-sm 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"
@@ -263,8 +264,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> <fa icon="user" class="fa-fw text-slate-400" />
{{ {{
serverUtil.didInfo( serverUtil.didInfo(
give.agentDid, give.agentDid,
@@ -293,21 +294,77 @@
<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 v-if="checkIsConfirmable(give)" @click="confirmClaim(give)"> <a
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">
Contributions To This Idea Projects That Contribute To This
</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">
@@ -319,12 +376,15 @@
{{ 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">
Contributions From This Idea Projects Getting Contributions From This
</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">
@@ -342,9 +402,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { AxiosError } 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";
@@ -358,12 +416,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,
GiverInputInfo, getHeaders,
GiverReceiverInputInfo,
GiveSummaryRecord, GiveSummaryRecord,
OfferSummaryRecord, OfferSummaryRecord,
PlanSummaryRecord, PlanSummaryRecord,
@@ -392,15 +450,21 @@ 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;
timeSince = ""; startTime = "";
truncatedDesc = ""; truncatedDesc = "";
truncateLength = 40; truncateLength = 40;
url = ""; url = "";
@@ -419,24 +483,12 @@ 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, identity); this.loadProject(this.projectId, this.activeDid);
}
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() {
@@ -456,29 +508,26 @@ export default class ProjectViewView extends Vue {
this.expanded = false; this.expanded = false;
} }
async loadProject(projectId: string, identity: IIdentifier) { async loadProject(projectId: string, userDid: string) {
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: RawAxiosRequestHeaders = { const headers = await getHeaders(userDid);
"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.startTime; const startTime = resp.data.claim?.startTime;
if (startTime != null) { if (startTime != null) {
const eventDate = new Date(startTime); const startDateTime = new Date(startTime);
const now = moment.now(); this.startTime =
this.timeSince = moment.utc(now).to(eventDate); startDateTime.toLocaleDateString() +
" " +
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)";
@@ -496,7 +545,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.",
}, },
-1, 5000,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -510,7 +559,7 @@ export default class ProjectViewView extends Vue {
title: "Error", title: "Error",
text: "That project does not exist.", text: "That project does not exist.",
}, },
-1, 5000,
); );
} else { } else {
this.$notify( this.$notify(
@@ -520,82 +569,18 @@ 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.",
}, },
-1, 5000,
); );
} }
} }
const givesInUrl = this.loadGives();
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,
);
}
const offersToUrl = this.loadGivesProvidedBy();
this.apiServer +
"/api/v2/report/offersToPlans?planIds=" + this.loadOffers();
encodeURIComponent(JSON.stringify([projectId]));
try { this.loadPlanFulfillersTo();
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 +
@@ -613,7 +598,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.",
}, },
-1, 5000,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -625,31 +610,42 @@ 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.",
}, },
-1, 5000,
); );
console.error( console.error(
"Error retrieving plans fulfilled by this project:", "Error retrieving plans fulfilled by this project:",
serverError.message, serverError.message,
); );
} }
}
const fulfillersToUrl = async loadGives() {
const givesUrl =
this.apiServer + this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" + "/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(projectId); 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 { try {
const resp = await this.axios.get(fulfillersToUrl, { headers }); const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200) { if (resp.status === 200 && resp.data.data) {
this.fulfillersToThis = resp.data.data; this.givesToThis = this.givesToThis.concat(resp.data.data);
this.givesHitLimit = 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 plan fulfillers to this project.", text: "Failed to retrieve more gives to this project.",
}, },
-1, 5000,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -659,12 +655,157 @@ export default class ProjectViewView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "Something went wrong retrieving plan fulfillers to this project.", text: "Something went wrong retrieving more gives to this project.",
}, },
-1, 5000,
); );
console.error( console.error(
"Error retrieving plan fulfillers to this project:", "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 +
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
encodeURIComponent(this.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 {
const resp = await this.axios.get(fulfillsInUrl, { headers });
if (resp.status === 200) {
this.fulfillersToThis = this.fulfillersToThis.concat(resp.data.data);
this.fulfillersToHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve more plans that fullfill 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 plans that fulfull this project.",
},
5000,
);
console.error(
"Something went wrong retrieving more plans that fulfill 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,
); );
} }
@@ -680,7 +821,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, await this.getIdentity(this.activeDid)); this.loadProject(projectId, this.activeDid);
} }
getOpenStreetMapUrl() { getOpenStreetMapUrl() {
@@ -697,8 +838,13 @@ export default class ProjectViewView extends Vue {
); );
} }
openGiftDialog(contact?: GiverInputInfo) { openGiftDialog(contact?: GiverReceiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(contact); (this.$refs.customGiveDialog as GiftedDialog).open(
contact,
undefined,
undefined,
"Given by " + (contact?.name || "someone not named"),
);
} }
openOfferDialog() { openOfferDialog() {
@@ -708,7 +854,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-gives", name: "contact-gift",
}; };
this.$router.push(route); this.$router.push(route);
} }
@@ -736,10 +882,15 @@ export default class ProjectViewView extends Vue {
claim: offer.fullClaim, claim: offer.fullClaim,
issuer: offer.offeredByDid, issuer: offer.offeredByDid,
}; };
const giver: GiverInputInfo = { const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord), did: libsUtil.offerGiverDid(offerRecord),
}; };
(this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId); (this.$refs.customGiveDialog as GiftedDialog).open(
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
@@ -780,55 +931,68 @@ 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) {
if (confirm("Do you personally confirm that this is true?")) { // similar logic is found in endorser-mobile
// similar logic is found in endorser-mobile const goodClaim = serverUtil.removeSchemaContext(
const goodClaim = serverUtil.removeSchemaContext( serverUtil.removeVisibleToDids(
serverUtil.removeVisibleToDids( serverUtil.addLastClaimOrHandleAsIdIfMissing(
serverUtil.addLastClaimOrHandleAsIdIfMissing( give.fullClaim,
give.fullClaim, give.jwtId,
give.jwtId, give.handleId,
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,
); );
const confirmationClaim: serverUtil.GenericVerifiableCredential = { } else {
"@context": "https://schema.org", console.error("Got error submitting the confirmation:", result);
"@type": "AgreeAction", const message =
object: goodClaim, (result.error?.error as string) ||
}; "There was a problem submitting the confirmation. See logs for more info.";
const result = await serverUtil.createAndSubmitClaim( this.$notify(
confirmationClaim, {
await this.getIdentity(this.activeDid), group: "alert",
this.apiServer, type: "danger",
this.axios, title: "Error",
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="showProjects" v-if="isRegistered && showProjects"
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full" class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()" @click="onClickNewProject()"
> >
<fa icon="plus" class="fa-fw"></fa> <fa icon="plus" class="fa-fw"></fa>
@@ -79,6 +79,13 @@
<!-- 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"
@@ -86,12 +93,12 @@
: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 w-12"> <div v-if="offer.fulfillsPlanHandleId" class="flex-none">
<ProjectIcon <ProjectIcon
:entityId="offer.fulfillsPlanHandleId" :entityId="offer.fulfillsPlanHandleId"
: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 max-h-12 max-w-12"
></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
@@ -177,8 +184,16 @@
</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"
@@ -189,12 +204,13 @@
@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 w-12"> <div class="flex-none">
<ProjectIcon <ProjectIcon
:entityId="project.handleId" :entityId="project.handleId"
:iconSize="48" :iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md" :imageUrl="project.image"
></ProjectIcon> class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div> </div>
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
@@ -211,19 +227,22 @@
</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 { OfferSummaryRecord, PlanData } from "@/libs/endorserServer"; import {
getHeaders,
OfferSummaryRecord,
PlanData,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
@Component({ @Component({
@@ -231,11 +250,18 @@ 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;
@@ -243,44 +269,25 @@ 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);
const activeDid: string = (settings?.activeDid as string) || ""; this.activeDid = (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.$notify( this.errNote("You need an identifier to load your projects.");
{
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.$notify( this.errNote("Something went wrong loading your projects.");
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
} }
} }
@@ -289,20 +296,23 @@ 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, token: string) { async projectDataLoader(url: 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 }); const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
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, issuerDid, rowid } = plan; const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid }); this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowid,
});
} }
} else { } else {
console.error( console.error(
@@ -310,28 +320,12 @@ export default class ProjectsView extends Vue {
resp.status, resp.status,
resp.data, resp.data,
); );
this.$notify( this.errNote("Failed to get projects from the server.");
{
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.$notify( this.errNote("Got an error loading projects.");
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading projects.",
},
-1,
);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
@@ -345,7 +339,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.currentIid, this.activeDid,
`beforeId=${latestProject.rowid}`, `beforeId=${latestProject.rowid}`,
); );
} }
@@ -353,30 +347,12 @@ export default class ProjectsView extends Vue {
/** /**
* Load projects initially * Load projects initially
* @param identifier of the user * @param issuerDid of the user
* @param urlExtra additional url parameters in a string * @param urlExtra additional url parameters in a string
**/ **/
async loadProjects(identifier?: IIdentifier, urlExtra: string = "") { async loadProjects(activeDid?: string, 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}`;
const token: string = await accessToken(identity); await this.projectDataLoader(url);
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;
} }
/** /**
@@ -414,16 +390,13 @@ 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, token: string) { async offerDataLoader(url: string) {
const headers: { [key: string]: string } = { const headers = getHeaders(this.activeDid);
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
try { try {
this.isLoading = true; this.isLoading = true;
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
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(
@@ -465,20 +438,18 @@ 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.currentIid, `&beforeId=${latestOffer.jwtId}`); await this.loadOffers(this.activeDid, `&beforeId=${latestOffer.jwtId}`);
} }
} }
/** /**
* Load offers initially * Load offers initially
* @param identifier of the user * @param issuerDid of the user
* @param urlExtra additional url parameters in a string * @param urlExtra additional url parameters in a string
**/ **/
async loadOffers(identifier?: IIdentifier, urlExtra: string = "") { async loadOffers(issuerDid?: string, urlExtra: string = "") {
const identity = identifier || this.currentIid; const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${issuerDid}${urlExtra}`;
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${identity.did}${urlExtra}`; await this.offerDataLoader(url);
const token: string = await accessToken(identity);
await this.offerDataLoader(url, token);
} }
public computedOfferTabClassNames() { public computedOfferTabClassNames() {

View File

@@ -97,6 +97,7 @@ 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,
@@ -123,7 +124,8 @@ 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;
@@ -131,7 +133,7 @@ export default class QuickActionBvcBeginView extends Vue {
const timeResult = await createAndSubmitGive( const timeResult = await createAndSubmitGive(
axios, axios,
apiServer, apiServer,
identity, activeDid,
activeDid, activeDid,
undefined, undefined,
undefined, undefined,
@@ -152,7 +154,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.",
}, },
-1, 5000,
); );
} }
} }
@@ -162,7 +164,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),
identity, activeDid,
apiServer, apiServer,
axios, axios,
); );
@@ -179,7 +181,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.",
}, },
-1, 5000,
); );
} }
} }
@@ -198,7 +200,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Success", title: "Success",
text: actions, text: actions,
}, },
-1, 3000,
); );
} }
@@ -210,9 +212,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 claims.", text: error.userMessage || "There was an error sending the claims.",
}, },
-1, 5000,
); );
} }
} }

View File

@@ -82,6 +82,16 @@
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>
@@ -128,28 +138,25 @@
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 },
@@ -165,6 +172,7 @@ 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[] = [];
@@ -202,16 +210,7 @@ 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 account: Account | undefined = await accountsDB.accounts const headers = await getHeaders(this.activeDid);
.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 +
@@ -223,7 +222,7 @@ export default class QuickActionBvcBeginView extends Vue {
); );
if (!response.ok) { if (!response.ok) {
console.log("Bad response", response); console.error("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) => {
@@ -236,6 +235,7 @@ 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.",
}, },
-1, 5000,
); );
} }
this.loadingConfirms = false; this.loadingConfirms = false;
@@ -263,7 +263,7 @@ export default class QuickActionBvcBeginView extends Vue {
async record() { async record() {
try { try {
const identity = await libsUtil.getIdentity(this.activeDid); this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
// in parallel, make a confirmation for each selected claim and send them all to the server // 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,9 +274,8 @@ 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(
identity, this.activeDid,
record.claim as GenericVerifiableCredential, record.claim as GenericVerifiableCredential,
record.id, record.id,
record.handleId, record.handleId,
@@ -300,7 +299,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.`,
}, },
-1, 5000,
); );
} }
@@ -310,7 +309,7 @@ export default class QuickActionBvcBeginView extends Vue {
const giveResult = await createAndSubmitGive( const giveResult = await createAndSubmitGive(
axios, axios,
this.apiServer, this.apiServer,
identity, this.activeDid,
undefined, undefined,
this.activeDid, this.activeDid,
this.description, this.description,
@@ -330,7 +329,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.",
}, },
-1, 5000,
); );
} }
} }
@@ -355,7 +354,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Success", title: "Success",
text: actions, text: actions,
}, },
-1, 3000,
); );
} }
@@ -369,7 +368,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.",
}, },
-1, 5000,
); );
} }
} }

View File

@@ -70,12 +70,9 @@ 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

@@ -0,0 +1,204 @@
<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">
Start Here Generate an Identity
</h1> </h1>
</div> </div>
@@ -25,33 +25,58 @@
<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">
Do you want a new identifier of your own? How do you want to create this identifier?
</p> </p>
<p class="text-center font-light"> <p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
If you haven't used this before, click "Yes" to generate a new A <strong>passkey</strong> is easy to manage, though it is less
identifier. interoperable with other systems for advanced uses.
<a
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
target="_blank"
>
<fa icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p> </p>
<p class="text-center mb-4 font-light"> <p class="text-center font-light mt-4">
Only click "No" if you have a seed of 12 or 24 words generated A <strong>new seed</strong> allows you full control over the keys,
elsewhere. though you are responsible for backups.
<a
href="https://www.perplexity.ai/search/what-is-a-seed-phrase-OqiP9foVRXidr_2le5OFKA"
target="_blank"
>
<fa icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p> </p>
<a <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
@click="onClickYes()" <a
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" v-if="PASSKEYS_ENABLED"
> @click="onClickNewPasskey()"
Yes, generate one 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"
</a> >
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> Generate one with a passkey
</a>
<a
@click="onClickNewSeed()"
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
>
Generate one with a new seed
</a>
</div>
<p class="text-center font-light mt-4">
You can also import an existing seed or derive a new address from an
existing seed.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<a <a
@click="onClickNo()" @click="onClickNo()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" 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"
> >
No, I have a seed You 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-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" class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"
> >
Derive new address from existing seed Derive new address from existing seed
</a> </a>
@@ -63,23 +88,41 @@
<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 onClickYes() { public onClickNewSeed() {
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 class="mb-8"> <div>
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2> <h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
<button <button
@click=" @click="
@@ -153,13 +153,307 @@
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 vue.config.js configureWebpack apply plugin // getNotificationCount is injected from safari-notifications.js at build time by the sw_combine.js script
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
message = await getNotificationCount(); message = await getNotificationCount();
} }
@@ -131,8 +131,32 @@ self.addEventListener("notificationclick", (event) => {
); );
}); });
// 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,6 +566,20 @@ 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

@@ -16,7 +16,30 @@ export default defineConfig({
srcDir: '.', srcDir: '.',
filename: 'sw_scripts-combined.js', filename: 'sw_scripts-combined.js',
manifest: { 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, 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/*'],
},
],
},
},
}, },
}), }),
], ],