Compare commits

..

250 Commits

Author SHA1 Message Date
4f97010f99 fix tests, add test for offer update 2024-08-18 13:48:07 -06:00
f38edff942 allow editing of an offer 2024-08-17 19:59:02 -06:00
73c82aefe2 start with offer-edit 2024-08-16 15:58:54 -06:00
7df6668dc6 put BTC before BX in unit rotation 2024-08-15 19:41:18 -06:00
60e2d549cc fix destination page after photo is shared 2024-08-14 08:56:57 -06:00
e5155a3da1 add recipient description to offers in user's list 2024-08-12 20:38:54 -06:00
b922675491 misc commentary 2024-08-12 18:51:41 -06:00
53e77e46dd fix list of offers (and some other lists), and add tests for offers 2024-08-12 09:25:01 -06:00
8c652ab29b change back the check for adding a service worker because tests would get constant errors 2024-08-12 09:23:25 -06:00
06d9052386 bump version and add "-beta" 2024-08-12 09:19:15 -06:00
0e2c4ed08b bump to version 0.3.17 2024-08-12 09:17:32 -06:00
86063b27e8 remove "export" that's not available in raw JS 2024-08-11 19:01:34 -06:00
57fe2cbe13 fix image shared with web share 2024-08-11 08:17:27 -06:00
6b4b3642f9 record some info on my attempt to test a service worker 2024-08-10 20:09:49 -06:00
844a462482 bump version and add "-beta" 2024-08-10 16:11:28 -06:00
d52f0a106a bump to version 0.3.16 2024-08-10 16:08:34 -06:00
a001f2fde3 fix linting, and give instructions for current test suite 2024-08-10 13:37:31 -06:00
5ad933f1c6 fix a test, add potential-failing comment 2024-08-10 08:11:31 -06:00
93caec3719 add image on entries in a project 2024-08-09 07:55:31 -06:00
e30e43d762 show image on the view-claim screen 2024-08-09 07:27:29 -06:00
c69c3a7126 refactor confirmation section to show together and more cleanly 2024-08-09 07:20:01 -06:00
bdb544a624 fix error sharing image and failing to upload, fix upload in webkit/safari, and test it 2024-08-08 08:51:25 -06:00
Jose Olarte III
c8bdaa10eb Playwright: added ID to spinbutton 2024-08-08 15:47:19 +08:00
f17f830453 tweak instructions for minimal test data 2024-08-07 09:25:30 -06:00
Jose Olarte III
761c49de45 (Switch back to test server) 2024-08-07 22:06:36 +08:00
Jose Olarte III
6474ae1f4b Playwright: expended contact test 2024-08-07 21:51:24 +08:00
Jose Olarte III
5fef073839 Playwright: test against created records 2024-08-06 20:12:34 +08:00
Jose Olarte III
a2164d8791 Playwright: added import 2024-08-06 16:20:52 +08:00
Jose Olarte III
128b18ab56 Playwright: removed redundant tests 2024-08-05 19:59:40 +08:00
Jose Olarte III
3da4b2bf9e Playwright: combined no-ID tests 2024-08-05 19:59:25 +08:00
Jose Olarte III
5da836c47c Playwright: implemented importUser 2024-08-05 19:58:40 +08:00
Jose Olarte III
43965e2ea7 Playwright: importUser function 2024-08-05 19:56:42 +08:00
2e6bd3bd9f bump version and add "-beta" 2024-08-04 20:26:56 -06:00
d3e5ac5c37 bump to version 0.3.15, fix a README instruction 2024-08-04 20:01:26 -06:00
db1291836e remove unused ethr-did-resolver (since it has vulerabilities and we're not using it and we can use the local one) 2024-08-04 19:59:02 -06:00
e0c50dcf62 add 'isRegistered' check to guard against many buttons 2024-08-04 19:56:10 -06:00
6bac80a280 move pointers to other projects up in the project view 2024-08-04 19:09:11 -06:00
61fffbb13e add a test for empty ID, fix some linting 2024-08-04 07:30:35 -06:00
0abe3aebee remove unused code 2024-08-02 20:38:21 -06:00
1ca61d72c9 comment out a breaking test on local data & enhance those instructions 2024-08-02 18:58:39 -06:00
Jose Olarte III
0f7d13ebf9 Playwright: check ID generation 2024-07-31 19:44:44 +08:00
Jose Olarte III
8008504828 Playwright: additional checks to add contact 2024-07-30 19:13:18 +08:00
Jose Olarte III
2aead1b4b1 ID-specific locators 2024-07-30 18:50:53 +08:00
Jose Olarte III
37d4e36561 Mirrored browser selection 2024-07-30 16:36:11 +08:00
Jose Olarte III
a410836539 Added IDs for Playwright targeting 2024-07-30 16:35:55 +08:00
5334c5970b make instructions for an Endorser server started from scratch 2024-07-29 19:19:07 -06:00
Jose Olarte III
421101a2c9 Playwright: check usage limits (no-ID and with-ID) 2024-07-29 19:09:56 +08:00
Jose Olarte III
ef2430319d Playwright: confirm contact appears on home feed 2024-07-29 19:09:35 +08:00
Jose Olarte III
36faf15a62 Corrected some test labels 2024-07-29 17:01:50 +08:00
710e00fdc2 add visibility flag set, refactor to see results, and add copy icons for contact info 2024-07-28 20:15:36 -06:00
b2545e2f76 move copy icon for DIDs on contact screen 2024-07-28 17:53:09 -06:00
44ac98faa8 tweak verbiage and make other UI tweaks 2024-07-28 17:09:57 -06:00
d4cafd2f79 fix where it doesn't remove the plan when editing and removing it 2024-07-28 17:09:06 -06:00
de2b0e1940 fix problem detecting plans when editing gifts 2024-07-28 17:08:44 -06:00
361000e59b hide the details of a claim by default 2024-07-27 18:38:52 -06:00
ff35e53367 show full contact details, plus other tweaks 2024-07-27 18:22:22 -06:00
77ce5c8ca7 add a config for local testing, plus add mobile testing and some instructions 2024-07-27 16:52:44 -06:00
e8e5c70843 fix one linting error 2024-07-26 19:34:22 -06:00
4472c3fbdd import & update selected contacts 2024-07-26 19:12:12 -06:00
Jose Olarte III
0d73106d0e Playwright: check no-ID messaging 2024-07-26 18:51:28 +08:00
Jose Olarte III
cfb1906b5b Playwright: check test API 2024-07-26 18:16:20 +08:00
b742857940 remove example test file 2024-07-25 18:40:12 -06:00
Jose Olarte III
3d4babb280 Optimize tests 2024-07-25 16:53:33 +08:00
Jose Olarte III
c695bec8e3 Merge branch 'master' into test-playwright 2024-07-25 14:41:43 +08:00
9ca7363388 add help text, both in general and for download 2024-07-24 20:06:49 -06:00
44041cac92 enhance seed-backup with clipboard copy & more info 2024-07-24 19:23:25 -06:00
Jose Olarte III
8ce439f78a New test 2024-07-24 22:09:10 +08:00
3b772f8b4a make the list of all claims show a link to each specific claim 2024-07-23 20:58:19 -06:00
59820a2f01 add more type casts 2024-07-23 20:57:10 -06:00
d724d8093c add ability to edit a GiveAction 2024-07-23 20:14:07 -06:00
Jose Olarte III
71ef3718c8 More tests added 2024-07-23 21:26:05 +08:00
6456ce8dcc refactor out unused DB reference 2024-07-20 07:24:22 -06:00
5ad8a2d2ba await all of the db.settings updates 2024-07-20 07:19:27 -06:00
Jose Olarte III
190732fb00 Filename-based sequence 2024-07-20 17:03:57 +08:00
Jose Olarte III
cd3cbda801 Switched to baseURL 2024-07-20 16:36:16 +08:00
Jose Olarte III
72472e9d5e Simplify 2024-07-20 15:16:54 +08:00
Jose Olarte III
1fdb4bfe8c Check activity feed 2024-07-20 15:15:08 +08:00
Jose Olarte III
357b8df364 Cleanup 2024-07-20 14:55:55 +08:00
41a9c65afb fix linting 2024-07-19 21:15:56 -06:00
4e1df0eeee Merge branch 'passkey-cache' 2024-07-19 20:53:37 -06:00
4270374a67 create an identifier by default, while letting them choose if passkeys are enabled 2024-07-19 20:49:43 -06:00
9b9254cc13 Merge pull request 'docs: add tlmgr font packages' (#122) from kentbull/crowd-funder-for-time-pwa:kent/docs-update-tlmgr-packages into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#122
2024-07-19 20:00:06 -04:00
Kent Bull
2fb8601e3a docs: add tlmgr font packages 2024-07-19 17:59:54 -06:00
4272c45b9e rename "docs" directory to "doc" 2024-07-19 14:40:48 -06:00
47274a9e7c add instructions to run tests, and fix linting (for WebStorm) 2024-07-19 14:35:48 -06:00
b2ebc2992b cache the passkey JWANT access token for multiple signatures 2024-07-19 12:44:54 -06:00
41a33398b0 Merge pull request 'docs: basic pandoc setup' (#118) from kentbull/crowd-funder-for-time-pwa:kb/add-usage-guide into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#118
2024-07-19 12:47:18 -04:00
Jose Olarte III
27501f0898 Create Project automation + test 2024-07-19 19:21:48 +08:00
Jose Olarte III
1642f1e748 Cleanup 2024-07-19 17:40:22 +08:00
Jose Olarte III
6e82db7cff Updated test directory 2024-07-18 20:00:34 +08:00
Jose Olarte III
8702ad0d22 Tesdt: validate copy contact info to clipboard 2024-07-18 19:56:40 +08:00
Jose Olarte III
fdb2fae3b9 Test: new ID from seed phrase 2024-07-18 19:56:12 +08:00
Jose Olarte III
14c501d124 Playwright install 2024-07-18 19:55:57 +08: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: trent_larson/crowd-funder-for-time-pwa#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: trent_larson/crowd-funder-for-time-pwa#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
acd5593c95 Merge branch 'master' into kb/add-usage-guide 2024-06-26 13:19:58 -04:00
Kent Bull
d4a9e7e364 docs: finish initial boostrapping dev guide 2024-06-26 11:06:18 -06:00
Kent Bull
91875e7305 docs: add more docs on local run 2024-06-25 19:30:29 -06:00
Kent Bull
abd751d562 docs: basic pandoc setup 2024-06-25 09:25:58 -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: trent_larson/crowd-funder-for-time-pwa#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: trent_larson/crowd-funder-for-time-pwa#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: trent_larson/crowd-funder-for-time-pwa#114
2024-04-19 17:36:53 -04:00
6d6e5266b4 make the home screen elements load more quickly 2024-04-19 15:37:10 -06:00
581a374b05 show contact's or user's icon in more places 2024-04-19 11:39:01 -06:00
1009574721 crop the image and store online and in settings 2024-04-18 20:27:43 -06:00
50cae65214 add photo to profile page (not yet saved) 2024-04-17 20:07:09 -06:00
48a46cf6f1 fix contact sorting to show those without names 2024-04-17 19:29:17 -06:00
60b2bf35fb update ClickUp link to a public link 2024-04-17 11:05:34 -06:00
cb5a7135ac remove tasks here in favor of ClickUp 2024-04-16 20:13:04 -06:00
a7a9e35766 note that tasks have moved 2024-04-11 20:43:52 -06:00
f029835e15 bump version and add "-beta" 2024-04-10 19:40:16 -06:00
017a172df3 bump to v 0.3.7 2024-04-10 19:32:46 -06:00
7837122a95 open the app when notification is clicked 2024-04-10 19:31:14 -06:00
0093255246 fix PWA creation & service-worker registration, plus some commentary tweaks 2024-04-09 20:29:21 -06:00
30bd53fb6f remove non-working interests, enhance error messages, update tasks & changelog 2024-04-09 17:54:17 -06:00
ca22930012 Merge pull request 'vitejs refactor' (#110) from jsnbuchanan/crowd-funder-for-time-pwa:feat/vitejs into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#110
2024-04-09 19:49:48 -04:00
c7c5bda014 Merge pull request 'misc tweaks for new vite build' (#4) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent3 into feat/vitejs
Reviewed-on: jsnbuchanan/crowd-funder-for-time-pwa#4
2024-04-09 06:05:55 -04:00
19aa572c95 misc tweaks for new vite build 2024-04-07 18:12:33 -06:00
03fae5dd95 Merge pull request 'A couple small fixes, plus a merge from master' (#1) from trentlarson/crowd-funder-from-jason:feat/vitejs-trent into feat/vitejs
Reviewed-on: jsnbuchanan/crowd-funder-for-time-pwa#1
2024-04-07 13:52:43 -04:00
80818a8861 remove a lingering debug console.log 2024-04-07 11:39:13 -06:00
d29a8d9637 fix title of the test app 2024-04-07 11:32:53 -06:00
f0b0231515 add linting before any build 2024-04-07 11:22:20 -06:00
b73d2a3b58 fix linting 2024-04-07 11:02:54 -06:00
22cba5babf Merge remote-tracking branch 'original-origin/master' into feat/vitejs-trent 2024-04-07 09:41:14 -06:00
708ac51f23 avoid a huge error message in a likely-well-known scenario 2024-04-07 09:24:55 -06:00
a91ffc88b9 reorder home page vapid check to avoid an error on localhost 2024-04-07 09:16:42 -06:00
d727c2841b add missing Dexie import (which causes failure upon download click) 2024-04-07 09:13:32 -06:00
226a97732d on home page, change the filtered button color 2024-04-06 17:58:10 -06:00
c94dd7743b Merge pull request 'ui-additions-2024-03' (#113) from ui-additions-2024-03 into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#113
2024-04-06 19:46:16 -04:00
64e38cb8ff Merge branch 'master' into ui-additions-2024-03 2024-04-06 17:45:32 -06:00
e61ac31710 show in description when recipient is a project (not just Anonymous) 2024-04-06 17:39:40 -06:00
3fbf68b117 filter by selections (now all working), add cache for plans 2024-04-06 14:01:18 -06:00
d4390483d9 Merge pull request 'send a time for notifications to the push server' (#112) from notify-time into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#112
2024-04-03 22:04:36 -04:00
8dea2091af Merge pull request 'ui-fixes-2024-03' (#111) from ui-fixes-2024-03 into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#111
2024-04-03 22:04:02 -04:00
e3696e3ac5 feed filter: save the changed values to the DB, go to map if no location chosen, reload if necessary 2024-04-03 19:54:01 -06:00
Jose Olarte III
027825b155 Names and variables for filter toggles 2024-04-03 15:51:23 +08:00
911203c190 adjust more code to the PushSubscriptionJSON 2024-04-02 19:32:41 -06:00
2da0394003 adjust the notification-subscription objects to try and send correct info 2024-04-02 19:18:31 -06:00
4a65d095db add adjustment to UTC hour for notification time 2024-04-02 18:38:44 -06:00
8ea5779312 update tasks 2024-04-01 19:06:18 -06:00
144ab76716 add logic to send a time for notifications 2024-04-01 19:04:54 -06:00
Jose Olarte III
8da2c8cc30 Additions to Account View 2024-03-29 21:41:14 +08:00
Jose Olarte III
570b31e2d6 Removed one more 2024-03-29 15:55:16 +08:00
Jose Olarte III
07f542ca16 Filter options reduced for release 2024-03-29 15:53:46 +08:00
Jose Olarte III
62e0fc51c2 Feed filters dialog 2024-03-27 19:57:31 +08:00
Jose Olarte III
94b600e527 Map fix #2 2024-03-26 21:38:21 +08:00
Jose Olarte III
5388e6052c Button width changes
For buttons that are next to each other
2024-03-26 19:55:16 +08:00
Jose Olarte III
21fe5a0279 Optimized grid space for wider screens 2024-03-26 17:12:55 +08:00
Jose Olarte III
ffba89a7b5 Fixed map z-index 2024-03-26 16:54:43 +08:00
Jose Olarte III
31954d2690 Added close icon to gifted prompts dialog 2024-03-26 16:02:24 +08:00
340d0a5219 refactor tasks 2024-03-25 19:03:01 -06:00
2d2785d6a0 docs: adding do for updated development server run command
- `npm run dev`
2024-03-25 08:15:04 -06:00
41d6e5fc73 fix: buffer typescript error in util.ts when parsing ArrayBuffer 2024-03-25 08:10:38 -06:00
7412d67c33 bump version and add -beta 2024-03-24 19:04:24 -06:00
83db5302ad bump to version 0.3.6 2024-03-24 18:28:42 -06:00
75f9f20ea3 fix check for more camera-device options 2024-03-24 18:27:06 -06:00
e43c45ebea add onboarding help instructions as separate page 2024-03-24 17:01:53 -06:00
708032311a Merge pull request 'add button during photo to switch to mirror mode' (#109) from photo-reverse into master
Reviewed-on: trent_larson/crowd-funder-for-time-pwa#109
2024-03-24 18:59:50 -04:00
5dead960ae fix: es modules syntax for buffer deps instead of commonjs require 2024-03-24 13:05:22 -06:00
12d81b79c7 chore: update vitejs config to deploy on the same default port as the @vue/cli-service
This port is 8080. This is done to not break existing tooling and devops code.
2024-03-24 12:05:09 -06:00
f3dc81e6eb fix: AccountViewView.vue template not resolving dep for dexie-export-import/dist/import
Previous error:

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

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

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

View File

@@ -1,4 +1,4 @@
# Only the variables that start with VITE_ are seen in the application process.env in Vue. # Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

27
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

4
.gitignore vendored
View File

@@ -27,3 +27,7 @@ pnpm-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -6,7 +6,41 @@ 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).
## [0.3.14] ## ?
### Fixed
- List of offers wasn't showing.
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
### Added
- Photos on more screens
### Fixed
- Share of a photo, including sharing a photo from webkit/Safari which never worked
### Changed in DB or environment
- Nothing (though there's a new temp field in IndexedDB)
## [0.3.15] - 2024.08.04 - c8f0f2c2b16b9f0b4b47d40f7bf29058c7baa68e
### Added
- Edit gives
- Page to edit claim JSON before submitting
- Update of imported contacts
- Improve messaging on give dialog
- Section for gives provided by plan
- Deletion of an identity
- UI for choosing a passkey creation (not enabled on prod)
- Cache signatures for reports for passkey-signed requests
- Refactor: consolidate alternative signing, eg. for passkeys & did:peer
- Playwright tests
### Changed
- Linked projects display below description (instead of at bottom)
### Fixed
- Visibility toggle appearance
### Changed in DB or environment
- Nothing
## [0.3.14] - 2024.06.22 - 1611d22892f683f43856d2503eee7f391b6bbce8
### Added ### Added
- Clearer give-confirmation screen - Clearer give-confirmation screen
- BX currency https://thebx.medium.com/ - BX currency https://thebx.medium.com/

View File

@@ -2,5 +2,10 @@
Welcome! We are happy to have your help with this project. Welcome! We are happy to have your help with this project.
Note that all contributions will be under our We expect contributions to include automated tests and pass linting. Run the `test-all` task.
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE). Note that some previous features don't have tests and adding more will make you friends quick.
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
If you want to see a code of conduct, we're probably not the people you want to hang with.
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.

View File

@@ -39,15 +39,17 @@ npm run lint
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`. * Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Record what version is currently on production. * Record what version is currently on production.
* Run the correct build: * Run the correct build:
* Test * Staging
``` ```
# (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 VITE_PASSKEYS_ENABLED=yep npm run build
``` ```
* Production * Production
@@ -56,7 +58,7 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https:
npm run build npm run build
``` ```
* Get on the server and back up 3 DBs and the time-safari folder. * Get on the server and back up the time-safari/dist folder.
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari` * `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
@@ -69,6 +71,41 @@ npm run build
## Tests ## Tests
### Automated
Use the locally running Endorser server:
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
```
test/test.sh
NODE_ENV=test-local npm run dev
```
* Now run the local tests:
```
npm run test-all
```
Note that a test will sometimes fail and rerunning may succeed (and repeat if a different test fails).
It's possible to use the global test Endorser (ledger) server (but currently the tests don't all succeed):
`npx playwright test`
It's possible to run with a minimal set of data: the following starts with the bare minimum of test data (but currently the tests don't all succeed):
```
rm ../endorser-ch-test-local.sqlite3
NODE_ENV=test-local npm run flyway migrate
NODE_ENV=test-local npm run test test/controller0
NODE_ENV=test-local npm run dev
```
### Register new user on test server ### Register new user on test server
On the test server, User #0 has rights to register others, so you can start On the test server, User #0 has rights to register others, so you can start

View File

@@ -7,45 +7,55 @@ This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew.
### Set Up ### Set Up
```bash ```bash
# See https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x
brew install pandoc brew install pandoc
brew install basictex brew install basictex
pandoc keystore-migration.md -o keystore-migration.pdf
# Setting up LaTex packages # Setting up LaTex packages
# First update tlmgr # First update tlmgr
sudo tlmgr update --self sudo tlmgr update --self
# Then install LaTex packages # Then install LaTex packages
sudo tlmgr install titlesec
sudo tlmgr install framed
sudo tlmgr install threeparttable
sudo tlmgr install wrapfig
sudo tlmgr install multirow
sudo tlmgr install enumitem
sudo tlmgr install bbding sudo tlmgr install bbding
sudo tlmgr install titling # Required for the fancy headers used sudo tlmgr install enumitem
sudo tlmgr install tabu sudo tlmgr install environ
sudo tlmgr install fancyhdr
sudo tlmgr install framed
sudo tlmgr install import
sudo tlmgr install lastpage # Enables Page X of Y
sudo tlmgr install mdframed sudo tlmgr install mdframed
sudo tlmgr install multirow
sudo tlmgr install needspace
sudo tlmgr install ntheorem
sudo tlmgr install tabu
sudo tlmgr install tcolorbox sudo tlmgr install tcolorbox
sudo tlmgr install textpos sudo tlmgr install textpos
sudo tlmgr install import sudo tlmgr install titlesec
sudo tlmgr install varwidth sudo tlmgr install titling # Required for the fancy headers used
sudo tlmgr install needspace sudo tlmgr install threeparttable
sudo tlmgr install tocloft # Required for \tableofcontents generation
sudo tlmgr install ntheorem
sudo tlmgr install environ
sudo tlmgr install trimspaces sudo tlmgr install trimspaces
sudo tlmgr install lastpage # Enables Page X of Y sudo tlmgr install tocloft # Required for \tableofcontents generation
sudo tlmgr install collection-fontsrecommended # And set up fonts sudo tlmgr install varwidth
sudo tlmgr install libertine # The main font the doc uses sudo tlmgr install wrapfig
# Install fonts
sudo tlmgr install cmbright
sudo tlmgr install collection-fontsrecommended # And set up fonts
sudo tlmgr install fira
sudo tlmgr install fontaxes
sudo tlmgr install libertine # The main font the doc uses
sudo tlmgr install opensans
sudo tlmgr install sourceserifpro
``` ```
#### References
The following guide was adapted to this project except that we install with Brew and have a few more packages.
Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x
### Usage ### Usage
Use the `pandoc` command to generate a PDF. Use the `pandoc` command to generate a PDF.

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 463 KiB

200
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.18-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.18-beta",
"dependencies": { "dependencies": {
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
@@ -39,7 +39,6 @@
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@@ -69,9 +68,11 @@
"web-did-resolver": "^2.0.27" "web-did-resolver": "^2.0.27"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.45.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^20.14.11",
"@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",
@@ -3207,31 +3208,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@ethersproject/abi": {
"version": "5.7.0",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/address": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/hash": "^5.7.0",
"@ethersproject/keccak256": "^5.7.0",
"@ethersproject/logger": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"@ethersproject/strings": "^5.7.0"
}
},
"node_modules/@ethersproject/abstract-provider": { "node_modules/@ethersproject/abstract-provider": {
"version": "5.7.0", "version": "5.7.0",
"funding": [ "funding": [
@@ -3385,32 +3361,6 @@
"@ethersproject/bignumber": "^5.7.0" "@ethersproject/bignumber": "^5.7.0"
} }
}, },
"node_modules/@ethersproject/contracts": {
"version": "5.7.0",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"@ethersproject/abstract-provider": "^5.7.0",
"@ethersproject/abstract-signer": "^5.7.0",
"@ethersproject/address": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/logger": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"@ethersproject/transactions": "^5.7.0"
}
},
"node_modules/@ethersproject/hash": { "node_modules/@ethersproject/hash": {
"version": "5.7.0", "version": "5.7.0",
"funding": [ "funding": [
@@ -3548,60 +3498,6 @@
"@ethersproject/logger": "^5.7.0" "@ethersproject/logger": "^5.7.0"
} }
}, },
"node_modules/@ethersproject/providers": {
"version": "5.7.2",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/abstract-provider": "^5.7.0",
"@ethersproject/abstract-signer": "^5.7.0",
"@ethersproject/address": "^5.7.0",
"@ethersproject/base64": "^5.7.0",
"@ethersproject/basex": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/hash": "^5.7.0",
"@ethersproject/logger": "^5.7.0",
"@ethersproject/networks": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"@ethersproject/random": "^5.7.0",
"@ethersproject/rlp": "^5.7.0",
"@ethersproject/sha2": "^5.7.0",
"@ethersproject/strings": "^5.7.0",
"@ethersproject/transactions": "^5.7.0",
"@ethersproject/web": "^5.7.0",
"bech32": "1.1.4",
"ws": "7.4.6"
}
},
"node_modules/@ethersproject/random": {
"version": "5.7.0",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/logger": "^5.7.0"
}
},
"node_modules/@ethersproject/rlp": { "node_modules/@ethersproject/rlp": {
"version": "5.7.0", "version": "5.7.0",
"funding": [ "funding": [
@@ -6191,6 +6087,21 @@
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/unts"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz",
"integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==",
"dev": true,
"dependencies": {
"playwright": "1.45.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pvermeer/dexie-encrypted-addon": { "node_modules/@pvermeer/dexie-encrypted-addon": {
"version": "3.0.0", "version": "3.0.0",
"license": "MIT", "license": "MIT",
@@ -8767,8 +8678,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.11.30", "version": "20.14.11",
"license": "MIT", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz",
"integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
@@ -10465,10 +10377,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/bech32": {
"version": "1.1.4",
"license": "MIT"
},
"node_modules/better-opn": { "node_modules/better-opn": {
"version": "3.0.2", "version": "3.0.2",
"license": "MIT", "license": "MIT",
@@ -12813,24 +12721,6 @@
"ethr-did-resolver": "10.1.5" "ethr-did-resolver": "10.1.5"
} }
}, },
"node_modules/ethr-did-resolver": {
"version": "8.1.2",
"license": "Apache-2.0",
"dependencies": {
"@ethersproject/abi": "^5.6.3",
"@ethersproject/abstract-signer": "^5.6.2",
"@ethersproject/address": "^5.6.1",
"@ethersproject/basex": "^5.6.1",
"@ethersproject/bignumber": "^5.6.2",
"@ethersproject/bytes": "^5.6.1",
"@ethersproject/contracts": "^5.6.2",
"@ethersproject/keccak256": "^5.6.1",
"@ethersproject/providers": "^5.6.8",
"@ethersproject/signing-key": "^5.6.2",
"@ethersproject/transactions": "^5.6.2",
"did-resolver": "^4.0.1"
}
},
"node_modules/ethr-did/node_modules/@noble/ciphers": { "node_modules/ethr-did/node_modules/@noble/ciphers": {
"version": "0.5.1", "version": "0.5.1",
"license": "MIT", "license": "MIT",
@@ -18099,6 +17989,50 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz",
"integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==",
"dev": true,
"dependencies": {
"playwright-core": "1.45.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz",
"integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"license": "MIT", "license": "MIT",
@@ -22287,6 +22221,8 @@
"node_modules/ws": { "node_modules/ws": {
"version": "7.4.6", "version": "7.4.6",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8.3.0" "node": ">=8.3.0"
}, },

View File

@@ -1,13 +1,15 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.18-beta",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js" "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
"test-local": "npx playwright test -c playwright.config-local.ts --trace on",
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on"
}, },
"dependencies": { "dependencies": {
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
@@ -41,7 +43,6 @@
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@@ -71,9 +72,11 @@
"web-did-resolver": "^2.0.27" "web-did-resolver": "^2.0.27"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.45.2",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^20.14.11",
"@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

@@ -0,0 +1,98 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test-playwright',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:8080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
/* Configure global timeout; default is 30000 milliseconds */
// the image upload will often not succeed at 5 seconds
//timeout: 10000,
/* Run your local dev server before starting the tests */
/**
* This could be an array of servers, meaning we could start the Endorser server as well:
* {
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
* url: 'http://localhost:3000',
* reuseExistingServer: !process.env.CI,
* },
*
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
* in the user's settings so that it can be blanked out and the default is used.
*/
webServer: {
command:
"VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
url: "http://localhost:8080",
reuseExistingServer: !process.env.CI,
},
});

82
playwright.config.ts Normal file
View File

@@ -0,0 +1,82 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test-playwright',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'https://test.timesafari.app',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command:
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
// url: "http://localhost:8080",
// reuseExistingServer: !process.env.CI,
// },
});

View File

@@ -181,6 +181,7 @@
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
@@ -192,7 +193,7 @@
" "
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2" 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 No {{ notification.noText ? ", " + notification.noText : "" }}
</button> </button>
<label <label

View File

@@ -133,18 +133,18 @@ export default class FeedFilters extends Vue {
this.visible = true; this.visible = true;
} }
toggleHasVisibleDid() { async toggleHasVisibleDid() {
this.settingChanged = true; this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid; this.hasVisibleDid = !this.hasVisibleDid;
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid, filterFeedByVisible: this.hasVisibleDid,
}); });
} }
toggleNearby() { async toggleNearby() {
this.settingChanged = true; this.settingChanged = true;
this.isNearby = !this.isNearby; this.isNearby = !this.isNearby;
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby, filterFeedByNearby: this.isNearby,
}); });
} }
@@ -154,7 +154,7 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false, filterFeedByNearby: false,
filterFeedByVisible: false, filterFeedByVisible: false,
}); });
@@ -168,7 +168,7 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true, filterFeedByNearby: true,
filterFeedByVisible: true, filterFeedByVisible: true,
}); });

View File

@@ -24,6 +24,7 @@
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
id="inputGivenAmount"
type="number" type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20" class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput" v-model="amountInput"
@@ -54,7 +55,7 @@
}" }"
class="text-blue-500" class="text-blue-500"
> >
Photo & Details ... Photo & more options ...
</router-link> </router-link>
</span> </span>
</div> </div>
@@ -287,13 +288,12 @@ export default class GiftedDialog extends Vue {
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 as string,
this.receiver?.did as string, recipientDid as string,
description, description,
amount, amount,
unitCode, unitCode,

View File

@@ -4,6 +4,7 @@
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1> <h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
<input <input
type="text" type="text"
data-testId="inputDescription"
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="Description, prerequisites, terms, etc." placeholder="Description, prerequisites, terms, etc."
v-model="description" v-model="description"
@@ -23,6 +24,7 @@
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
data-testId="inputOfferAmount"
type="number" type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center" class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="amountInput" v-model="amountInput"
@@ -34,18 +36,27 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</div> </div>
<div class="flex flex-row mt-2"> <div class="mt-4 flex justify-center">
<span <span>
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2" <router-link
> :to="{
Expiration name: 'offer-details',
query: {
amountInput,
description,
offererDid: activeDid,
projectId,
projectName,
recipientDid,
recipientName,
unitCode: amountUnitCode,
},
}"
class="text-blue-500"
>
Conditions & more options...
</router-link>
</span> </span>
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="datePlaceholder()"
v-model="expirationDateInput"
/>
</div> </div>
<p class="text-center mt-6 mb-2 italic"> <p class="text-center mt-6 mb-2 italic">
Sign & Send to publish to the world Sign & Send to publish to the world
@@ -69,7 +80,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon";
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@@ -82,7 +92,8 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId? = ""; @Prop projectId?;
@Prop projectName?;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
@@ -92,13 +103,15 @@ export default class OfferDialog extends Vue {
description = ""; description = "";
expirationDateInput = ""; expirationDateInput = "";
recipientDid? = ""; recipientDid? = "";
recipientName? = "";
visible = false; visible = false;
libsUtil = libsUtil; libsUtil = libsUtil;
async open(recipientDid?: string) { async open(recipientDid?: string, recipientName?: string) {
try { try {
this.recipientDid = recipientDid; this.recipientDid = recipientDid;
this.recipientName = recipientName;
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;
@@ -144,12 +157,6 @@ export default class OfferDialog extends Vue {
)}`; )}`;
} }
datePlaceholder() {
return (
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
);
}
cancel() { cancel() {
this.close(); this.close();
this.eraseValues(); this.eraseValues();
@@ -223,11 +230,10 @@ 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,

View File

@@ -126,7 +126,6 @@ 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";
@@ -348,10 +347,10 @@ export default class PhotoDialog extends Vue {
this.blob = (await cropper?.getBlob()) || undefined; 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,
// axios fills in Content-Type of multipart/form-data
}; };
const formData = new FormData(); const formData = new FormData();
if (!this.blob) { if (!this.blob) {

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",
@@ -32,6 +36,9 @@ export const DEFAULT_PUSH_SERVER =
export const IMAGE_TYPE_PROFILE = "profile"; 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
@@ -41,8 +48,10 @@ export interface NotificationIface {
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>; onCancel?: (stopAsking: boolean) => Promise<void>;
onNo?: (stopAsking: boolean) => Promise<void>; onNo?: (stopAsking: boolean) => Promise<void>;
onYes?: () => Promise<void>; onYes?: () => Promise<void>;
promptToStopAsking?: boolean; promptToStopAsking?: boolean;
yesText?: string;
} }

View File

@@ -51,8 +51,8 @@ db.version(2).stores({
db.version(3).stores(TempSchema); 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", async () => {
db.settings.add({ await db.settings.add({
id: MASTER_SETTINGS_KEY, id: MASTER_SETTINGS_KEY,
apiServer: DEFAULT_ENDORSER_API_SERVER, apiServer: DEFAULT_ENDORSER_API_SERVER,
}); });

View File

@@ -26,6 +26,7 @@ export type Settings = {
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
@@ -37,6 +38,7 @@ export type Settings = {
}>; }>;
showContactGivesInline?: boolean; // Display contact inline or not showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server warnIfProdServer?: boolean; // Warn if using a production server
@@ -45,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);
} }
/** /**
@@ -59,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;

View File

@@ -2,7 +2,8 @@
export type Temp = { export type Temp = {
id: string; id: string;
blob?: Blob; blob?: Blob; // deprecated because webkit (Safari) does not support Blob
blobB64?: string; // base64-encoded blob
}; };
/** /**

View File

@@ -3,11 +3,13 @@ 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'";
@@ -83,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:
@@ -175,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(privateKeyHexString)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
export function decodeEndorserJwt(jwt: string): JWTDecoded {
return didJwt.decodeJWT(jwt);
}

View File

@@ -1,7 +1,5 @@
import asn1 from "asn1-ber";
import { Buffer } from "buffer/"; import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x"; import { JWTPayload } from "did-jwt";
import { bytesToMultibase, JWTPayload, multibaseToBytes } from "did-jwt";
import { DIDResolutionResult } from "did-resolver"; import { DIDResolutionResult } from "did-resolver";
import { sha256 } from "ethereum-cryptography/sha256.js"; import { sha256 } from "ethereum-cryptography/sha256.js";
import { import {
@@ -21,10 +19,15 @@ import {
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/types"; } from "@simplewebauthn/types";
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers"; import { AppString } from "@/constants/app";
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers";
import {
arrayToBase64Url,
cborToKeys,
peerDidToPublicKeyBytes,
verifyPeerSignature,
} from "@/libs/crypto/vc/didPeer";
const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
export interface JWK { export interface JWK {
kty: string; kty: string;
crv: string; crv: string;
@@ -32,20 +35,12 @@ export interface JWK {
y: string; y: string;
} }
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export async function registerCredential(passkeyName?: string) { export async function registerCredential(passkeyName?: string) {
const options: PublicKeyCredentialCreationOptionsJSON = const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({ await generateRegistrationOptions({
rpName: "Time Safari", rpName: AppString.APP_NAME,
rpID: window.location.hostname, rpID: window.location.hostname,
userName: passkeyName || "Time Safari User", userName: passkeyName || AppString.APP_NAME + " User",
// Don't prompt users for additional information about the authenticator // Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX) // (Recommended for smoother UX)
attestationType: "none", attestationType: "none",
@@ -74,7 +69,7 @@ export async function registerCredential(passkeyName?: string) {
const credIdBase64Url = verification.registrationInfo?.credentialID as string; const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) { if (attResp.rawId !== credIdBase64Url) {
console.log("Warning! The raw ID does not match the credential ID.") console.log("Warning! The raw ID does not match the credential ID.");
} }
const credIdHex = Buffer.from( const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url), base64URLStringToArrayBuffer(credIdBase64Url),
@@ -92,21 +87,6 @@ export async function registerCredential(passkeyName?: string) {
}; };
} }
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;
}
function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
export class PeerSetup { export class PeerSetup {
public authenticatorData?: ArrayBuffer; public authenticatorData?: ArrayBuffer;
public challenge?: Uint8Array; public challenge?: Uint8Array;
@@ -117,13 +97,17 @@ export class PeerSetup {
issuerDid: string, issuerDid: string,
payload: object, payload: object,
credIdHex: string, credIdHex: string,
expMinutes: number = 1,
) { ) {
const credentialId = arrayBufferToBase64URLString( const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer, 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 = { const fullPayload = {
...payload, ...payload,
iat: Math.floor(Date.now() / 1000), exp: expiryTime,
iat: issuedAt,
iss: issuerDid, iss: issuerDid,
}; };
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
@@ -159,7 +143,8 @@ export class PeerSetup {
const dataInJwt = { const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url, AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url, ClientDataJSONB64URL: this.clientDataJsonBase64Url,
iat: Math.floor(Date.now() / 1000), exp: expiryTime,
iat: issuedAt,
iss: issuerDid, iss: issuerDid,
}; };
const dataInJwtString = JSON.stringify(dataInJwt); const dataInJwtString = JSON.stringify(dataInJwt);
@@ -178,10 +163,14 @@ export class PeerSetup {
issuerDid: string, issuerDid: string,
payload: object, payload: object,
credIdHex: string, 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 = { const fullPayload = {
...payload, ...payload,
iat: Math.floor(Date.now() / 1000), exp: expiryTime,
iat: issuedAt,
iss: issuerDid, iss: issuerDid,
}; };
const dataToSignString = JSON.stringify(fullPayload); const dataToSignString = JSON.stringify(fullPayload);
@@ -195,12 +184,12 @@ export class PeerSetup {
allowCredentials: [ allowCredentials: [
{ {
id: credentialId, id: credentialId,
type: "public-key", type: "public-key" as const,
}, },
], ],
challenge: this.challenge.buffer, challenge: this.challenge.buffer,
rpID: window.location.hostname, rpID: window.location.hostname,
userVerification: "preferred", userVerification: "preferred" as const,
}, },
}; };
@@ -209,7 +198,7 @@ export class PeerSetup {
this.authenticatorData = credential?.response.authenticatorData; this.authenticatorData = credential?.response.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString( const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData, this.authenticatorData as ArrayBuffer,
); );
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
@@ -227,7 +216,8 @@ export class PeerSetup {
const dataInJwt = { const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url, AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url, ClientDataJSONB64URL: this.clientDataJsonBase64Url,
iat: Math.floor(Date.now() / 1000), exp: expiryTime,
iat: issuedAt,
iss: issuerDid, iss: issuerDid,
}; };
const dataInJwtString = JSON.stringify(dataInJwt); const dataInJwtString = JSON.stringify(dataInJwt);
@@ -237,8 +227,9 @@ export class PeerSetup {
.replace(/\//g, "_") .replace(/\//g, "_")
.replace(/=+$/, ""); .replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature) const origSignature = Buffer.from(credential?.response.signature).toString(
.toString("base64") "base64",
);
this.signature = origSignature this.signature = origSignature
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_") .replace(/\//g, "_")
@@ -248,6 +239,9 @@ export class PeerSetup {
return jwt; 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 // return a low-level signing function, similar to createJWS approach
// async webAuthnES256KSigner(credentialID: string) { // async webAuthnES256KSigner(credentialID: string) {
// return async (data: string | Uint8Array) => { // return async (data: string | Uint8Array) => {
@@ -304,6 +298,16 @@ export class PeerSetup {
// } // }
} }
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. // I'd love to use this but it doesn't verify.
// Requires: // Requires:
// npm install @noble/curves // npm install @noble/curves
@@ -376,6 +380,7 @@ export async function verifyJwtSimplewebauthn(
return verification.verified; return verification.verified;
} }
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
export async function verifyJwtWebCrypto( export async function verifyJwtWebCrypto(
credId: Base64URLString, credId: Base64URLString,
issuerDid: string, issuerDid: string,
@@ -394,35 +399,10 @@ export async function verifyJwtWebCrypto(
// Construct the preimage // Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]); const preimage = Buffer.concat([authDataFromBase, hash]);
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
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,
finalSigBuffer,
preimage,
);
return verified;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) { if (!did.startsWith("did:peer:0z")) {
throw new Error( throw new Error(
@@ -431,13 +411,21 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
} }
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types // 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) // (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 id = did.split(":")[2];
const multibase = id.slice(1); const multibase = id.slice(1);
const encnumbasis = multibase.slice(1); const encnumbasis = multibase.slice(1);
const didDocument = { const didDocument = {
"@context": [ "@context": [
"https://www.w3.org/ns/did/v1", "https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1", "https://w3id.org/security/suites/secp256k1-2019/v1",
], ],
assertionMethod: [did + "#" + encnumbasis], assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis], authentication: [did + "#" + encnumbasis],
@@ -463,12 +451,15 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
} }
// convert COSE public key to PEM format // convert COSE public key to PEM format
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function COSEtoPEM(cose: Buffer) { function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm // const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format // 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]); const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format // Convert to PEM format
@@ -479,6 +470,7 @@ ${pubKeyBuffer.toString("base64")}
return pem; return pem;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecode(input: string) { function base64urlDecode(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/"); input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
@@ -490,13 +482,14 @@ function base64urlDecode(input: string) {
return bytes.buffer; return bytes.buffer;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncode(buffer: ArrayBuffer) { function base64urlEncode(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer)); const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
} }
// from @simplewebauthn/browser // from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer) { function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let str = ""; let str = "";
for (const charCode of bytes) { for (const charCode of bytes) {
@@ -520,31 +513,7 @@ function base64URLStringToArrayBuffer(base64URLString: string) {
return buffer; return buffer;
} }
function cborToKeys(publicKeyBytes: Uint8Array) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
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 };
}
async function pemToCryptoKey(pem: string) { async function pemToCryptoKey(pem: string) {
const binaryDerString = atob( const binaryDerString = atob(
pem pem

View File

@@ -55,8 +55,8 @@ export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
} }
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts // from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: unknown = undefined; let webCrypto: { subtle: SubtleCrypto } | undefined = undefined;
export function getWebCrypto() { export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
/** /**
* Hello there! If you came here wondering why this method is asynchronous when use of * 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 * `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
@@ -67,25 +67,28 @@ export function getWebCrypto() {
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense * TODO: If it's after February 2025 when you read this then consider whether it still makes sense
* to keep this method asynchronous. * to keep this method asynchronous.
*/ */
const toResolve = new Promise((resolve, reject) => { const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise(
if (webCrypto) { (resolve, reject) => {
return resolve(webCrypto); if (webCrypto) {
} return resolve(webCrypto);
/** }
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times /**
* support (and Node v20+) * 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) { const _globalThisCrypto =
webCrypto = _globalThisCrypto; _getWebCryptoInternals.stubThisGlobalThisCrypto();
return resolve(webCrypto); if (_globalThisCrypto) {
} webCrypto = _globalThisCrypto;
// We tried to access it both in Node and globally, so bail out return resolve(webCrypto);
return reject(new MissingWebCrypto()); }
}); // We tried to access it both in Node and globally, so bail out
return reject(new MissingWebCrypto());
},
);
return toResolve; return toResolve;
} }
export class MissingWebCrypto extends Error { class MissingWebCrypto extends Error {
constructor() { constructor() {
const message = "An instance of the Crypto API could not be located"; const message = "An instance of the Crypto API could not be located";
super(message); super(message);
@@ -93,10 +96,10 @@ export class MissingWebCrypto extends Error {
} }
} }
// Make it possible to stub return values during testing // Make it possible to stub return values during testing
export const _getWebCryptoInternals = { const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto, stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file // Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: unknown) => { setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => {
webCrypto = newCrypto; webCrypto = newCrypto;
}, },
}; };

View File

@@ -1,19 +1,13 @@
import { import { Axios, AxiosRequestConfig, AxiosResponse } from "axios";
Axios,
AxiosRequestConfig,
AxiosResponse,
RawAxiosRequestHeaders,
} from "axios";
import * as didJwt from "did-jwt";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import * as R from "ramda"; import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; import { 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 { NonsensitiveDexie } from "@/db/index"; import { NonsensitiveDexie } from "@/db/index";
import { getIdentity } from "@/libs/util"; import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util";
import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc";
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
@@ -54,29 +48,32 @@ export interface ClaimResult {
} }
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context": string; "@context"?: string; // optional when embedded, eg. in an Agree
"@type": string; "@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
} }
export interface GenericCredWrapper extends GenericVerifiableCredential { export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
"@context": string;
"@type": string;
claim: T;
claimType?: string;
handleId: string; handleId: string;
id: string; id: string;
issuedAt: string; issuedAt: string;
issuer: string; issuer: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any publicUrls?: Record<string, string>; // only for IDs that want to be public
claim: Record<string, any>;
claimType?: string;
} }
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = { export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
"@context": SCHEMA_ORG_CONTEXT, {
"@type": "", "@context": SCHEMA_ORG_CONTEXT,
claim: {}, "@type": "",
handleId: "", claim: { "@type": "" },
id: "", handleId: "",
issuedAt: "", id: "",
issuer: "", issuedAt: "",
}; issuer: "",
};
// a summary record; the VC is found the fullClaim field // a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord { export interface GiveSummaryRecord {
@@ -129,7 +126,7 @@ export interface PlanSummaryRecord {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4 // https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential { export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded, eg. in an Agree
"@type": "GiveAction"; "@type": "GiveAction";
agent?: { identifier: string }; agent?: { identifier: string };
@@ -143,13 +140,13 @@ export interface GiveVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8 // https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential { export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
"@type": "Offer"; "@type": "Offer";
description?: string; description?: string; // conditions for the offer
includesObject?: { amountOfThisGood: number; unitCode: string }; includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: { itemOffered?: {
description?: string; description?: string; // description of the item
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string }; isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
}; };
offeredBy?: { identifier: string }; offeredBy?: { identifier: string };
@@ -159,7 +156,7 @@ export interface OfferVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7 // https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential { export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org"; "@context": "https://schema.org";
"@type": "PlanAction"; "@type": "PlanAction";
name: string; name: string;
@@ -197,7 +194,7 @@ export interface PlanData {
*/ */
issuerDid: string; issuerDid: string;
/** /**
* The Identier of the project -- different from jwtId, needs to be fixed * The identifier of the project -- different from jwtId, needs to be fixed
**/ **/
rowid?: string; rowid?: string;
} }
@@ -271,10 +268,6 @@ export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN"; const HIDDEN_DID = "did:none:HIDDEN";
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
export function isDid(did: string) { export function isDid(did: string) {
return did.startsWith("did:"); return did.startsWith("did:");
} }
@@ -453,28 +446,79 @@ 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;
} }
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
/** /**
* @param handleId nullable, in which case "undefined" will be returned * @param handleId nullable, in which case "undefined" will be returned
* @param identity nullable, in which case 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> { ): Promise<PlanSummaryRecord | undefined> {
if (!handleId) { if (!handleId) {
return undefined; return undefined;
@@ -485,7 +529,7 @@ 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) {
@@ -519,19 +563,95 @@ 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 lastClaimId supplied when editing a previous claim
*/
export function hydrateGive(
vcClaimOrig?: GiveVerifiableCredential,
fromDid?: string,
toDid?: string,
description?: string,
amount?: number,
unitCode?: string,
fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
lastClaimId?: string,
): GiveVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: GiveVerifiableCredential = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "GiveAction",
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = description || undefined;
vcClaim.object =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
// ensure fulfills is an array
if (!Array.isArray(vcClaim.fulfills)) {
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
}
// ... and replace or add each element, ending with Trade or Donate
// I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction",
);
if (fulfillsProjectHandleId) {
vcClaim.fulfills.push({
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
});
}
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "Offer",
);
if (fulfillsOfferHandleId) {
vcClaim.fulfills.push({
"@type": "Offer",
identifier: fulfillsOfferHandleId,
});
}
// do Trade/Donate last because current endorser.ch only looks at the first for plans & offers
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) =>
elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction",
);
vcClaim.fulfills.push({ "@type": isTrade ? "TradeAction" : "DonateAction" });
vcClaim.image = imageUrl || undefined;
return vcClaim;
}
/**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* *
* @param identity
* @param fromDid may be null * @param fromDid may be null
* @param toDid * @param toDid
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
*/ */
export async function createAndSubmitGive( export async function createAndSubmitGive(
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
identity: IIdentifier, issuerDid: string,
fromDid?: string | null, fromDid?: string,
toDid?: string, toDid?: string,
description?: string, description?: string,
amount?: number, amount?: number,
@@ -541,90 +661,196 @@ export async function createAndSubmitGive(
isTrade: boolean = false, isTrade: boolean = false,
imageUrl?: string, imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: GiveVerifiableCredential = { const vcClaim = hydrateGive(
"@context": "https://schema.org", undefined,
"@type": "GiveAction", fromDid,
recipient: toDid ? { identifier: toDid } : undefined, toDid,
agent: fromDid ? { identifier: fromDid } : undefined, description,
description: description || undefined, amount,
object: amount unitCode,
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" } fulfillsProjectHandleId,
: undefined, fulfillsOfferHandleId,
fulfills: [{ "@type": isTrade ? "TradeAction" : "DonateAction" }], isTrade,
}; imageUrl,
if (fulfillsProjectHandleId) { undefined,
vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this );
vcClaim.fulfills.push({
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
});
}
if (fulfillsOfferHandleId) {
vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this
vcClaim.fulfills.push({
"@type": "Offer",
identifier: fulfillsOfferHandleId,
});
}
if (imageUrl) {
vcClaim.image = imageUrl;
}
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericCredWrapper, vcClaim as GenericVerifiableCredential,
identity, issuerDid,
apiServer, apiServer,
axios, axios,
); );
} }
/**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
*
* @param fromDid may be null
* @param toDid may be null if project is provided
* @param description may be null
* @param amount may be null
*/
export async function editAndSubmitGive(
axios: Axios,
apiServer: string,
fullClaim: GenericCredWrapper<GiveVerifiableCredential>,
issuerDid: string,
fromDid?: string,
toDid?: string,
description?: string,
amount?: number,
unitCode?: string,
fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateGive(
fullClaim.claim,
fromDid,
toDid,
description,
amount,
unitCode,
fulfillsProjectHandleId,
fulfillsOfferHandleId,
isTrade,
imageUrl,
fullClaim.id,
);
return createAndSubmitClaim(
vcClaim as GenericVerifiableCredential,
issuerDid,
apiServer,
axios,
);
}
/**
* Construct Offer VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateOffer(
vcClaimOrig?: OfferVerifiableCredential,
fromDid?: string,
toDid?: string,
itemDescription?: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
fulfillsProjectHandleId?: string,
validThrough?: string,
lastClaimId?: string,
): OfferVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: OfferVerifiableCredential = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "Offer",
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = conditionDescription || undefined;
vcClaim.includesObject =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
if (itemDescription || fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.description = itemDescription || undefined;
if (fulfillsProjectHandleId) {
vcClaim.itemOffered.isPartOf = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
}
vcClaim.validThrough = validThrough || undefined;
return vcClaim;
}
/** /**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* *
* @param identity * @param identity
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null) * @param validThrough ISO 8601 date string YYYY-MM-DD (may be null)
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null) * @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
*/ */
export async function createAndSubmitOffer( export async function createAndSubmitOffer(
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
identity: IIdentifier, issuerDid: string,
description?: string, itemDescription: string,
amount?: number, amount?: number,
unitCode?: string, unitCode?: string,
expirationDate?: string, conditionDescription?: string,
validThrough?: string,
recipientDid?: string, recipientDid?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = { const vcClaim = hydrateOffer(
"@context": "https://schema.org", undefined,
"@type": "Offer", issuerDid,
offeredBy: { identifier: identity.did }, recipientDid,
validThrough: expirationDate || undefined, itemDescription,
}; amount,
if (amount) { unitCode,
vcClaim.includesObject = { conditionDescription,
amountOfThisGood: amount, fulfillsProjectHandleId,
unitCode: unitCode || "HUR", validThrough,
}; undefined,
} );
if (description) {
vcClaim.itemOffered = { description };
}
if (recipientDid) {
vcClaim.recipient = { identifier: recipientDid };
}
if (fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericCredWrapper, vcClaim as OfferVerifiableCredential,
identity, issuerDid,
apiServer,
axios,
);
}
export async function editAndSubmitOffer(
axios: Axios,
apiServer: string,
fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
issuerDid: string,
itemDescription: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
validThrough?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateOffer(
fullClaim.claim,
issuerDid,
recipientDid,
itemDescription,
amount,
unitCode,
conditionDescription,
fulfillsProjectHandleId,
validThrough,
fullClaim.id,
);
return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential,
issuerDid,
apiServer, apiServer,
axios, axios,
); );
@@ -632,7 +858,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,
@@ -645,16 +871,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> {
@@ -667,41 +893,22 @@ 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}`,
}, },
}); });
return { type: "success", response }; return { type: "success", response };
// 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 creating claim:", error); console.error("Error submitting claim:", error);
const errorMessage: string = const errorMessage: string =
error.response?.data?.error?.message || error.response?.data?.error?.message ||
error.message || error.message ||
@@ -716,6 +923,14 @@ 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. * An AcceptAction is when someone accepts some contract or pledge.
* *
@@ -762,24 +977,29 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
similar code is also contained in endorser-mobile similar code is also contained in endorser-mobile
**/ **/
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const claimSummary = (claim: Record<string, any>) => { const claimSummary = (
claim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
if (!claim) { if (!claim) {
// to differentiate from "something" above // to differentiate from "something" above
return "something"; return "something";
} }
let specificClaim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential> = claim;
if (claim.claim) { if (claim.claim) {
// probably a Verified Credential // probably a Verified Credential
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
claim = claim.claim as Record<string, any>; specificClaim = claim.claim;
} }
if (Array.isArray(claim)) { if (Array.isArray(specificClaim)) {
if (claim.length === 1) { if (specificClaim.length === 1) {
claim = claim[0]; specificClaim = specificClaim[0];
} else { } else {
return "multiple claims"; return "multiple claims";
} }
} }
const type = claim["@type"]; const type = specificClaim["@type"];
if (!type) { if (!type) {
return "a claim"; return "a claim";
} else { } else {
@@ -800,7 +1020,7 @@ const claimSummary = (claim: Record<string, any>) => {
similar code is also contained in endorser-mobile similar code is also contained in endorser-mobile
**/ **/
export const claimSpecialDescription = ( export const claimSpecialDescription = (
record: GenericCredWrapper, record: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string, activeDid: string,
identifiers: Array<string>, identifiers: Array<string>,
contacts: Array<Contact>, contacts: Array<Contact>,
@@ -894,7 +1114,11 @@ export const claimSpecialDescription = (
"...]" "...]"
); );
} else { } else {
return issuer + " declared " + claimSummary(claim as GenericCredWrapper); return (
issuer +
" declared " +
claimSummary(claim as GenericCredWrapper<GenericVerifiableCredential>)
);
} }
}; };
@@ -919,18 +1143,31 @@ 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( export async function register(
activeDid: string, activeDid: string,
apiServer: string, apiServer: string,
axios: Axios, axios: Axios,
contact: Contact, contact: Contact,
) { ) {
const identity = await getIdentity(activeDid);
const vcClaim: RegisterVerifiableCredential = { const vcClaim: RegisterVerifiableCredential = {
"@context": "https://schema.org", "@context": SCHEMA_ORG_CONTEXT,
"@type": "RegisterAction", "@type": "RegisterAction",
agent: { identifier: identity.did }, agent: { identifier: activeDid },
object: SERVICE_ID, object: SERVICE_ID,
participant: { identifier: contact.did }, participant: { identifier: contact.did },
}; };
@@ -943,26 +1180,10 @@ export async function register(
}, },
}; };
// Create a signature using private key of identity // Create a signature using private key of identity
if (identity.keys[0].privateKeyHex == null) { const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload);
return { error: "Private key not found." };
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = apiServer + "/api/v2/claim"; const url = apiServer + "/api/v2/claim";
const headers = await getHeaders(identity); const resp = await axios.post(url, { jwtEncoded: vcJwt });
const resp = await axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) { if (resp.data?.success?.handleId) {
return { success: true }; return { success: true };
} else if (resp.data?.success?.embeddedRecordError) { } else if (resp.data?.success?.embeddedRecordError) {
@@ -991,15 +1212,17 @@ export async function setVisibilityUtil(
} }
const url = const url =
apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe"); apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe");
const identity = await getIdentity(activeDid); const headers = await getHeaders(activeDid);
const headers = await getHeaders(identity);
const payload = JSON.stringify({ did: contact.did }); const payload = JSON.stringify({ did: contact.did });
try { try {
const resp = await axios.post(url, payload, { headers }); const resp = await axios.post(url, payload, { headers });
if (resp.status === 200) { if (resp.status === 200) {
db.contacts.update(contact.did, { seesMe: visibility }); const success = resp.data.success;
return { success: true }; if (success) {
db.contacts.update(contact.did, { seesMe: visibility });
}
return { success };
} else { } else {
console.error( console.error(
"Got some bad server response when setting visibility: ", "Got some bad server response when setting visibility: ",
@@ -1021,16 +1244,16 @@ export async function setVisibilityUtil(
* *
* @param apiServer endorser server URL string * @param apiServer endorser server URL string
* @param axios Axios instance * @param axios Axios instance
* @param {IIdentifier} identity - The identity object to check rate limits for. * @param {string} issuerDid - The DID for which to check rate limits.
* @returns {Promise<AxiosResponse>} The Axios response object. * @returns {Promise<AxiosResponse>} The Axios response object.
*/ */
export async function fetchEndorserRateLimits( export async function fetchEndorserRateLimits(
apiServer: string, apiServer: string,
axios: Axios, axios: Axios,
identity: IIdentifier, issuerDid: string,
) { ) {
const url = `${apiServer}/api/report/rateLimits`; const url = `${apiServer}/api/report/rateLimits`;
const headers = await getHeaders(identity); const headers = await getHeaders(issuerDid);
return await axios.get(url, { headers } as AxiosRequestConfig); return await axios.get(url, { headers } as AxiosRequestConfig);
} }
@@ -1039,15 +1262,11 @@ export async function fetchEndorserRateLimits(
* *
* @param apiServer image server URL string * @param apiServer image server URL string
* @param axios Axios instance * @param axios Axios instance
* @param {IIdentifier} identity - The identity object to check rate limits for. * @param {string} issuerDid - The DID for which to check rate limits.
* @returns {Promise<AxiosResponse>} The Axios response object. * @returns {Promise<AxiosResponse>} The Axios response object.
*/ */
export async function fetchImageRateLimits( export async function fetchImageRateLimits(axios: Axios, issuerDid: string) {
apiServer: string,
axios: Axios,
identity: IIdentifier,
) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits"; const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
const headers = await getHeaders(identity); const headers = await getHeaders(issuerDid);
return await axios.get(url, { headers } as AxiosRequestConfig); return await axios.get(url, { headers } as AxiosRequestConfig);
} }

View File

@@ -1,24 +1,37 @@
// 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 {
containsHiddenDid,
GenericCredWrapper,
GenericVerifiableCredential,
OfferVerifiableCredential,
} 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.";
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
/* 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",
"BX": "BX",
"ETH": "ETH", "ETH": "ETH",
"HUR": "Hours", "HUR": "Hours",
"USD": "US $", "USD": "US $",
@@ -27,8 +40,8 @@ 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",
"BX": "Buxbe",
"ETH": "Ethereum", "ETH": "Ethereum",
"HUR": "hours", "HUR": "hours",
"USD": "dollars", "USD": "dollars",
@@ -72,7 +85,9 @@ export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
}; };
export const isGiveAction = (veriClaim: GenericCredWrapper) => { export const isGiveAction = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return veriClaim.claimType === "GiveAction"; return veriClaim.claimType === "GiveAction";
}; };
@@ -88,11 +103,13 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
* @param veriClaim is expected to have fields: claim, claimType, and issuer * @param veriClaim is expected to have fields: claim, claimType, and issuer
*/ */
export const isGiveRecordTheUserCanConfirm = ( export const isGiveRecordTheUserCanConfirm = (
veriClaim: GenericCredWrapper, isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string, activeDid: string,
confirmerIdList: string[] = [], confirmerIdList: string[] = [],
) => { ) => {
return ( return (
isRegistered &&
isGiveAction(veriClaim) && isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) && !confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid && veriClaim.issuer !== activeDid &&
@@ -100,13 +117,45 @@ export const isGiveRecordTheUserCanConfirm = (
); );
}; };
export async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
// Extract the content type and the Base64 data
const [metadata, base64] = base64DataUrl.split(",");
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
const byteCharacters = atob(base64);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: contentType });
}
/** /**
* @returns the DID of the person who offered, or undefined if hidden * @returns the DID of the person who offered, or undefined if hidden
* @param veriClaim is expected to have fields: claim and issuer * @param veriClaim is expected to have fields: claim and issuer
*/ */
export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = ( export const offerGiverDid: (
veriClaim, arg0: GenericCredWrapper<OfferVerifiableCredential>,
) => { ) => string | undefined = (veriClaim) => {
let giver; let giver;
if ( if (
veriClaim.claim.offeredBy?.identifier && veriClaim.claim.offeredBy?.identifier &&
@@ -123,8 +172,13 @@ export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = (
* @returns true if the user can fulfill the offer * @returns true if the user can fulfill the offer
* @param veriClaim is expected to have fields: claim, claimType, and issuer * @param veriClaim is expected to have fields: claim, claimType, and issuer
*/ */
export const canFulfillOffer = (veriClaim: GenericCredWrapper) => { export const canFulfillOffer = (
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim)); veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return !!(
veriClaim.claimType === "Offer" &&
offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
);
}; };
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid" // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
@@ -193,20 +247,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 identity ${activeDid} but no identifier was found`,
);
}
return identity;
}; };
/** /**
@@ -239,6 +290,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

@@ -2,6 +2,7 @@
import { register } from "register-service-worker"; import { register } from "register-service-worker";
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
if (import.meta.env.NODE_ENV === "production") { if (import.meta.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", { register("/sw_scripts-combined.js", {
ready() { ready() {

View File

@@ -38,6 +38,11 @@ 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",
@@ -58,6 +63,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-gift", name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"), component: () => import("../views/ContactGiftingView.vue"),
}, },
{
path: "/contact-import",
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
},
{ {
path: "/contact-qr", path: "/contact-qr",
name: "contact-qr", name: "contact-qr",
@@ -81,7 +91,7 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: "/gifted-details", path: "/gifted-details",
name: "gifted-details", name: "gifted-details",
component: () => import("../views/GiftedDetails.vue"), component: () => import("@/views/GiftedDetailsView.vue"),
}, },
{ {
path: "/help", path: "/help",
@@ -133,6 +143,11 @@ const routes: Array<RouteRecordRaw> = [
name: "new-identifier", name: "new-identifier",
component: () => import("../views/NewIdentifierView.vue"), component: () => import("../views/NewIdentifierView.vue"),
}, },
{
path: "/offer-details/:id?",
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{ {
path: "/project/:id?", path: "/project/:id?",
name: "project", name: "project",
@@ -179,6 +194,9 @@ const routes: Array<RouteRecordRaw> = [
name: "shared-photo", name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"), component: () => import("@/views/SharedPhotoView.vue"),
}, },
// /share-target is also an endpoint in the service worker
{ {
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";

View File

@@ -37,6 +37,7 @@
<!-- ID notice --> <!-- ID notice -->
<div <div
v-if="!activeDid" v-if="!activeDid"
id="noticeBeforeShare"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
> >
<p class="mb-4"> <p class="mb-4">
@@ -52,7 +53,10 @@
</div> </div>
<!-- Identity Details --> <!-- Identity Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div
id="sectionIdentityDetails"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div v-if="givenName"> <div v-if="givenName">
<h2 class="text-xl font-semibold mb-2"> <h2 class="text-xl font-semibold mb-2">
{{ givenName }} {{ givenName }}
@@ -152,7 +156,7 @@
<div class="text-blue-500 text-sm font-bold"> <div class="text-blue-500 text-sm font-bold">
<router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }"> <router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }">
Activity Your Activity
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -161,6 +165,7 @@
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. --> <!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
<div <div
v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime" v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
id="noticeBeforeAnnounce"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
> >
<p class="mb-4"> <p class="mb-4">
@@ -175,7 +180,10 @@
</router-link> </router-link>
</div> </div>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"> <div
id="sectionNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<!-- label --> <!-- label -->
<div class="mb-2 font-bold">Notifications</div> <div class="mb-2 font-bold">Notifications</div>
<div <div
@@ -211,13 +219,15 @@
</router-link> </router-link>
</div> </div>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"> <div
id="sectionSearchLocation"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<!-- label --> <!-- label -->
<div class="mb-2 font-bold">Location</div> <div class="mb-2 font-bold">Location for Searches</div>
<router-link <router-link
:to="{ name: 'search-area' }" :to="{ name: 'search-area' }"
v-if="activeDid" class="block w-full text-center text-m 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 mb-2 mt-6"
class="block w-full text-center text-m 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-6"
> >
Set Search Area Set Search Area
<!-- If already set, change button label to "Change Search Area" --> <!-- If already set, change button label to "Change Search Area" -->
@@ -226,6 +236,7 @@
<div <div
v-if="activeDid" v-if="activeDid"
id="sectionUsageLimits"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
> >
<div class="mb-2 font-bold">Usage Limits</div> <div class="mb-2 font-bold">Usage Limits</div>
@@ -278,19 +289,22 @@
</button> </button>
</div> </div>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"> <div
id="sectionDataExport"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div class="mb-2 font-bold">Data Export</div> <div class="mb-2 font-bold">Data Export</div>
<router-link <router-link
:to="{ name: 'seed-backup' }" :to="{ name: 'seed-backup' }"
v-if="activeDid" v-if="activeDid"
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" class="block w-full text-center text-md 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 mb-2 mt-2"
> >
Backup Identifier Seed Backup Identifier Seed
</router-link> </router-link>
<button <button
v-bind:class="computedStartDownloadLinkClassNames()" v-bind:class="computedStartDownloadLinkClassNames()"
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" class="block w-full text-center text-md 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"
@click="exportDatabase()" @click="exportDatabase()"
> >
Download Settings & Contacts Download Settings & Contacts
@@ -304,6 +318,23 @@
> >
If no download happened yet, click again here to download now. If no download happened yet, click again here to download now.
</a> </a>
<div>
<p>
After the download, you can save the file in your preferred storage
location.
</p>
<ul>
<li class="list-disc list-outside ml-4">
On iOS: Choose "More..." and select a place in iCloud, or go "Back"
and save to another location.
</li>
<li class="list-disc list-outside ml-4">
On Android: Choose "Open" and then share
<fa icon="share-nodes" class="fa-fw" />
to your prefered place.
</li>
</ul>
</div>
</div> </div>
<!-- id used by puppeteer test script --> <!-- id used by puppeteer test script -->
@@ -314,7 +345,7 @@
> >
Advanced Advanced
</h3> </h3>
<div v-if="showAdvanced"> <div id="sectionAdvanced" v-if="showAdvanced || showGeneralAdvanced">
<p class="text-rose-600 mb-8"> <p class="text-rose-600 mb-8">
Beware: the features here can be confusing and even change data in ways Beware: the features here can be confusing and even change data in ways
you do not expect. But we support your freedom! you do not expect. But we support your freedom!
@@ -324,7 +355,10 @@
<span class="text-slate-500 text-sm font-bold mb-2"> <span class="text-slate-500 text-sm font-bold mb-2">
Deep Identifier Details Deep Identifier Details
</span> </span>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div
id="sectionDeepIdentifier"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div> <div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
<div <div
class="text-sm text-slate-500 flex justify-start items-center mb-1" class="text-sm text-slate-500 flex justify-start items-center mb-1"
@@ -359,6 +393,7 @@
<div class="text-slate-500 text-sm font-bold">Derivation Path</div> <div class="text-slate-500 text-sm font-bold">Derivation Path</div>
<div <div
v-if="derivationPath"
class="text-sm text-slate-500 flex justify-start items-center mb-1" class="text-sm text-slate-500 flex justify-start items-center mb-1"
> >
<code class="truncate">{{ derivationPath }}</code> <code class="truncate">{{ derivationPath }}</code>
@@ -375,6 +410,12 @@
</button> </button>
<span v-show="showDerCopy">Copied</span> <span v-show="showDerCopy">Copied</span>
</div> </div>
<div
v-else
class="text-sm text-slate-500 flex justify-start items-center mb-1"
>
(none)
</div>
</div> </div>
<!-- id used by puppeteer test script --> <!-- id used by puppeteer test script -->
@@ -386,6 +427,34 @@
Switch Identifier Switch Identifier
</router-link> </router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">
Import Contacts & Settings Database
</h2>
<div class="ml-4 mt-2">
<input type="file" @change="uploadImportFile" class="ml-2" />
<div v-if="showContactImport()" class="mt-4">
<button
class="block text-center text-md 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 mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
<button
class="block text-center text-md 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 mb-6"
@click="checkContactImports()"
>
Import Contacts
<br />
after comparing
</button>
</div>
</div>
</div>
<label <label
for="toggleShowAmounts" for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4" class="flex items-center justify-between cursor-pointer my-4"
@@ -412,7 +481,7 @@
</div> </div>
</label> </label>
<div> <div id="sectionClaimServer">
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2> <h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
<div class="px-4 py-4"> <div class="px-4 py-4">
<input <input
@@ -491,7 +560,7 @@
<h2 class="text-slate-500 text-sm font-bold mb-2"> <h2 class="text-slate-500 text-sm font-bold mb-2">
Notification Push Server Notification Push Server
</h2> </h2>
<div class="px-3 py-4"> <div id="sectionNotificationPushServer" class="px-3 py-4">
<input <input
type="text" type="text"
class="block w-full rounded border border-slate-400 px-3 py-2" class="block w-full rounded border border-slate-400 px-3 py-2"
@@ -529,7 +598,7 @@
{{ DEFAULT_PUSH_SERVER }} {{ DEFAULT_PUSH_SERVER }}
</span> </span>
<div class="mt-2"> <div id="sectionImageServerURL" class="mt-2">
<span class="text-slate-500 text-sm font-bold">Image Server URL</span> <span class="text-slate-500 text-sm font-bold">Image Server URL</span>
&nbsp; &nbsp;
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span> <span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
@@ -583,27 +652,6 @@
</div> </div>
</label> </label>
<div class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">
Contacts & Settings Database
</h2>
<div class="ml-4 mt-2">
Import
<input type="file" @change="uploadImportFile" class="ml-2" />
<div v-if="showContactImport()">
<button
class="block text-center text-md 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 mb-6"
@click="confirmSubmitImportFile()"
>
Import Settings & Contacts
<br />
(excluding Identifier Data)
</button>
</div>
</div>
</div>
<div class="flex mt-4"> <div class="flex mt-4">
<button> <button>
<router-link <router-link
@@ -614,19 +662,70 @@
</router-link> </router-link>
</button> </button>
</div> </div>
<div id="sectionPasskeyExpiration" class="flex justify-between">
<span>
<span class="text-slate-500 text-sm font-bold mb-2">
Passkey Expiration Minutes
</span>
<br />
<span class="text-sm ml-2">
{{ passkeyExpirationDescription }}
</span>
</span>
<div class="relative ml-2">
<input
type="number"
class="border border-slate-400 rounded px-2 py-2 text-center w-20"
v-model="passkeyExpirationMinutes"
@change="updatePasskeyExpiration"
/>
</div>
</div>
<label
for="toggleShowGeneralAdvanced"
class="flex items-center justify-between cursor-pointer mt-4"
@click="toggleShowGeneralAdvanced"
>
<!-- label -->
<span class="text-slate-500 text-sm font-bold">
Show All General Advanced Functions
</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="showGeneralAdvanced"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/>
</div>
</label>
</div> </div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import Dexie from "dexie"; import Dexie from "dexie";
import "dexie-export-import"; import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import"; import { ImportProgress } from "dexie-export-import/dist/import";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import EntityIcon from "@/components/EntityIcon.vue";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; 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";
@@ -638,25 +737,24 @@ import {
NotificationIface, NotificationIface,
} from "@/constants/app"; } from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { Account } from "@/db/tables/accounts";
import { accessToken } from "@/libs/crypto"; import { Contact } from "@/db/tables/contacts";
import { IIdentifier } from "@veramo/core";
import { import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
MASTER_SETTINGS_KEY,
Settings,
} from "@/db/tables/settings";
import {
clearPasskeyToken,
ErrorResponse, ErrorResponse,
EndorserRateLimits, EndorserRateLimits,
ImageRateLimits,
fetchEndorserRateLimits, fetchEndorserRateLimits,
fetchImageRateLimits, fetchImageRateLimits,
getHeaders,
ImageRateLimits,
tokenExpiryTimeDescription,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { Buffer } from "buffer/"; import { getAccount } from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue";
interface IAccount {
did: string;
publicKeyHex: string;
privateHex?: string;
derivationPath: string;
}
const inputImportFileNameRef = ref<Blob>(); const inputImportFileNameRef = ref<Blob>();
@@ -677,31 +775,35 @@ export default class AccountViewView extends Vue {
downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor downloadUrl = ""; // because DuckDuckGo doesn't download on automated call to "click" on the anchor
endorserLimits: EndorserRateLimits | null = null; endorserLimits: EndorserRateLimits | null = null;
givenName = ""; givenName = "";
hideRegisterPromptOnNewContact = false;
imageLimits: ImageRateLimits | null = null; imageLimits: ImageRateLimits | null = null;
imageServer = "";
isRegistered = false; isRegistered = false;
isSubscribed = false; isSubscribed = false;
limitsMessage = "";
loadingLimits = false;
notificationMaybeChanged = false; notificationMaybeChanged = false;
passkeyExpirationDescription = "";
passkeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
previousPasskeyExpirationMinutes = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
profileImageUrl?: string; profileImageUrl?: string;
publicHex = ""; publicHex = "";
publicBase64 = ""; publicBase64 = "";
showLargeIdenticonId?: string; showAdvanced = false;
showLargeIdenticonUrl?: string; showB64Copy = false;
webPushServer = "";
webPushServerInput = "";
limitsMessage = "";
loadingLimits = false;
showContactGives = false; showContactGives = false;
showDidCopy = false; showDidCopy = false;
showDerCopy = false; showDerCopy = false;
showB64Copy = false; showGeneralAdvanced = false;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
showPubCopy = false; showPubCopy = false;
showAdvanced = false;
hideRegisterPromptOnNewContact = false;
showShortcutBvc = false; showShortcutBvc = false;
subscription: PushSubscription | null = null; subscription: PushSubscription | null = null;
warnIfProdServer = false; warnIfProdServer = false;
warnIfTestServer = false; warnIfTestServer = false;
webPushServer = "";
webPushServerInput = "";
/** /**
* Async function executed when the component is mounted. * Async function executed when the component is mounted.
@@ -712,25 +814,42 @@ export default class AccountViewView extends Vue {
*/ */
async mounted() { async mounted() {
try { try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
// Initialize component state with values from the database or defaults // Initialize component state with values from the database or defaults
this.initializeState(settings); await this.initializeState();
await this.processIdentity();
// Get and process the identity this.passkeyExpirationDescription = tokenExpiryTimeDescription();
const identity = await this.getIdentity(this.activeDid);
if (identity) {
this.processIdentity(identity);
}
/**
* Beware! I've seen where this "ready" never resolves.
*/
const registration = await navigator.serviceWorker.ready; const registration = await navigator.serviceWorker.ready;
this.subscription = await registration.pushManager.getSubscription(); this.subscription = await registration.pushManager.getSubscription();
this.isSubscribed = !!this.subscription; this.isSubscribed = !!this.subscription;
console.log("Got to the end of 'mounted' call.");
/**
* Beware! I've seen where we never get to this point because "ready" never resolves.
*/
} catch (error) { } catch (error) {
console.error("Mount error:", error); // this can happen when running automated tests in dev mode because notifications don't work
this.handleError(error); console.error(
"Telling user to clear cache at page create because:",
error,
);
// this sometimes gives different information on the error
console.error(
"Telling user to clear cache at page create because (error added): " +
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Account",
text: "Clear your cache and start over (after data backup).",
},
-1,
);
} }
} }
@@ -742,9 +861,12 @@ export default class AccountViewView extends Vue {
/** /**
* Initializes component state with values from the database or defaults. * Initializes component state with values from the database or defaults.
* @param {SettingsType} settings - Object containing settings from the database.
*/ */
initializeState(settings: Settings | undefined) { async initializeState() {
await db.open();
const settings: Settings | undefined =
await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || ""; this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || ""; this.apiServer = (settings?.apiServer as string) || "";
this.apiServerInput = (settings?.apiServer as string) || ""; this.apiServerInput = (settings?.apiServer as string) || "";
@@ -752,10 +874,16 @@ export default class AccountViewView extends Vue {
(settings?.firstName || "") + (settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3 (settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
this.imageServer = (settings?.imageServer as string) || "";
this.profileImageUrl = settings?.profileImageUrl as string; this.profileImageUrl = settings?.profileImageUrl as string;
this.showContactGives = !!settings?.showContactGivesInline; this.showContactGives = !!settings?.showContactGivesInline;
this.hideRegisterPromptOnNewContact = this.hideRegisterPromptOnNewContact =
!!settings?.hideRegisterPromptOnNewContact; !!settings?.hideRegisterPromptOnNewContact;
this.passkeyExpirationMinutes =
(settings?.passkeyExpirationMinutes as number) ??
DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.showGeneralAdvanced = !!settings?.showGeneralAdvanced;
this.showShortcutBvc = !!settings?.showShortcutBvc; this.showShortcutBvc = !!settings?.showShortcutBvc;
this.warnIfProdServer = !!settings?.warnIfProdServer; this.warnIfProdServer = !!settings?.warnIfProdServer;
this.warnIfTestServer = !!settings?.warnIfTestServer; this.warnIfTestServer = !!settings?.warnIfTestServer;
@@ -763,49 +891,6 @@ export default class AccountViewView extends Vue {
this.webPushServerInput = (settings?.webPushServer as string) || ""; this.webPushServerInput = (settings?.webPushServer as string) || "";
} }
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
try {
// Open the accounts database
await accountsDB.open();
// Search for the account with the matching DID (decentralized identifier)
const account: { identity?: string } | undefined =
await accountsDB.accounts.where("did").equals(activeDid).first();
// Return parsed identity or null if not found
return JSON.parse((account?.identity as string) || "null");
} catch (error) {
console.error("Failed to find account:", error);
return null;
}
}
/**
* Asynchronously retrieves headers for HTTP requests.
*
* @param {IIdentifier} identity - The identity object for which to generate the headers.
* @returns {Promise<Record<string,string>>} A Promise that resolves to an object containing the headers.
*
* @throws Will throw an error if unable to generate an access token.
*/
public async getHeaders(
identity: IIdentifier,
): Promise<Record<string, string>> {
try {
const token = await accessToken(identity);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
return headers;
} catch (error) {
console.error("Failed to get headers:", error);
return Promise.reject(error);
}
}
// call fn, copy text to the clipboard, then redo fn after 2 seconds // call fn, copy text to the clipboard, then redo fn after 2 seconds
doCopyTwoSecRedo(text: string, fn: () => void) { doCopyTwoSecRedo(text: string, fn: () => void) {
fn(); fn();
@@ -819,6 +904,11 @@ export default class AccountViewView extends Vue {
this.updateShowContactAmounts(); this.updateShowContactAmounts();
} }
toggleShowGeneralAdvanced() {
this.showGeneralAdvanced = !this.showGeneralAdvanced;
this.updateShowGeneralAdvanced();
}
toggleProdWarning() { toggleProdWarning() {
this.warnIfProdServer = !this.warnIfProdServer; this.warnIfProdServer = !this.warnIfProdServer;
this.updateWarnIfProdServer(this.warnIfProdServer); this.updateWarnIfProdServer(this.warnIfProdServer);
@@ -840,25 +930,19 @@ export default class AccountViewView extends Vue {
/** /**
* Processes the identity and updates the component's state. * Processes the identity and updates the component's state.
* @param {IdentityType} identity - Object containing identity information.
*/ */
processIdentity(identity: IIdentifier) { async processIdentity() {
if ( const account: Account | undefined = await getAccount(this.activeDid);
identity && if (account?.identity) {
identity.keys && const identity = JSON.parse(account.identity as string) as IIdentifier;
identity.keys.length > 0 &&
identity.keys[0].meta
) {
this.publicHex = identity.keys[0].publicKeyHex; this.publicHex = identity.keys[0].publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.derivationPath = identity.keys[0].meta?.derivationPath as string; this.derivationPath = identity.keys[0].meta?.derivationPath as string;
await this.checkLimitsFor(this.activeDid);
db.settings.update(MASTER_SETTINGS_KEY, { } else if (account?.publicKeyHex) {
activeDid: identity.did, this.publicHex = account.publicKeyHex as string;
}); this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.checkLimitsFor(identity); await this.checkLimitsFor(this.activeDid);
} else {
// Handle the case where any of these are null or undefined
} }
} }
@@ -887,58 +971,24 @@ export default class AccountViewView extends Vue {
this.notificationMaybeChanged = true; this.notificationMaybeChanged = true;
} }
/** public async updateShowContactAmounts() {
* Handles errors and updates the component's state accordingly. await db.open();
* @param {Error} err - The error object. await db.settings.update(MASTER_SETTINGS_KEY, {
*/ showContactGivesInline: this.showContactGives,
handleError(err: unknown) { });
if (
err instanceof Error &&
err.message ===
"Attempted to load account records with no identifier available."
) {
this.limitsMessage = "No identifier.";
} else {
console.error("Telling user to clear cache at page create because:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Account",
text: "Clear your cache and start over (after data backup).",
},
-1,
);
}
} }
public async updateShowContactAmounts() { public async updateShowGeneralAdvanced() {
try { await db.open();
await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, { showGeneralAdvanced: this.showGeneralAdvanced,
showContactGivesInline: this.showContactGives, });
});
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Contact Setting",
text: "The setting may not have saved. Try again, maybe after restarting the app.",
},
-1,
);
console.error(
"Telling user to try again after contact-amounts setting update because:",
err,
);
}
} }
public async updateWarnIfProdServer(newSetting: boolean) { public async updateWarnIfProdServer(newSetting: boolean) {
try { try {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
warnIfProdServer: newSetting, warnIfProdServer: newSetting,
}); });
} catch (err) { } catch (err) {
@@ -959,71 +1009,35 @@ export default class AccountViewView extends Vue {
} }
public async updateWarnIfTestServer(newSetting: boolean) { public async updateWarnIfTestServer(newSetting: boolean) {
try { await db.open();
await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, { warnIfTestServer: newSetting,
warnIfTestServer: newSetting, });
});
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Test Warning",
text: "The setting may not have saved. Try again, maybe after restarting the app.",
},
-1,
);
console.error(
"Telling user to try again after test-server-warning setting update because:",
err,
);
}
} }
public async toggleHideRegisterPromptOnNewContact() { public async toggleHideRegisterPromptOnNewContact() {
const newSetting = !this.hideRegisterPromptOnNewContact; const newSetting = !this.hideRegisterPromptOnNewContact;
try { await db.open();
await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: newSetting,
hideRegisterPromptOnNewContact: newSetting, });
}); this.hideRegisterPromptOnNewContact = newSetting;
this.hideRegisterPromptOnNewContact = newSetting; }
} catch (err) {
this.$notify( public async updatePasskeyExpiration() {
{ await db.open();
group: "alert", await db.settings.update(MASTER_SETTINGS_KEY, {
type: "danger", passkeyExpirationMinutes: this.passkeyExpirationMinutes,
title: "Error Updating Setting", });
text: "The setting may not have saved. Try again, maybe after restarting the app.", clearPasskeyToken();
}, this.passkeyExpirationDescription = tokenExpiryTimeDescription();
-1,
);
console.error("Telling user to try again because:", err);
}
} }
public async updateShowShortcutBvc(newSetting: boolean) { public async updateShowShortcutBvc(newSetting: boolean) {
try { await db.open();
await db.open(); await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, { showShortcutBvc: newSetting,
showShortcutBvc: newSetting, });
});
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating BVC Shortcut Setting",
text: "The setting may not have saved. Try again, maybe after restarting the app.",
},
-1,
);
console.error(
"Telling user to try again after BVC-shortcut setting update because:",
err,
);
}
} }
/** /**
@@ -1129,7 +1143,7 @@ export default class AccountViewView extends Vue {
} }
async uploadImportFile(event: Event) { async uploadImportFile(event: Event) {
inputImportFileNameRef.value = event.target.files[0]; inputImportFileNameRef.value = (event.target as EventTarget).files[0];
} }
showContactImport() { showContactImport() {
@@ -1167,6 +1181,40 @@ export default class AccountViewView extends Vue {
} }
} }
async checkContactImports() {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
console.error("Error checking contact imports:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Importing",
text: "There was an error reading that Dexie file.",
},
3000,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress) { private progressCallback(progress: ImportProgress) {
console.log( console.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`, `Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
@@ -1187,9 +1235,8 @@ export default class AccountViewView extends Vue {
} }
async checkLimits() { async checkLimits() {
const identity = await this.getIdentity(this.activeDid); if (this.activeDid) {
if (identity) { this.checkLimitsFor(this.activeDid);
this.checkLimitsFor(identity);
} else { } else {
this.limitsMessage = this.limitsMessage =
"You have no identifier, or your data has been corrupted."; "You have no identifier, or your data has been corrupted.";
@@ -1201,7 +1248,7 @@ export default class AccountViewView extends Vue {
* *
* Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`. * Updates component state variables `limits`, `limitsMessage`, and `loadingLimits`.
*/ */
public async checkLimitsFor(identity: IIdentifier) { public async checkLimitsFor(did: string) {
this.loadingLimits = true; this.loadingLimits = true;
this.limitsMessage = ""; this.limitsMessage = "";
@@ -1209,7 +1256,7 @@ export default class AccountViewView extends Vue {
const resp = await fetchEndorserRateLimits( const resp = await fetchEndorserRateLimits(
this.apiServer, this.apiServer,
this.axios, this.axios,
identity, did,
); );
if (resp.status === 200) { if (resp.status === 200) {
this.endorserLimits = resp.data; this.endorserLimits = resp.data;
@@ -1217,7 +1264,7 @@ export default class AccountViewView extends Vue {
// the user was not known to be registered, but now they are (because we got no error) so let's record it // the user was not known to be registered, but now they are (because we got no error) so let's record it
try { try {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true, isRegistered: true,
}); });
this.isRegistered = true; this.isRegistered = true;
@@ -1234,28 +1281,13 @@ export default class AccountViewView extends Vue {
); );
} }
} }
const imageResp = await fetchImageRateLimits( const imageResp = await fetchImageRateLimits(this.axios, did);
this.apiServer,
this.axios,
identity,
);
if (imageResp.status === 200) { if (imageResp.status === 200) {
this.imageLimits = imageResp.data; this.imageLimits = imageResp.data;
} }
} }
} catch (error) { } catch (error) {
this.handleRateLimitsError(error); this.handleRateLimitsError(error);
try {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: false,
});
this.isRegistered = false;
} catch (err) {
console.error("Got an error marking user not registered:", err);
// already set an error notification for the user
}
} }
this.loadingLimits = false; this.loadingLimits = false;
@@ -1273,8 +1305,8 @@ export default class AccountViewView extends Vue {
(data?.error?.message as string) || "Bad server response."; (data?.error?.message as string) || "Bad server response.";
console.error( console.error(
"Got bad response retrieving limits, which usually means user isn't registered.", "Got bad response retrieving limits, which usually means user isn't registered.",
error,
); );
//console.error(error);
} else { } else {
this.limitsMessage = "Got an error retrieving limits."; this.limitsMessage = "Got an error retrieving limits.";
console.error("Got some error retrieving limits:", error); console.error("Got some error retrieving limits:", error);
@@ -1335,9 +1367,9 @@ export default class AccountViewView extends Vue {
* *
* @param {AccountType} account - The account object. * @param {AccountType} account - The account object.
*/ */
private updateActiveAccountProperties(account: IAccount) { private updateActiveAccountProperties(account: Account) {
this.activeDid = account.did; this.activeDid = account.did;
this.derivationPath = account.derivationPath; this.derivationPath = account.derivationPath || "";
this.publicHex = account.publicKeyHex; this.publicHex = account.publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64"); this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
} }
@@ -1351,7 +1383,7 @@ export default class AccountViewView extends Vue {
async onClickSaveApiServer() { async onClickSaveApiServer() {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
apiServer: this.apiServerInput, apiServer: this.apiServerInput,
}); });
this.apiServer = this.apiServerInput; this.apiServer = this.apiServerInput;
@@ -1359,7 +1391,7 @@ export default class AccountViewView extends Vue {
async onClickSavePushServer() { async onClickSavePushServer() {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
webPushServer: this.webPushServerInput, webPushServer: this.webPushServerInput,
}); });
this.webPushServer = this.webPushServerInput; this.webPushServer = this.webPushServerInput;
@@ -1378,7 +1410,7 @@ export default class AccountViewView extends Vue {
(this.$refs.imageMethodDialog as ImageMethodDialog).open( (this.$refs.imageMethodDialog as ImageMethodDialog).open(
async (imgUrl) => { async (imgUrl) => {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl, profileImageUrl: imgUrl,
}); });
this.profileImageUrl = imgUrl; this.profileImageUrl = imgUrl;
@@ -1408,20 +1440,13 @@ export default class AccountViewView extends Vue {
return; return;
} }
try { try {
const identity = await this.getIdentity(this.activeDid); const headers = await getHeaders(this.activeDid);
if (!identity) { this.passkeyExpirationDescription = tokenExpiryTimeDescription();
throw Error("No identity found.");
}
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.profileImageUrl), encodeURIComponent(this.profileImageUrl),
{ { 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
@@ -1441,7 +1466,7 @@ export default class AccountViewView extends Vue {
} }
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: undefined, profileImageUrl: undefined,
}); });
@@ -1453,7 +1478,7 @@ export default class AccountViewView extends Vue {
console.error("The image was already deleted:", error); console.error("The image was already deleted:", error);
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: undefined, profileImageUrl: undefined,
}); });

View File

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

View File

@@ -22,6 +22,18 @@
<div class="overflow-hidden"> <div class="overflow-hidden">
<h2 class="text-md font-bold"> <h2 class="text-md font-bold">
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }} {{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<button
v-if="
['GiveAction', 'Offer'].includes(
veriClaim.claimType as string,
) && veriClaim.issuer === activeDid
"
@click="onClickEditClaim"
title="Edit"
data-testId="editClaimButton"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2> </h2>
<div class="text-sm"> <div class="text-sm">
<div> <div>
@@ -39,9 +51,12 @@
</button> </button>
<span v-show="showIdCopy">Copied ID</span> <span v-show="showIdCopy">Copied ID</span>
</div> </div>
<div> <div data-testId="description">
<fa icon="message" class="fa-fw text-slate-400" /> <fa icon="message" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }} {{
veriClaim.claim?.itemOffered?.description ||
veriClaim.claim?.description
}}
</div> </div>
<div> <div>
<fa icon="user" class="fa-fw text-slate-400" /> <fa icon="user" class="fa-fw text-slate-400" />
@@ -65,6 +80,11 @@
<fa icon="calendar" class="fa-fw text-slate-400" /> <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>
<div v-if="veriClaim.claim.image" class="flex justify-center">
<a :href="veriClaim.claim.image" target="_blank">
<img :src="veriClaim.claim.image" class="h-24 rounded-xl" />
</a>
</div>
<!-- Fullfills Links --> <!-- Fullfills Links -->
@@ -121,32 +141,7 @@
</div> </div>
</div> </div>
<div class="flex columns-3"> <div class="mt-8">
<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>
<span class="px-4 py-2">
<router-link
v-if="libsUtil.isGiveAction(veriClaim)"
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
class="col-span-1 text-blue-500"
>
Confirmation Details...
</router-link>
</span>
<button <button
v-if="libsUtil.canFulfillOffer(veriClaim)" v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()" @click="openFulfillGiftDialog()"
@@ -156,10 +151,37 @@
<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" />
<div v-if="libsUtil.isGiveAction(veriClaim)"> <div v-if="libsUtil.isGiveAction(veriClaim)">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2> <div class="flex columns-3">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
)
"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<h2 v-else class="font-bold uppercase text-xl mt-2">Confirmations</h2>
<span class="mt-0.5 px-4 py-2">
<router-link
v-if="libsUtil.isGiveAction(veriClaim)"
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
class="col-span-1 text-blue-500"
>
Details...
</router-link>
</span>
</div>
<GiftedDialog ref="customGiveDialog" />
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span> <span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1"> <span v-else-if="totalConfirmers() === 1">
@@ -289,13 +311,15 @@
<a @click="onClickShareClaim()" class="text-blue-500" <a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a >click to send them this info</a
> >
and see if they are willing to make an introduction. and see if they are willing to make an introduction. They are surely
connected to someone; if you don't know who to ask, you might try the
person who registered you.
</span> </span>
<span v-else> <span v-else>
If you'd like to ask any of your contacts to take a look and see if If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details, their contacts can see more details,
<a <a
@click="copyToClipboard('Location', windowLocation)" @click="copyToClipboard('This page location', windowLocation)"
class="text-blue-500" class="text-blue-500"
>share this page with them</a >share this page with them</a
> >
@@ -368,9 +392,19 @@
</div> </div>
</div> </div>
</div> </div>
<span v-if="isEditedGlobalId" class="mt-2">
This record is an edited version. The latest version is here.
</span>
<br />
<button @click="showVeriClaimDump = !showVeriClaimDump" class="ml-2">
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> <!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre <pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ veriClaimDump }}</pre >{{ veriClaimDump }}</pre
> >
</div> </div>
@@ -393,7 +427,10 @@
</button> </button>
</div> </div>
<div v-else> <div v-else>
<pre>{{ fullClaimDump }}</pre> <pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div> </div>
<a <a
@@ -407,11 +444,11 @@
</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 { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
@@ -419,12 +456,15 @@ 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 { GiverReceiverInputInfo } from "@/libs/endorserServer"; import {
GenericCredWrapper,
GiverReceiverInputInfo,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
@Component({ @Component({
components: { GiftedDialog, QuickNav }, components: { GiftedDialog, QuickNav },
@@ -432,7 +472,6 @@ import { GiverReceiverInputInfo } from "@/libs/endorserServer";
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> = [];
@@ -447,9 +486,12 @@ export default class ClaimView extends Vue {
fullClaim = null; fullClaim = null;
fullClaimDump = ""; fullClaimDump = "";
fullClaimMessage = ""; fullClaimMessage = "";
isEditedGlobalId = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
showDidCopy = false; showDidCopy = false;
showIdCopy = false; showIdCopy = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible = {}; veriClaimDidsVisible = {};
@@ -469,6 +511,8 @@ export default class ClaimView extends Vue {
this.fullClaim = null; this.fullClaim = null;
this.fullClaimDump = ""; this.fullClaimDump = "";
this.fullClaimMessage = ""; this.fullClaimMessage = "";
this.isEditedGlobalId = false;
this.isRegistered = false;
this.numConfsNotVisible = 0; this.numConfsNotVisible = 0;
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = ""; this.veriClaimDump = "";
@@ -480,20 +524,18 @@ export default class ClaimView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = settings?.isRegistered || false;
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
const accountsArr: Array<Account> = 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 as string) || "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(
{ {
@@ -527,33 +569,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(
@@ -564,12 +579,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 });
@@ -595,13 +610,15 @@ export default class ClaimView extends Vue {
return; return;
} }
this.isEditedGlobalId = !this.veriClaim.handleId.endsWith(claimId);
// retrieve more details on Give, Offer, or Plan // retrieve more details on Give, Offer, or Plan
if (this.veriClaim.claimType === "GiveAction") { if (this.veriClaim.claimType === "GiveAction") {
const giveUrl = const giveUrl =
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,
}); });
@@ -615,7 +632,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,
}); });
@@ -631,12 +648,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,
@@ -671,15 +690,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 as string) || "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 });
@@ -758,7 +771,7 @@ export default class ClaimView extends Vue {
}; };
const result = await serverUtil.createAndSubmitClaim( const result = await serverUtil.createAndSubmitClaim(
confirmationClaim, confirmationClaim,
await this.getIdentity(this.activeDid), this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
@@ -790,15 +803,17 @@ export default class ClaimView extends Vue {
const route = { const route = {
path: "/claim/" + encodeURIComponent(claimId), path: "/claim/" + encodeURIComponent(claimId),
}; };
this.$router.push(route).then(async () => { (this.$router as 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: GiverReceiverInputInfo = { const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim), did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferVerifiableCredential>,
),
}; };
(this.$refs.customGiveDialog as GiftedDialog).open( (this.$refs.customGiveDialog as GiftedDialog).open(
giver, giver,
@@ -831,5 +846,43 @@ export default class ClaimView extends Vue {
url: this.windowLocation, url: this.windowLocation,
}); });
} }
onClickEditClaim() {
if (this.veriClaim.claimType === "GiveAction") {
const route = {
name: "gifted-details",
query: {
prevCredToEdit: JSON.stringify(this.veriClaim),
destinationPathAfter:
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
},
};
(this.$router as Router).push(route);
} else if (this.veriClaim.claimType === "Offer") {
const route = {
name: "offer-details",
query: {
prevCredToEdit: JSON.stringify(this.veriClaim),
destinationPathAfter:
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
},
};
(this.$router as Router).push(route);
} else {
console.error(
"Unrecognized claim type for edit:",
this.veriClaim.claimType,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "This is an unrecognized claim type.",
},
3000,
);
}
}
} }
</script> </script>

View File

@@ -15,6 +15,7 @@
<span <span
v-if=" v-if="
libsUtil.isGiveRecordTheUserCanConfirm( libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim, veriClaim,
activeDid, activeDid,
confirmerIdList, confirmerIdList,
@@ -27,12 +28,13 @@
</h1> </h1>
</div> </div>
<div v-if="giveDetails"> <div v-if="giveDetails && !isLoading">
<div class="flex justify-center"> <div class="flex justify-center">
<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="
libsUtil.isGiveRecordTheUserCanConfirm( libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim, veriClaim,
activeDid, activeDid,
confirmerIdList, confirmerIdList,
@@ -52,6 +54,7 @@
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> <fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button> </button>
<a <a
v-if="isRegistered"
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md" 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" :href="urlForNewGive"
> >
@@ -371,9 +374,9 @@
> >
</div> </div>
</div> </div>
<div v-else>This does not have details to confirm.</div> <div v-else-if="!isLoading">This does not have details to confirm.</div>
<div class="mt-4"> <div class="mt-4" v-if="!isLoading">
<a <a
@click="showClaimPage(veriClaim.id)" @click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer" class="text-blue-500 cursor-pointer"
@@ -382,38 +385,42 @@
All Generic Info All Generic Info
</a> </a>
</div> </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> </section>
</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 { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue";
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 { 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 serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer"; import { displayAmount } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { isGiveAction } from "@/libs/util"; import { isGiveAction } from "@/libs/util";
@Component({ @Component({
methods: { displayAmount }, methods: { displayAmount },
components: { GiftedDialog, QuickNav }, components: { 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> = [];
@@ -426,6 +433,8 @@ export default class ClaimView extends Vue {
giveDetails = null; giveDetails = null;
giverName = ""; giverName = "";
issuerName = ""; issuerName = "";
isLoading = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = ""; recipientName = "";
showDetails = false; showDetails = false;
@@ -445,6 +454,7 @@ export default class ClaimView extends Vue {
this.confsVisibleErrorMessage = ""; this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = []; this.confsVisibleToIdList = [];
this.giveDetails = null; this.giveDetails = null;
this.isRegistered = false;
this.numConfsNotVisible = 0; this.numConfsNotVisible = 0;
this.urlForNewGive = ""; this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
@@ -452,19 +462,18 @@ export default class ClaimView extends Vue {
} }
async mounted() { async mounted() {
this.isLoading = true;
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.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = settings?.isRegistered || false;
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
const accountsArr: Array<Account> = 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 as string) || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring( const pathParam = window.location.pathname.substring(
"/confirm-gift/".length, "/confirm-gift/".length,
@@ -472,7 +481,7 @@ export default class ClaimView extends Vue {
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(
{ {
@@ -488,6 +497,8 @@ export default class ClaimView extends Vue {
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare // 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() // then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share; this.canShare = !!navigator.share;
this.isLoading = false;
} }
// insert a space before any capital letters except the initial letter // insert a space before any capital letters except the initial letter
@@ -519,33 +530,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 | undefined) { didInfo(did: string | undefined) {
return serverUtil.didInfo( return serverUtil.didInfo(
@@ -556,14 +540,14 @@ 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);
try { try {
const headers = await this.getHeaders(identity); const headers = await serverUtil.getHeaders(userDid);
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
// resp.data is: // resp.data is:
// - a Jwt from https://api.endorser.ch/api-docs/ // - a Jwt from https://api.endorser.ch/api-docs/
@@ -603,7 +587,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,
}); });
@@ -674,12 +658,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.giveDetails.agentDid, (did: string) => did === this.giveDetails.agentDid,
@@ -747,7 +733,7 @@ export default class ClaimView extends Vue {
}; };
const result = await serverUtil.createAndSubmitClaim( const result = await serverUtil.createAndSubmitClaim(
confirmationClaim, confirmationClaim,
await this.getIdentity(this.activeDid), this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
); );
@@ -779,24 +765,12 @@ export default class ClaimView extends Vue {
const route = { const route = {
path: "/claim/" + encodeURIComponent(claimId), path: "/claim/" + encodeURIComponent(claimId),
}; };
this.$router.push(route).then(async () => { (this.$router as Router).push(route).then(async () => {
this.resetThisValues(); this.resetThisValues();
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr)); 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) { copyToClipboard(name: string, text: string) {
useClipboard() useClipboard()
.copy(text) .copy(text)
@@ -814,7 +788,17 @@ export default class ClaimView extends Vue {
} }
notifyWhyCannotConfirm() { notifyWhyCannotConfirm() {
if (!isGiveAction(this.veriClaim)) { if (!this.isRegistered) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not Registered",
text: "Someone needs to register you before you can contribute.",
},
3000,
);
} else if (!isGiveAction(this.veriClaim)) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",

View File

@@ -55,7 +55,7 @@
{{ new Date(record.issuedAt).toLocaleString() }} {{ new Date(record.issuedAt).toLocaleString() }}
</td> </td>
<td class="p-1"> <td class="p-1">
<span v-if="record.agentDid == contact.did"> <span v-if="record.agentDid == contact?.did">
<div class="font-bold"> <div class="font-bold">
{{ displayAmount(record.unit, record.amount) }} {{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed"> <span v-if="record.amountConfirmed" title="Confirmed">
@@ -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,7 +79,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">
<div class="font-bold"> <div class="font-bold">
{{ displayAmount(record.unit, record.amount) }} {{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed"> <span v-if="record.amountConfirmed" title="Confirmed">
@@ -105,21 +105,21 @@
</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 { Router } from "vue-router";
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 { 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, displayAmount,
getHeaders,
GiveSummaryRecord, GiveSummaryRecord,
GiveVerifiableCredential, GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT, SCHEMA_ORG_CONTEXT,
@@ -142,40 +142,15 @@ export default class ContactAmountssView extends Vue {
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();
const contactDid = this.$route.query.contactDid as string; const contactDid = (this.$route as Router).query["contactDid"] as string;
this.contact = (await db.contacts.get(contactDid)) || null; 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);
@@ -199,15 +174,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;
@@ -233,8 +207,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);
@@ -289,66 +263,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 = (await 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

@@ -72,17 +72,14 @@
<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 } from "@/db/index";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverReceiverInputInfo } from "@/libs/endorserServer"; import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({ @Component({
@@ -94,13 +91,8 @@ export default class ContactGiftingView extends Vue {
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = []; allContacts: Array<Contact> = [];
apiServer = ""; apiServer = "";
accounts: typeof AccountsSchema;
projectId = localStorage.getItem("projectId") || ""; projectId = localStorage.getItem("projectId") || "";
async beforeCreate() {
accountsDB.open();
}
async created() { async created() {
try { try {
await db.open(); await db.open();
@@ -134,32 +126,7 @@ export default class ContactGiftingView extends Vue {
} }
} }
public async getIdentity(activeDid: string) { openDialog(giver?: GiverReceiverInputInfo) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
openDialog(giver: GiverReceiverInputInfo) {
const recipient = this.projectId const recipient = this.projectId
? undefined ? undefined
: { did: this.activeDid, name: "you" }; : { did: this.activeDid, name: "you" };

View File

@@ -0,0 +1,190 @@
<template>
<QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Contact Import
</h1>
<span>
Note that you will have to make them visible one-by-one in the list of
Contacts.
</span>
<div v-if="sameCount > 0">
{{ sameCount }} contact{{ sameCount == 1 ? "" : "s" }} are the same as
existing contacts.
</div>
<!-- Results List -->
<ul v-if="contactsImporting.length > 0" class="border-t border-slate-300">
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div class="font-bold">Field</div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[contact.did]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border p-1">{{ contactField }}</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<fa icon="spinner" v-if="importing" class="animate-spin" />
<button
v-else
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else>There are no contacts to import.</p>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import OfferDialog from "@/components/OfferDialog.vue";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
})
export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
libsUtil = libsUtil;
R = R;
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
contactsImporting: Array<Contact> = []; // contacts from the import
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
contactDifferences: Record<
string,
Record<string, { new: string; old: string }>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
importing = false;
sameCount = 0;
async created() {
// Retrieve the imported contacts from the query parameter
const importedContacts =
((this.$route as Router).query["contacts"] as string) || "[]";
this.contactsImporting = JSON.parse(importedContacts);
this.contactsSelected = new Array(this.contactsImporting.length).fill(
false,
);
await db.open();
const baseContacts = await db.contacts.toArray();
// set the existing contacts, keyed by DID, if they exist in contactsImporting
for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i];
const existingContact = baseContacts.find(
(contact) => contact.did === contactIn.did,
);
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const differences: Record<string, { new: string; old: string }> = {};
Object.keys(contactIn).forEach((key) => {
if (contactIn[key] !== existingContact[key]) {
differences[key] = {
old: existingContact[key],
new: contactIn[key],
};
}
});
this.contactDifferences[contactIn.did] = differences;
if (R.isEmpty(differences)) {
this.sameCount++;
}
} else {
// automatically import new data
this.contactsSelected[i] = true;
}
}
}
async importContacts() {
this.importing = true;
let importedCount = 0,
updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
const existingContact = this.contactsExisting[contact.did];
if (existingContact) {
await db.contacts.update(contact.did, contact);
updatedCount++;
} else {
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
await db.contacts.add(R.clone(contact));
importedCount++;
}
}
}
this.importing = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Import Success",
text:
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
(updatedCount ? ` ${updatedCount} updated.` : ""),
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
}
</script>

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
@@ -72,7 +87,7 @@
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import * as didJwt from "did-jwt"; import { Buffer } from "buffer/";
import { sha256 } from "ethereum-cryptography/sha256.js"; import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3"; import QRCodeVue3 from "qr-code-generator-vue3";
import * as R from "ramda"; import * as R from "ramda";
@@ -83,24 +98,22 @@ import { useClipboard } from "@vueuse/core";
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 { 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 { import {
deriveAddress, deriveAddress,
getContactPayloadFromJwtUrl, getContactPayloadFromJwtUrl,
nextDerivationPath, nextDerivationPath,
SimpleSigner,
} from "@/libs/crypto"; } from "@/libs/crypto";
import { import {
CONTACT_URL_PREFIX, CONTACT_URL_PREFIX,
createEndorserJwtForDid,
ENDORSER_JWT_URL_LOCATION, ENDORSER_JWT_URL_LOCATION,
isDid, isDid,
register, register,
setVisibilityUtil, setVisibilityUtil,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { Buffer } from "buffer/";
@Component({ @Component({
components: { components: {
@@ -119,6 +132,8 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false; isRegistered = false;
qrValue = ""; qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
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);
@@ -133,17 +148,9 @@ export default class ContactQRScanShow extends Vue {
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,
@@ -152,21 +159,28 @@ 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, 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;
} }
@@ -184,23 +198,6 @@ export default class ContactQRScanShow extends Vue {
); );
} }
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identifier available.",
);
}
return identity;
}
/** /**
* *
* @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
@@ -281,7 +278,7 @@ export default class ContactQRScanShow extends Vue {
text: "Do you want to register them?", text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => { onCancel: async (stopAsking: boolean) => {
if (stopAsking) { if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
}); });
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
@@ -289,7 +286,7 @@ export default class ContactQRScanShow extends Vue {
}, },
onNo: async (stopAsking: boolean) => { onNo: async (stopAsking: boolean) => {
if (stopAsking) { if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
}); });
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
@@ -433,7 +430,7 @@ export default class ContactQRScanShow extends Vue {
); );
} }
onCopyToClipboard() { onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing //this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard() useClipboard()
.copy(this.qrValue) .copy(this.qrValue)
@@ -450,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>

View File

@@ -20,7 +20,7 @@
</div> </div>
<!-- New Contact --> <!-- New Contact -->
<div class="mt-4 mb-4 flex items-stretch"> <div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<router-link <router-link
:to="{ name: 'contact-qr' }" :to="{ name: 'contact-qr' }"
class="flex items-center 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-1 mr-1 rounded-md" class="flex items-center 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-1 mr-1 rounded-md"
@@ -79,7 +79,11 @@
</div> </div>
<!-- Results List --> <!-- Results List -->
<ul v-if="contacts.length > 0" class="border-t border-slate-300"> <ul
id="listContacts"
v-if="contacts.length > 0"
class="border-t border-slate-300"
>
<li <li
class="border-b border-slate-300 pt-2.5 pb-4" class="border-b border-slate-300 pt-2.5 pb-4"
v-for="contact in contacts" v-for="contact in contacts"
@@ -113,7 +117,7 @@
</router-link> </router-link>
</h2> </h2>
<div class="text-sm truncate"> <div class="text-sm truncate">
{{ contact.did }} Identifier:
<button <button
@click=" @click="
libsUtil.doCopyTwoSecRedo( libsUtil.doCopyTwoSecRedo(
@@ -121,24 +125,51 @@
() => (showDidCopy = !showDidCopy), () => (showDidCopy = !showDidCopy),
) )
" "
class="ml-2 mr-2"
> >
<fa icon="copy" class="text-slate-400 fa-fw"></fa> <fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button> </button>
<span v-show="showDidCopy">Copied DID</span> <span v-show="showDidCopy" class="text-green-500">Copied DID</span>
{{ contact.did }}
</div> </div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64"> <div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64): {{ contact.publicKeyBase64 }} Public Key (base 64):
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.publicKeyBase64,
() => (showPubKeyCopy = !showPubKeyCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubKeyCopy" class="text-green-500"
>Copied Key</span
>
{{ contact.publicKeyBase64 }}
</div> </div>
<div class="text-sm truncate" v-if="contact.nextPubKeyHashB64"> <div class="text-sm truncate" v-if="contact.nextPubKeyHashB64">
Next Public Key Hash (base 64): Next Public Key Hash (base 64):
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.nextPubKeyHashB64,
() => (showPubKeyHashCopy = !showPubKeyHashCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubKeyHashCopy" class="text-green-500"
>Copied Hash</span
>
{{ contact.nextPubKeyHashB64 }} {{ contact.nextPubKeyHashB64 }}
</div> </div>
<div id="ContactActions" class="flex gap-1.5 mt-2"> <div id="ContactActions" class="flex gap-1.5 mt-2">
<div v-if="activeDid"> <div v-if="activeDid">
<button <button
v-if="contact.seesMe" v-if="contact.seesMe && contact.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, false)" @click="confirmSetVisibility(contact, false)"
title="They can see you" title="They can see you"
@@ -146,25 +177,31 @@
<fa icon="eye" class="fa-fw" /> <fa icon="eye" class="fa-fw" />
</button> </button>
<button <button
v-else v-else-if="!contact.seesMe && contact.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, true)" @click="confirmSetVisibility(contact, true)"
title="They cannot see you" title="They cannot see you"
> >
<fa icon="eye-slash" class="fa-fw" /> <fa icon="eye-slash" class="fa-fw" />
</button> </button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="eye" class="text-white mx-2.5" />
<button <button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contact)" @click="checkVisibility(contact)"
title="Check Visibility" title="Check Visibility"
v-if="activeDid" v-if="contact.did !== activeDid"
> >
<fa icon="rotate" class="fa-fw" /> <fa icon="rotate" class="fa-fw" />
</button> </button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white mx-2.5" />
<button <button
@click="confirmRegister(contact)" @click="confirmRegister(contact)"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md"
v-if="activeDid" v-if="contact.did !== activeDid"
title="Registration" title="Registration"
> >
<fa <fa
@@ -174,6 +211,8 @@
/> />
<fa v-else icon="person-circle-question" class="fa-fw" /> <fa v-else icon="person-circle-question" class="fa-fw" />
</button> </button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
</div> </div>
<button <button
@@ -232,7 +271,7 @@
<button <button
class="text-sm 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-1.5 rounded-md border border-blue-400" class="text-sm 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-1.5 rounded-md border border-blue-400"
@click="openOfferDialog(contact.did)" @click="openOfferDialog(contact.did, contact.name)"
> >
Offer Offer
</button> </button>
@@ -301,22 +340,23 @@
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { IndexableType } from "dexie"; import { IndexableType } from "dexie";
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 { Router } from "vue-router"; import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { 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, getContactPayloadFromJwtUrl } from "@/libs/crypto"; import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
import { import {
CONTACT_CSV_HEADER, CONTACT_CSV_HEADER,
CONTACT_URL_PREFIX, CONTACT_URL_PREFIX,
GiverReceiverInputInfo, GiverReceiverInputInfo,
GiveSummaryRecord, GiveSummaryRecord,
getHeaders,
isDid, isDid,
register, register,
setVisibilityUtil, setVisibilityUtil,
@@ -326,9 +366,6 @@ import QuickNav from "@/components/QuickNav.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 OfferDialog from "@/components/OfferDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue";
import { Account } from "@/db/tables/accounts";
import { Buffer } from "buffer/";
@Component({ @Component({
components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav }, components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav },
@@ -357,6 +394,8 @@ export default class ContactsView extends Vue {
hideRegisterPromptOnNewContact = false; hideRegisterPromptOnNewContact = false;
isRegistered = false; isRegistered = false;
showDidCopy = false; showDidCopy = false;
showPubKeyCopy = false;
showPubKeyHashCopy = false;
showGiveNumbers = false; showGiveNumbers = false;
showGiveTotals = true; showGiveTotals = true;
showGiveConfirmed = true; showGiveConfirmed = true;
@@ -400,36 +439,6 @@ export default class ContactsView extends Vue {
); );
} }
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
public async getHeadersAndIdentity(activeDid: string) {
const identity = await this.getIdentity(activeDid);
const headers = await this.getHeaders(identity);
return { headers, identity };
}
async loadGives() { async loadGives() {
if (!this.activeDid) { if (!this.activeDid) {
return; return;
@@ -481,7 +490,7 @@ export default class ContactsView extends Vue {
}; };
try { try {
const { headers } = await this.getHeadersAndIdentity(this.activeDid); const headers = await getHeaders(this.activeDid);
const givenByUrl = const givenByUrl =
this.apiServer + this.apiServer +
"/api/v2/report/gives?agentDid=" + "/api/v2/report/gives?agentDid=" +
@@ -722,7 +731,7 @@ export default class ContactsView extends Vue {
text: "Do you want to register them?", text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => { onCancel: async (stopAsking: boolean) => {
if (stopAsking) { if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
}); });
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
@@ -730,7 +739,7 @@ export default class ContactsView extends Vue {
}, },
onNo: async (stopAsking: boolean) => { onNo: async (stopAsking: boolean) => {
if (stopAsking) { if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
}); });
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
@@ -834,7 +843,7 @@ export default class ContactsView extends Vue {
); );
if (regResult.success) { if (regResult.success) {
contact.registered = true; contact.registered = true;
db.contacts.update(contact.did, { registered: true }); await db.contacts.update(contact.did, { registered: true });
this.$notify( this.$notify(
{ {
@@ -898,7 +907,10 @@ export default class ContactsView extends Vue {
title: "Set Visibility", title: "Set Visibility",
text: visibilityPrompt, text: visibilityPrompt,
onYes: async () => { onYes: async () => {
await this.setVisibility(contact, visibility, true); const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
}, },
}, },
-1, -1,
@@ -919,6 +931,8 @@ export default class ContactsView extends Vue {
visibility, visibility,
); );
if (result.success) { if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) { if (showSuccessAlert) {
this.$notify( this.$notify(
{ {
@@ -934,18 +948,21 @@ export default class ContactsView extends Vue {
3000, 3000,
); );
} }
} else if (result.error) { return true;
} else {
console.error("Got strange result from setting visibility:", result);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error Setting Visibility", title: "Error Setting Visibility",
text: result.error as string, text: message,
}, },
5000, 5000,
); );
} else { return false;
console.error("Got strange result from setting visibility:", result);
} }
} }
@@ -954,22 +971,27 @@ export default class ContactsView extends Vue {
this.apiServer + this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" + "/api/report/canDidExplicitlySeeMe?did=" +
encodeURIComponent(contact.did); encodeURIComponent(contact.did);
const identity = await this.getIdentity(this.activeDid); const headers = await getHeaders(this.activeDid);
const headers = await this.getHeaders(identity); if (!headers["Authorization"]) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Identity",
text: "There is no identity to use to check visibility.",
},
3000,
);
return;
}
try { 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 visibility = resp.data; const visibility = resp.data;
contact.seesMe = visibility; contact.seesMe = visibility;
console.log( //console.log("Visi check:", visibility, contact.seesMe, contact.did);
"Visibility checked:", await db.contacts.update(contact.did, { seesMe: visibility });
visibility,
contact.did,
contact.name,
); // eslint-disable-line no-console
console.log(this.contacts); // eslint-disable-line no-console
db.contacts.update(contact.did, { seesMe: visibility });
this.$notify( this.$notify(
{ {
@@ -1109,8 +1131,11 @@ export default class ContactsView extends Vue {
); );
} }
openOfferDialog(recipientDid: string) { openOfferDialog(recipientDid: string, recipientName: string) {
(this.$refs.customOfferDialog as OfferDialog).open(recipientDid); (this.$refs.customOfferDialog as OfferDialog).open(
recipientDid,
recipientName,
);
} }
private async onClickCancelName() { private async onClickCancelName() {
@@ -1129,7 +1154,7 @@ export default class ContactsView extends Vue {
const newShowValue = !this.showGiveNumbers; const newShowValue = !this.showGiveNumbers;
try { try {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
showContactGivesInline: newShowValue, showContactGivesInline: newShowValue,
}); });
} catch (err) { } catch (err) {

View File

@@ -27,9 +27,17 @@
.displayName .displayName
}} }}
</h2> </h2>
<span class="mt-2 text-xl font-semibold break-words"> <button @click="showDidDetails = !showDidDetails" class="ml-2 mr-2">
{{ viewingDid }} Details
</span> <fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
v-if="showDidDetails"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ contactYaml }}</pre
>
</div> </div>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
<span v-if="contact?.profileImageUrl" class="flex justify-between"> <span v-if="contact?.profileImageUrl" class="flex justify-between">
@@ -105,10 +113,7 @@
{{ claimDescription(claim) }} {{ claimDescription(claim) }}
</span> </span>
<span class="col-span-1"> <span class="col-span-1">
<a <a @click="onClickLoadClaim(claim.id)" class="cursor-pointer">
@click="onClickLoadClaim(claim.handleId)"
class="cursor-pointer"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> <fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a> </a>
</span> </span>
@@ -128,6 +133,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as yaml from "js-yaml";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
@@ -136,16 +143,17 @@ 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 { import {
capitalizeAndInsertSpacesBeforeCaps, capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact, didInfoForContact,
displayAmount, displayAmount,
getHeaders,
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
GiveVerifiableCredential, GiveVerifiableCredential,
OfferVerifiableCredential, OfferVerifiableCredential,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
@Component({ @Component({
@@ -159,14 +167,19 @@ import EntityIcon from "@/components/EntityIcon.vue";
export default class DIDView extends Vue { export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
yaml = yaml;
activeDid = ""; activeDid = "";
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
claims: Array<GenericCredWrapper> = []; claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contact?: Contact; contact?: Contact;
contactYaml = "";
hitEnd = false; hitEnd = false;
isLoading = false; isLoading = false;
searchBox: { name: string; bbox: BoundingBox } | null = null; searchBox: { name: string; bbox: BoundingBox } | null = null;
showDidDetails = false;
showLargeIdenticonId?: string; showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string; showLargeIdenticonUrl?: string;
viewingDid?: string; viewingDid?: string;
@@ -185,6 +198,7 @@ export default class DIDView extends Vue {
if (pathParam) { if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam); this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid); this.contact = await db.contacts.get(this.viewingDid);
this.contactYaml = yaml.dump(this.contact);
await this.loadClaimsAbout(); await this.loadClaimsAbout();
} else { } else {
this.$notify( this.$notify(
@@ -203,30 +217,6 @@ export default class DIDView extends Vue {
this.allMyDids = allAccounts.map((acc) => acc.did); this.allMyDids = allAccounts.map((acc) => acc.did);
} }
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 as string) || "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;
}
/** /**
* 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
@@ -255,7 +245,7 @@ export default class DIDView extends Vue {
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix, this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
{ {
method: "GET", method: "GET",
headers: await this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );
@@ -299,7 +289,7 @@ export default class DIDView extends Vue {
const route = { const route = {
path: "/claim/" + encodeURIComponent(jwtId), path: "/claim/" + encodeURIComponent(jwtId),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
public claimAmount(claim: GenericVerifiableCredential) { public claimAmount(claim: GenericVerifiableCredential) {

View File

@@ -92,7 +92,7 @@
<!-- Results List --> <!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData"> <InfiniteScroll @reached-bottom="loadMoreData">
<ul> <ul id="listDiscoverResults">
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
v-for="project in projects" v-for="project in projects"
@@ -129,6 +129,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
@@ -138,8 +139,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: {
@@ -203,30 +203,6 @@ export default class DiscoverView extends Vue {
} }
} }
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
public async searchAll(beforeId?: string) { public async searchAll(beforeId?: string) {
this.resetCounts(); this.resetCounts();
@@ -247,7 +223,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),
}, },
); );
@@ -289,6 +265,8 @@ export default class DiscoverView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
console.error("Error with feed load:", e); console.error("Error with feed load:", e);
// this sometimes gives different information
console.error("Error with feed load (error added): " + e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -337,7 +315,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),
}, },
); );
@@ -419,7 +397,7 @@ export default class DiscoverView extends Vue {
const route = { const route = {
path: "/project/" + encodeURIComponent(id), path: "/project/" + encodeURIComponent(id),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
public computedLocalTabStyleClassNames() { public computedLocalTabStyleClassNames() {

View File

@@ -21,8 +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">
<span>From {{ giverName || "somebody not named" }}</span> <span>From {{ giverName }}</span>
<span> to {{ recipientName || "somebody not named" }}</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"
@@ -55,9 +64,9 @@
</div> </div>
</div> </div>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4" data-testid="imagery">
<span v-if="imageUrl" class="flex justify-between"> <span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4"> <a :href="imageUrl" target="_blank">
<img :src="imageUrl" class="h-24 rounded-xl" /> <img :src="imageUrl" class="h-24 rounded-xl" />
</a> </a>
<fa <fa
@@ -78,7 +87,7 @@
<div class="h-7 mt-4 flex"> <div class="h-7 mt-4 flex">
<input <input
v-if="projectId && !givenToUser" v-if="projectId && !givenToRecipient"
type="checkbox" type="checkbox"
class="h-6 w-6 mr-2" class="h-6 w-6 mr-2"
v-model="givenToProject" v-model="givenToProject"
@@ -100,20 +109,24 @@
<div class="h-7 mt-4 flex"> <div class="h-7 mt-4 flex">
<input <input
v-if="!givenToProject" v-if="recipientDid && !givenToProject"
type="checkbox" type="checkbox"
class="h-6 w-6 mr-2" class="h-6 w-6 mr-2"
v-model="givenToUser" v-model="givenToRecipient"
/> />
<fa <fa
v-else v-else
icon="square" icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded" class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click=" @click="notifyUserOfRecipient()"
notifyUser('You cannot assign this both a project and also to you.')
"
/> />
<label class="text-sm mt-1">This was given to you</label> <label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName
: "No recipient was chosen."
}}
</label>
</div> </div>
<div class="mt-4 flex"> <div class="mt-4 flex">
@@ -121,6 +134,20 @@
<label class="text-sm mt-1">This was a trade (not a gift)</label> <label class="text-sm mt-1">This was a trade (not a gift)</label>
</div> </div>
<div class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructGiveParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</div>
<p class="text-center mb-2 mt-6 italic"> <p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world Sign & Send to publish to the world
<fa <fa
@@ -148,16 +175,26 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; 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 { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive, getPlanFromCache } from "@/libs/endorserServer"; import {
createAndSubmitGive,
didInfo,
editAndSubmitGive,
GenericCredWrapper,
getHeaders,
getPlanFromCache,
GiveVerifiableCredential,
hydrateGive,
} 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";
@Component({ @Component({
components: { components: {
@@ -174,9 +211,9 @@ export default class GiftedDetails extends Vue {
amountInput = "0"; amountInput = "0";
description = ""; description = "";
destinationNameAfter = ""; destinationPathAfter = "";
givenToProject = false; givenToProject = false;
givenToUser = false; givenToRecipient = false;
giverDid: string | undefined; giverDid: string | undefined;
giverName = ""; giverName = "";
hideBackButton = false; hideBackButton = false;
@@ -184,48 +221,101 @@ export default class GiftedDetails extends Vue {
isTrade = false; isTrade = false;
message = ""; message = "";
offerId = ""; offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
projectId = ""; projectId = "";
projectName = "a project"; projectName = "a project";
recipientDid = ""; recipientDid = "";
recipientName = ""; recipientName = "";
showGivenToUser = false;
unitCode = "HUR"; unitCode = "HUR";
libsUtil = libsUtil; libsUtil = libsUtil;
async mounted() { async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
) as GenericCredWrapper<GiveVerifiableCredential>)
: undefined;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
);
}
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood;
this.amountInput = this.amountInput =
(this.$route.query.amountInput as string) || this.amountInput; (this.$route as Router).query["amountInput"] ||
this.description = (this.$route.query.description as string) || ""; (prevAmount ? String(prevAmount) : "") ||
this.destinationNameAfter = this.$route.query this.amountInput;
.destinationNameAfter as string; this.description =
this.giverDid = this.$route.query.giverDid as string; (this.$route as Router).query["description"] ||
this.giverName = (this.$route.query.giverName as string) || ""; this.prevCredToEdit?.claim?.description ||
this.hideBackButton = this.$route.query.hideBackButton === "true"; this.description;
this.message = (this.$route.query.message as string) || ""; this.destinationPathAfter = (this.$route as Router).query[
this.offerId = this.$route.query.offerId as string; "destinationPathAfter"
this.projectId = this.$route.query.projectId as string; ];
this.recipientDid = this.$route.query.recipientDid as string; this.giverDid = ((this.$route as Router).query["giverDid"] ||
this.recipientName = (this.$route.query.recipientName as string) || ""; this.prevCredToEdit?.claim?.agent?.identifier ||
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode; this.giverDid) as string;
this.giverName =
((this.$route as Router).query["giverName"] as string) || "";
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any offer ID
const fulfills = this.prevCredToEdit?.claim?.fulfills;
const fulfillsArray = Array.isArray(fulfills)
? fulfills
: fulfills
? [fulfills]
: [];
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer");
this.offerId = ((this.$route as Router).query["offerId"] ||
offer?.identifier ||
this.offerId) as string;
// find any project ID
const project = fulfillsArray.find((rec) => rec["@type"] === "PlanAction");
this.projectId = ((this.$route as Router).query["projectId"] ||
project?.identifier ||
this.projectId) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.prevCredToEdit?.claim?.object?.unitCode ||
this.unitCode) as string;
this.imageUrl = this.imageUrl =
(this.$route.query.imageUrl as string) || ((this.$route as Router).query["imageUrl"] as string) ||
this.prevCredToEdit?.claim?.image ||
localStorage.getItem("imageUrl") || localStorage.getItem("imageUrl") ||
""; this.imageUrl;
// this is an endpoint for sharing project info to highlight something given // this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target // https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if (this.$route.query.shareTitle) { if ((this.$route as Router).query["shareTitle"]) {
this.description = this.$route.query.shareTitle as string;
}
if (this.$route.query.shareText) {
this.description = this.description =
(this.description ? this.description + " " : "") + ((this.$route as Router).query["shareTitle"] as string) +
(this.$route.query.shareText as string); (this.description ? "\n" + this.description : "");
} }
if (this.$route.query.shareUrl) { if ((this.$route as Router).query["shareText"]) {
this.imageUrl = this.$route.query.shareUrl as string; this.description =
(this.description ? this.description + "\n" : "") +
((this.$route as Router).query["shareText"] as string);
}
if ((this.$route as Router).query["shareUrl"]) {
this.imageUrl = (this.$route as Router).query["shareUrl"] as string;
} }
try { try {
@@ -234,18 +324,37 @@ export default class GiftedDetails extends Vue {
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
if (this.giverDid && !this.giverName) { let allContacts: Contact[] = [];
this.giverName = let allMyDids: string[] = [];
this.giverDid === this.activeDid ? "you" : "someone not named"; if (
} (this.giverDid && !this.giverName) ||
this.givenToUser = this.recipientDid === this.activeDid; (this.recipientDid && !this.recipientName)
if (this.recipientDid && !this.recipientName) { ) {
this.recipientName = allContacts = await db.contacts.toArray();
this.recipientDid === this.activeDid ? "you" : "someone not named";
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
if (this.giverDid && !this.giverName) {
this.giverName = didInfo(
this.giverDid,
this.activeDid,
allMyDids,
allContacts,
);
}
if (this.recipientDid && !this.recipientName) {
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
} }
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.projectId; this.givenToProject = !!this.projectId;
this.givenToUser = this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
!this.projectId && this.recipientDid === this.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) {
@@ -263,14 +372,12 @@ export default class GiftedDetails extends Vue {
if (this.projectId) { if (this.projectId) {
// console.log("Getting project name from cache", this.projectId); // console.log("Getting project name from cache", this.projectId);
const identity = await libsUtil.getIdentity(this.activeDid);
const project = await getPlanFromCache( const project = await getPlanFromCache(
this.projectId, this.projectId,
identity,
this.axios, this.axios,
this.apiServer, this.apiServer,
this.activeDid,
); );
console.log("Got project name from cache", project);
this.projectName = project?.name this.projectName = project?.name
? "the project: " + project.name ? "the project: " + project.name
: "a project"; : "a project";
@@ -296,16 +403,16 @@ export default class GiftedDetails extends Vue {
cancel() { cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationNameAfter) { if (this.destinationPathAfter) {
this.$router.push({ name: this.destinationNameAfter }); (this.$router as Router).push({ path: this.destinationPathAfter });
} else { } else {
this.$router.back(); (this.$router as Router).back();
} }
} }
cancelBack() { 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 as Router).back();
} }
openImageDialog() { openImageDialog() {
@@ -332,17 +439,12 @@ 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
@@ -442,18 +544,6 @@ export default class GiftedDetails extends Vue {
await this.recordGive(); await this.recordGive();
} }
notifyUser(message: string) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: message,
},
3000,
);
}
notifyUserOfProject() { notifyUserOfProject() {
if (!this.projectId) { if (!this.projectId) {
this.$notify( this.$notify(
@@ -461,18 +551,43 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Error", title: "Error",
text: "To assign to a project, you must open this dialog through a project.", text: "To assign to a project, you must open this page through a project.",
}, },
3000, 3000,
); );
} else { } else {
// must be because givenToUser is true // must be because givenToRecipient is true
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Error", title: "Error",
text: "You cannot assign both to a project and to yourself.", text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
);
} else {
// must be because givenToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a recipient and to a project.",
}, },
3000, 3000,
); );
@@ -488,28 +603,44 @@ 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
const recipientDid = ? this.recipientDid
this.recipientDid === this.activeDid : undefined;
? this.givenToUser
? this.activeDid
: undefined
: this.recipientDid;
const projectId = this.givenToProject ? this.projectId : undefined; const projectId = this.givenToProject ? this.projectId : undefined;
const result = await createAndSubmitGive( let result;
this.axios, if (this.prevCredToEdit) {
this.apiServer, // don't create from a blank one in case some properties were set from a different interface
identity, result = await editAndSubmitGive(
this.giverDid, this.axios,
recipientDid, this.apiServer,
this.description, this.prevCredToEdit,
parseFloat(this.amountInput), this.activeDid,
this.unitCode, this.giverDid,
projectId, recipientDid,
this.offerId, this.description,
this.isTrade, parseFloat(this.amountInput),
this.imageUrl, this.unitCode,
); projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
} else {
result = await createAndSubmitGive(
this.axios,
this.apiServer,
this.activeDid,
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
}
if ( if (
result.type === "error" || result.type === "error" ||
@@ -537,10 +668,10 @@ export default class GiftedDetails extends Vue {
5000, 5000,
); );
localStorage.removeItem("imageUrl"); localStorage.removeItem("imageUrl");
if (this.destinationNameAfter) { if (this.destinationPathAfter) {
this.$router.push({ name: this.destinationNameAfter }); (this.$router as Router).push({ path: this.destinationPathAfter });
} else { } else {
this.$router.back(); (this.$router as Router).back();
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -562,6 +693,26 @@ export default class GiftedDetails extends Vue {
} }
} }
constructGiveParam() {
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const projectId = this.givenToProject ? this.projectId : undefined;
const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(giveClaim);
return claimStr;
}
// Helper functions for readability // Helper functions for readability
/** /**

View File

@@ -24,23 +24,22 @@
<!-- 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 one 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.
</p> </p>
<p> <p>
You highlight giving and also offer help to ideas -- which could be With this, you highlight giving and also offer help --
conditional on others' willingness to help, too. which could be conditional on others' willingness to help, too.
You can record your own ideas and invite others to collaborate. You can record your own ideas and invite others to collaborate.
</p> </p>
<p> <p>
@@ -59,14 +58,28 @@
<fa icon="users" class="fa-fw" /> page. After they register you, you can <fa icon="users" class="fa-fw" /> page. After they register you, you can
select any contact on the home page (or "anonymous") and record your select any contact on the home page (or "anonymous") and record your
appreciation for... whatever. The main goal is to record what people appreciation for... whatever. The main goal is to record what people
have given you, to grow giving economies. Each claim is recorded on a have given you, to grow giving economies. You can also record your own
ideas for projects. Each claim is recorded on a
custom ledger. The day after being registered, you'll be able to able to custom ledger. The day after being registered, you'll be able to able to
register others; later, you can create projects, too. register others, too.
</p> </p>
<p> <p>
Note that there are rate limits to how many others you can register, Note that there are limits to how many others you can register.
so it may take some time to register everyone you want. Take your time... Take your time to bring people on... make it an opportunity to get to
make it an opportunity to get to know their projects, and show your own. know their projects, and to show your own.
</p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<a href="/help-onboarding" target="_blank" class="text-blue-500">
Use these instructions.
</a>
To start scanning, go to the
<router-link class="text-blue-500" to="/contact-qr">contact-scanning page.</router-link>
</p>
<p>
If they are not nearby to scan QR codes, you each can tap on the QR code
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
</p> </p>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
@@ -76,29 +89,13 @@
<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>
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<a href="/help-onboarding" target="_blank" class="text-blue-500">
Use these instructions.
</a>
To start scanning, go
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
</p>
<p>
If they are not nearby to scan QR codes, you each can tap on the QR code
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
</p> </p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2> <h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p> <p>
There are three sets of data to backup: the identifier secrets; There are four sets of data to backup: the identifier secrets;
the non-public textual data that isn't quite a secret such as settings and contacts; the private text data that isn't quite as secret such as settings and contacts;
the non-public image for yourself; and the data that you have sent to the public. the private image for yourself; and the data that you have sent to the public.
</p> </p>
<div class="px-4"> <div class="px-4">
@@ -119,7 +116,7 @@
</ul> </ul>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I backup my non-secret, non-public text 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>
@@ -133,7 +130,7 @@
</ul> </ul>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I backup my non-secret, non-public image? How do I backup my profile image?
</h2> </h2>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
@@ -143,7 +140,7 @@
</ul> </ul>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
How do I backup my public data? How do I backup other data I've posted?
</h2> </h2>
<ul class="list-disc list-outside ml-4"> <ul class="list-disc list-outside ml-4">
<li> <li>
@@ -180,6 +177,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>
@@ -202,6 +200,9 @@
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Mobile Mobile
<ul> <ul>
<li class="list-disc list-outside ml-4">
Home Screen: hold down on the icon, and choose to delete it
</li>
<li class="list-disc list-outside ml-4"> <li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data Chrome: Settings -> Privacy and Security -> Clear Browsing Data
</li> </li>
@@ -340,7 +341,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
@@ -366,6 +367,26 @@
</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 v-show="!showDidCopy" icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showDidCopy" class="ml-2 text-sm text-green-500">Copied</span>
For other donations, contact us.
</p>
<h2 class="text-xl font-semibold">Where can I read more?</h2> <h2 class="text-xl font-semibold">Where can I read more?</h2>
<p> <p>
This is part of the This is part of the
@@ -379,7 +400,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 all your data from the public ledger: 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
@@ -394,6 +415,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";
@@ -405,5 +427,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 -->
@@ -77,91 +77,87 @@
<div v-else> <div v-else>
<!-- !isCreatingIdentifier --> <!-- !isCreatingIdentifier -->
<div <!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
v-if="!activeDid" <div class="mb-4">
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4" <div
> v-if="!isRegistered"
<p class="text-lg mb-3"> id="noticeSomeoneMustRegisterYou"
Want to connect with your contacts, or share contributions or class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
projects?
</p>
<router-link
:to="{ name: 'start' }"
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"
> >
Create An Identifier <!-- activeDid && !isRegistered -->
</router-link> To share, someone must register you.
</div> <router-link
:to="{ name: 'contact-qr' }"
<div 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"
v-else-if="!isRegistered" >
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4" Show Them {{ PASSKEYS_ENABLED ? "Default" : "Your" }} Identifier
> Info
<!-- activeDid && !isRegistered --> </router-link>
Someone must register you before you can give kudos or make offers or <div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
create projects... basically before doing anything. <router-link
<router-link :to="{ name: 'start' }"
:to="{ name: 'contact-qr' }" 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"
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" >
> See all your options first
Show Them Your Identifier Info </router-link>
</router-link> </div>
</div>
<div v-else>
<!-- activeDid && isRegistered -->
<!-- show the actions for recognizing a give -->
<div class="mb-4">
<h2 class="text-xl font-bold">Record Something Given By:</h2>
</div> </div>
<ul <div v-else id="sectionRecordSomethingGiven">
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 -->
>
<li @click="openDialog()">
<img
src="../assets/blank-square.svg"
class="mx-auto border border-slate-300 rounded-md mb-1"
/>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Unnamed/Unknown
</h3>
</li>
<li
v-for="contact in allContacts.slice(0, 7)"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || contact.did }}
</h3>
</li>
</ul>
<div class="flex justify-between"> <!-- show the actions for recognizing a give -->
<router-link <div class="mb-4">
v-if="allContacts.length >= 7" <h2 class="text-xl font-bold">Record Something Given By:</h2>
:to="{ name: 'contact-gift' }" </div>
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"
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
> >
Choose From All Contacts <li @click="openDialog()">
</router-link> <img
<button src="../assets/blank-square.svg"
@click="openGiftedPrompts()" class="mx-auto border border-slate-300 rounded-md mb-1"
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" />
> <h3
Ideas... class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
</button> >
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>
@@ -193,7 +189,7 @@
</button> </button>
</div> </div>
<InfiniteScroll @reached-bottom="loadMoreGives"> <InfiniteScroll @reached-bottom="loadMoreGives">
<ul class="border-t border-slate-300"> <ul id="listLatestActivity" class="border-t border-slate-300">
<li <li
class="border-b border-slate-300 py-2" class="border-b border-slate-300 py-2"
v-for="record in feedData" v-for="record in feedData"
@@ -305,10 +301,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 { 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";
@@ -316,9 +312,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,
@@ -326,17 +325,20 @@ 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, fetchEndorserRateLimits,
getHeaders,
getPlanFromCache, getPlanFromCache,
GiverReceiverInputInfo, 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: {
@@ -354,6 +356,11 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
} }
@Component({ @Component({
computed: {
App() {
return App;
},
},
components: { components: {
GiftedDialog, GiftedDialog,
GiftedPrompts, GiftedPrompts,
@@ -367,6 +374,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> = [];
@@ -374,6 +384,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;
@@ -387,30 +398,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): Promise<IIdentifier | null> {
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;
@@ -418,6 +417,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;
@@ -426,26 +426,18 @@ export default class HomeView extends Vue {
this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings); this.isAnyFeedFilterOn = isAnyFeedFilterOn(settings);
if (this.allMyDids.length === 0) { // someone may have have registered after sharing contact info, so recheck
this.isCreatingIdentifier = true;
this.activeDid = await generateSaveAndActivateIdentity();
this.allMyDids = [this.activeDid];
this.isCreatingIdentifier = false;
}
// someone may have have registered after sharing contact info
if (!this.isRegistered && this.activeDid) { if (!this.isRegistered && this.activeDid) {
const identity = await this.getIdentity(this.activeDid);
try { try {
const resp = await fetchEndorserRateLimits( const resp = await fetchEndorserRateLimits(
this.apiServer, this.apiServer,
this.axios, this.axios,
identity as IIdentifier, this.activeDid,
); );
if (resp.status === 200) { if (resp.status === 200) {
// we just needed to know that they're registered // we just needed to know that they're registered
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true, isRegistered: true,
}); });
this.isRegistered = true; this.isRegistered = true;
@@ -475,6 +467,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;
} }
@@ -483,26 +484,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();
@@ -520,7 +501,7 @@ 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) {
// Since feed now loads projects along the way, it takes longer // Since feed now loads projects along the way, it takes longer
// and the InfiniteScroll component triggers a load before finished. // and the InfiniteScroll component triggers a load before finished.
// One alternative is to totally separate the project link loading. // One alternative is to totally separate the project link loading.
@@ -542,7 +523,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)
@@ -550,7 +531,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
@@ -567,9 +547,9 @@ export default class HomeView extends Vue {
// We should display it immediately and then get the plan later. // 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
@@ -619,7 +599,7 @@ export default class HomeView extends Vue {
this.feedLastViewedClaimId < results.data[0].jwtId this.feedLastViewedClaimId < results.data[0].jwtId
) { ) {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
lastViewedClaimId: results.data[0].jwtId, lastViewedClaimId: results.data[0].jwtId,
}); });
} }
@@ -650,7 +630,7 @@ 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 +
@@ -658,7 +638,7 @@ export default class HomeView extends Vue {
beforeQuery, beforeQuery,
{ {
method: "GET", method: "GET",
headers: await this.buildHeaders(), headers: await getHeaders(this.activeDid),
}, },
); );

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>
@@ -80,10 +99,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { 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 +110,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,20 +123,14 @@ export default class IdentitySwitcherView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || ""; this.apiServerInput = settings?.apiServer || "";
this.showContactGives = !!settings?.showContactGivesInline;
await accountsDB.open(); await accountsDB.open();
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) { for (let n = 0; n < accounts.length; n++) {
try { const acct = accounts[n];
const did = accounts[n]["did"]; this.otherIdentities.push({ id: acct.id as string, did: acct.did });
this.otherIdentities.push({ did: did }); if (acct.did && this.activeDid === acct.did) {
if (did && this.activeDid === did) { this.activeDidInIdentities = true;
this.activeDidInIdentities = true;
}
} catch (err) {
console.error("Error parsing identity:", err);
continue;
} }
} }
} catch (err) { } catch (err) {
@@ -146,7 +156,38 @@ export default class IdentitySwitcherView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did, activeDid: did,
}); });
this.$router.push({ name: "account" }); (this.$router as Router).push({ name: "account" });
}
async deleteAccount(id: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
await accountsDB.open();
await accountsDB.accounts.delete(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
},
},
-1,
);
}
notifyCannotDelete() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Cannot Delete",
text: "You cannot delete the active identity.",
},
3000,
);
} }
} }
</script> </script>

View File

@@ -77,6 +77,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
@@ -110,7 +111,7 @@ export default class ImportAccountView extends Vue {
} }
public onCancelClick() { public onCancelClick() {
this.$router.back(); (this.$router as Router).back();
} }
public async fromMnemonic() { public async fromMnemonic() {
@@ -143,10 +144,10 @@ export default class ImportAccountView extends Vue {
// record that as the active DID // record that as the active DID
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did, activeDid: newId.did,
}); });
this.$router.push({ name: "account" }); (this.$router as Router).push({ name: "account" });
// 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 saving mnemonic & updating settings:", err); console.error("Error saving mnemonic & updating settings:", err);

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">
@@ -70,12 +70,14 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { import {
DEFAULT_ROOT_DERIVATION_PATH, DEFAULT_ROOT_DERIVATION_PATH,
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";
@@ -100,7 +102,7 @@ export default class ImportAccountView extends Vue {
} }
public onCancelClick() { public onCancelClick() {
this.$router.back(); (this.$router as Router).back();
} }
public switchAccount(did: string) { public switchAccount(did: string) {
@@ -124,9 +126,11 @@ export default class ImportAccountView extends Vue {
} }
}); });
// increment the last number in that max derivation path // increment the last number in that max derivation path
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath); const newDerivPath = nextDerivationPath(
accountWithMaxDeriv.derivationPath as string,
);
const mne: string = accountWithMaxDeriv.mnemonic; const mne: string = accountWithMaxDeriv.mnemonic as string;
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath); const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
@@ -144,10 +148,10 @@ export default class ImportAccountView extends Vue {
// record that as the active DID // record that as the active DID
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did, activeDid: newId.did,
}); });
this.$router.push({ name: "account" }); (this.$router as Router).push({ name: "account" });
} catch (err) { } catch (err) {
console.error("Error saving mnemonic & updating settings:", err); console.error("Error saving mnemonic & updating settings:", err);
} }

View File

@@ -45,6 +45,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
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";
@@ -63,18 +65,16 @@ export default class NewEditAccountView extends Vue {
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3 (settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
} }
onClickSaveChanges() { async onClickSaveChanges() {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
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); (this.$router as Router).back();
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
this.$router.back();
} }
onClickCancel() { onClickCancel() {
this.$router.back(); (this.$router as Router).back();
} }
} }
</script> </script>

View File

@@ -74,6 +74,9 @@
v-model="fullClaim.description" v-model="fullClaim.description"
maxlength="5000" maxlength="5000"
></textarea> ></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
If you want to be contacted, be sure to include your contact information.
</div>
<div class="text-xs text-slate-500 italic -mt-3 mb-4"> <div class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ fullClaim.description?.length }}/5000 max. characters {{ fullClaim.description?.length }}/5000 max. characters
</div> </div>
@@ -94,8 +97,8 @@
/> />
<input <input
:disabled="!startDateInput" :disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time" placeholder="Start Time"
v-model="startTimeInput"
type="time" type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2" class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/> />
@@ -173,22 +176,23 @@
<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 { DateTime } from "luxon";
import { IIdentifier } from "@veramo/core";
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 { Router } from "vue-router";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { 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 {
import * as libsUtil from "@/libs/util"; createEndorserJwtVcFromClaim,
getHeaders,
PlanVerifiableCredential,
} from "@/libs/endorserServer";
import { useAppStore } from "@/store/app"; import { useAppStore } from "@/store/app";
import { PlanVerifiableCredential } from "@/libs/endorserServer";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
@Component({ @Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav }, components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
@@ -227,33 +231,6 @@ export default class NewEditProjectView extends Vue {
zoneName = DateTime.local().zoneName; zoneName = DateTime.local().zoneName;
zoom = 2; zoom = 2;
libsUtil = libsUtil;
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 mounted() { async mounted() {
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); this.numAccounts = await accountsDB.accounts.count();
@@ -267,27 +244,17 @@ export default class NewEditProjectView extends Vue {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
this.errNote("There was a problem loading your account info."); 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 });
@@ -342,17 +309,12 @@ export default class NewEditProjectView extends Vue {
return; return;
} }
try { try {
const identity = await libsUtil.getIdentity(this.activeDid); const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
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
@@ -395,7 +357,7 @@ export default class NewEditProjectView extends Vue {
} }
} }
private async saveProject(identity: IIdentifier) { 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) {
@@ -446,110 +408,84 @@ export default class NewEditProjectView extends Vue {
} else { } else {
delete vcClaim.startTime; delete vcClaim.startTime;
} }
// Make a payload for the claim const vcJwt = await createEndorserJwtVcFromClaim(issuerDid, vcClaim);
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 // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim"; const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity); const headers = await getHeaders(issuerDid);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) { if (resp.data?.success?.handleId) {
this.errorMessage = ""; this.errorMessage = "";
useAppStore() useAppStore()
.setProjectId(resp.data.success.handleId) .setProjectId(resp.data.success.handleId)
.then(() => { .then(() => {
this.$router.push({ name: "project" }); (this.$router as Router).push({ name: "project" });
}); });
} else { } else {
console.error( console.error(
"Got unexpected 'data' inside response from server", "Got unexpected 'data' inside response from server",
resp, resp,
); );
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error Saving Idea", title: "Error Saving Idea",
text: "Server did not save the idea. Try again.", text: "Server did not save the idea. Try again.",
}, },
-1, -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;
} }
} 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;
} }
} }
@@ -560,8 +496,7 @@ 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);
} }
} }
@@ -587,7 +522,7 @@ export default class NewEditProjectView extends Vue {
} }
public onCancelClick() { public onCancelClick() {
this.$router.back(); (this.$router as Router).back();
} }
} }
</script> </script>

View File

@@ -54,6 +54,8 @@
<script lang="ts"> <script lang="ts">
import "dexie-export-import"; import "dexie-export-import";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { generateSaveAndActivateIdentity } from "@/libs/util"; import { generateSaveAndActivateIdentity } from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@@ -65,7 +67,7 @@ export default class NewIdentifierView extends Vue {
await generateSaveAndActivateIdentity(); await generateSaveAndActivateIdentity();
this.loading = false; this.loading = false;
setTimeout(() => { setTimeout(() => {
this.$router.push({ name: "home" }); (this.$router as Router).push({ name: "home" });
}, 1000); }, 1000);
} }
} }

View File

@@ -0,0 +1,633 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Offered</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>
Offer to
{{
offeredToProject
? projectName
: offeredToRecipient
? recipientName
: "someone unidentified"
}}</span
>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was offered"
v-model="itemDescription"
data-testId="itemDescription"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
data-testId="inputOfferAmount"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Conditions
</span>
<textarea
class="w-full border border-slate-400 px-3 py-2 rounded-r"
placeholder="Prerequisites, other people to include, etc."
v-model="conditionDescription"
/>
</div>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
{{ validThroughDateInput ? "" : "No" }}&nbsp;Expiration
</span>
<input
v-model="validThroughDateInput"
type="date"
class="w-full rounded border border-slate-400 px-3 py-2 rounded-r"
/>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !offeredToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToProject"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="recipientDid && !offeredToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToRecipient"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfRecipient()"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName
: "No recipient was chosen."
}}
</label>
</div>
<div class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructOfferParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import {
createAndSubmitOffer,
didInfo,
editAndSubmitOffer,
GenericCredWrapper,
getPlanFromCache,
hydrateOffer,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class OfferDetailsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
conditionDescription = "";
itemDescription = "";
destinationPathAfter = "";
offeredToProject = false;
offeredToRecipient = false;
offererDid: string | undefined;
hideBackButton = false;
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
projectId = "";
projectName = "a project";
recipientDid = "";
recipientName = "";
unitCode = "HUR";
validThroughDateInput = "";
libsUtil = libsUtil;
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
) as GenericCredWrapper<OfferVerifiableCredential>)
: undefined;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
);
}
const prevAmount =
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
this.unitCode) as string;
this.conditionDescription =
this.prevCredToEdit?.claim?.description || this.conditionDescription;
this.itemDescription =
(this.$route as Router).query["description"] ||
this.prevCredToEdit?.claim?.itemOffered?.description ||
this.itemDescription;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.offererDid = ((this.$route as Router).query["offererDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.offererDid) as string;
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any project ID
let project;
if (
this.prevCredToEdit?.claim?.itemOffered?.isPartOf["@type"] ===
"PlanAction"
) {
project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf;
}
this.projectId = ((this.$route as Router).query["projectId"] ||
project?.identifier ||
this.projectId) as string;
this.projectName = ((this.$route as Router).query["projectName"] ||
project?.name ||
this.projectName) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.validThroughDateInput =
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
let allContacts: Contact[] = [];
let allMyDids: string[] = [];
if (this.recipientDid && !this.recipientName) {
allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
if (this.projectId && !this.projectName) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache(
this.projectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.projectName = project?.name
? "the project: " + project.name
: "a project";
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
cancelBack() {
(this.$router as Router).back();
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a offer.",
},
2000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.itemDescription && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordOffer();
}
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this page through a project.",
},
3000,
);
} else {
// must be because offeredToRecipient is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
);
} else {
// must be because offeredToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
);
}
}
/**
*
* @param offererDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordOffer() {
try {
const recipientDid = this.offeredToRecipient
? this.recipientDid
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
let result;
if (this.prevCredToEdit) {
// don't create from a blank one in case some properties were set from a different interface
result = await editAndSubmitOffer(
this.axios,
this.apiServer,
this.prevCredToEdit,
this.activeDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
this.validThroughDateInput,
recipientDid,
projectId,
);
} else {
result = await createAndSubmitOffer(
this.axios,
this.apiServer,
this.activeDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
this.validThroughDateInput,
recipientDid,
projectId,
);
}
if (result.type === "error" || this.isCreationError(result.response)) {
const errorMessage = this.getCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That offer was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1,
);
}
}
constructOfferParam() {
const recipientDid = this.offeredToRecipient
? this.recipientDid
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
const giveClaim = hydrateOffer(
this.prevCredToEdit?.claim as OfferVerifiableCredential,
this.activeDid,
recipientDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
projectId,
this.validThroughDateInput,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(giveClaim);
return claimStr;
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

View File

@@ -115,9 +115,54 @@
</button> </button>
</div> </div>
<div v-if="activeDid" class="mt-4"> <div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div>
<div
v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mt-3">
Projects That Contribute To This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
>
{{ plan.name }}
</button>
</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">
<h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div>
</div>
</div>
</div>
<div v-if="activeDid && isRegistered" class="mt-4">
<div class="text-center"> <div class="text-center">
<button <button
data-testId="offerButton"
@click="openOfferDialog()" @click="openOfferDialog()"
class="block w-full text-lg font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" class="block w-full text-lg font-bold 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"
> >
@@ -125,9 +170,13 @@
</button> </button>
</div> </div>
</div> </div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" /> <OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
/>
<div v-if="activeDid"> <div v-if="activeDid && isRegistered">
<div class="text-center"> <div class="text-center">
<p class="mt-2 mt-4 text-center">Record a contribution from:</p> <p class="mt-2 mt-4 text-center">Record a contribution from:</p>
</div> </div>
@@ -256,6 +305,7 @@
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 +313,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,
@@ -300,6 +350,11 @@
<fa icon="circle-check" class="text-blue-500 cursor-pointer" /> <fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a> </a>
</div> </div>
<div v-if="give.fullClaim.image" class="flex justify-center">
<a :href="give.fullClaim.image" target="_blank">
<img :src="give.fullClaim.image" class="h-24 mt-2 rounded-xl" />
</a>
</div>
</li> </li>
</ul> </ul>
<div v-if="givesHitLimit" class="text-center text-blue-500"> <div v-if="givesHitLimit" class="text-center text-blue-500">
@@ -309,38 +364,54 @@
<div class="grid items-start grid-cols-1 gap-4"> <div class="grid items-start grid-cols-1 gap-4">
<div <div
v-if="fulfillersToThis.length > 0" v-if="givesProvidedByThis.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"> <div>
Contributions To This Idea <h3 class="text-sm font-semibold border-b">
</h3> Individuals Getting Contributions From This
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" --> </h3>
<div class="text-center"> <!-- similar to gift display above -->
<div v-for="plan in fulfillersToThis" :key="plan.handleId"> <ul class="text-sm border-t border-slate-300">
<button <li
@click="onClickLoadProject(plan.handleId)" v-for="give in givesProvidedByThis"
class="text-blue-500" :key="give.id"
class="py-1.5 border-b border-slate-300"
> >
{{ plan.name }} <div class="flex justify-between gap-4">
</button> <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 v-if="fulfillersToHitLimit" class="text-center">Load More</div>
</div>
</div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Contributions From This Idea
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -349,9 +420,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { AxiosError } from "axios";
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 GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue";
@@ -364,14 +435,16 @@ import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { import {
BLANK_GENERIC_SERVER_RECORD, BLANK_GENERIC_SERVER_RECORD,
GenericCredWrapper, GenericCredWrapper,
getHeaders,
GiverReceiverInputInfo, GiverReceiverInputInfo,
GiveSummaryRecord, GiveSummaryRecord,
GiveVerifiableCredential,
OfferSummaryRecord, OfferSummaryRecord,
OfferVerifiableCredential,
PlanSummaryRecord, PlanSummaryRecord,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
@@ -401,7 +474,10 @@ export default class ProjectViewView extends Vue {
fulfillersToHitLimit = false; fulfillersToHitLimit = false;
givesToThis: Array<GiveSummaryRecord> = []; givesToThis: Array<GiveSummaryRecord> = [];
givesHitLimit = false; givesHitLimit = false;
givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false;
imageUrl = ""; imageUrl = "";
isRegistered = false;
issuer = ""; issuer = "";
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
@@ -424,29 +500,18 @@ export default class ProjectViewView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open(); await accountsDB.open();
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 as string) || "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() {
@@ -454,7 +519,7 @@ export default class ProjectViewView extends Vue {
const route = { const route = {
name: "new-edit-project", name: "new-edit-project",
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
// 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?
@@ -466,18 +531,12 @@ 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 });
@@ -540,15 +599,12 @@ export default class ProjectViewView extends Vue {
this.loadGives(); this.loadGives();
this.loadGivesProvidedBy();
this.loadOffers(); this.loadOffers();
this.loadFulfillersTo(); this.loadPlanFulfillersTo();
// now load fulfilled-by, a single project
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
const fulfilledByUrl = const fulfilledByUrl =
this.apiServer + this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" + "/api/v2/report/planFulfilledByPlan?planHandleId=" +
@@ -598,15 +654,7 @@ export default class ProjectViewView extends Vue {
} }
const givesInUrl = givesUrl + postfix; const givesInUrl = givesUrl + postfix;
const headers: RawAxiosRequestHeaders = { const headers = await getHeaders(this.activeDid);
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try { try {
const resp = await this.axios.get(givesInUrl, { headers }); const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
@@ -653,15 +701,7 @@ export default class ProjectViewView extends Vue {
} }
const offersInUrl = offersUrl + postfix; const offersInUrl = offersUrl + postfix;
const headers: RawAxiosRequestHeaders = { const headers = await getHeaders(this.activeDid);
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try { try {
const resp = await this.axios.get(offersInUrl, { headers }); const resp = await this.axios.get(offersInUrl, { headers });
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
@@ -696,7 +736,7 @@ export default class ProjectViewView extends Vue {
} }
} }
async loadFulfillersTo() { async loadPlanFulfillersTo() {
const fulfillsUrl = const fulfillsUrl =
this.apiServer + this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" + "/api/v2/report/planFulfillersToPlan?planHandleId=" +
@@ -709,15 +749,7 @@ export default class ProjectViewView extends Vue {
} }
const fulfillsInUrl = fulfillsUrl + postfix; const fulfillsInUrl = fulfillsUrl + postfix;
const headers: RawAxiosRequestHeaders = { const headers = await getHeaders(this.activeDid);
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try { try {
const resp = await this.axios.get(fulfillsInUrl, { headers }); const resp = await this.axios.get(fulfillsInUrl, { headers });
if (resp.status === 200) { if (resp.status === 200) {
@@ -752,6 +784,56 @@ export default class ProjectViewView extends Vue {
} }
} }
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,
);
}
}
/** /**
* Handle clicking on a project entry found in the list * Handle clicking on a project entry found in the list
* @param id of the project * @param id of the project
@@ -761,8 +843,8 @@ export default class ProjectViewView extends Vue {
const route = { const route = {
path: "/project/" + encodeURIComponent(projectId), path: "/project/" + encodeURIComponent(projectId),
}; };
this.$router.push(route); (this.$router as Router).push(route);
this.loadProject(projectId, await this.getIdentity(this.activeDid)); this.loadProject(projectId, this.activeDid);
} }
getOpenStreetMapUrl() { getOpenStreetMapUrl() {
@@ -797,18 +879,18 @@ export default class ProjectViewView extends Vue {
const route = { const route = {
name: "contact-gift", name: "contact-gift",
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
onClickLoadClaim(jwtId: string) { onClickLoadClaim(jwtId: string) {
const route = { const route = {
path: "/claim/" + encodeURIComponent(jwtId), path: "/claim/" + encodeURIComponent(jwtId),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
checkIsFulfillable(offer: OfferSummaryRecord) { checkIsFulfillable(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper = { const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
...BLANK_GENERIC_SERVER_RECORD, ...BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim, claim: offer.fullClaim,
claimType: "Offer", claimType: "Offer",
@@ -818,7 +900,7 @@ export default class ProjectViewView extends Vue {
} }
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) { onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper = { const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
...BLANK_GENERIC_SERVER_RECORD, ...BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim, claim: offer.fullClaim,
issuer: offer.offeredByDid, issuer: offer.offeredByDid,
@@ -863,13 +945,17 @@ export default class ProjectViewView extends Vue {
} }
checkIsConfirmable(give: GiveSummaryRecord) { checkIsConfirmable(give: GiveSummaryRecord) {
const giveDetails: GenericCredWrapper = { const giveDetails: GenericCredWrapper<GiveVerifiableCredential> = {
...BLANK_GENERIC_SERVER_RECORD, ...BLANK_GENERIC_SERVER_RECORD,
claim: give.fullClaim, claim: give.fullClaim,
claimType: "GiveAction", claimType: "GiveAction",
issuer: give.agentDid, issuer: give.agentDid,
}; };
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid); return libsUtil.isGiveRecordTheUserCanConfirm(
this.isRegistered,
giveDetails,
this.activeDid,
);
} }
confirmConfirmClaim(give: GiveSummaryRecord) { confirmConfirmClaim(give: GiveSummaryRecord) {
@@ -906,7 +992,7 @@ export default class ProjectViewView extends Vue {
}; };
const result = await serverUtil.createAndSubmitClaim( const result = await serverUtil.createAndSubmitClaim(
confirmationClaim, confirmationClaim,
await this.getIdentity(this.activeDid), this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
); );

View File

@@ -86,7 +86,7 @@
Look for projects worth some of your time. Look for projects worth some of your time.
</router-link> </router-link>
</div> </div>
<ul class="border-t border-slate-300"> <ul id="listOffers" class="border-t border-slate-300">
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
v-for="offer in offers" v-for="offer in offers"
@@ -109,6 +109,19 @@
</div> </div>
<div> <div>
<div>
To
{{
offer.fulfillsPlanHandleId
? projectNameFromHandleId[offer.fulfillsPlanHandleId]
: didInfo(
offer.recipientDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
<div> <div>
{{ offer.objectDescription }} {{ offer.objectDescription }}
</div> </div>
@@ -189,12 +202,16 @@
<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"> <div v-if="projects.length === 0" class="text-center py-4">
You have not announced any projects. You have not announced any projects.
<br /> <div v-if="isRegistered">
Hit the big Hit the big
<fa icon="plus" class="bg-blue-600 text-white px-1 py-1 rounded-full" /> <fa
button. You'll never know until you try. icon="plus"
class="bg-blue-600 text-white px-1 py-1 rounded-full"
/>
button. You'll never know until you try.
</div>
</div> </div>
<ul class="border-t border-slate-300"> <ul id="listProjects" class="border-t border-slate-300">
<li <li
class="border-b border-slate-300" class="border-b border-slate-300"
v-for="project in projects" v-for="project in projects"
@@ -229,19 +246,25 @@
<script lang="ts"> <script lang="ts">
import { AxiosRequestConfig } from "axios"; import { AxiosRequestConfig } from "axios";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
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 {
didInfo,
getHeaders,
getPlanFromCache,
OfferSummaryRecord,
PlanData,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import { Contact } from "@/db/tables/contacts";
@Component({ @Component({
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage }, components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
@@ -255,33 +278,39 @@ export default class ProjectsView extends Vue {
); );
} }
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
projects: PlanData[] = []; projects: PlanData[] = [];
currentIid: IIdentifier;
isLoading = false; isLoading = false;
isRegistered = false; isRegistered = false;
numAccounts = 0;
offers: OfferSummaryRecord[] = []; offers: OfferSummaryRecord[] = [];
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
showOffers = true; showOffers = true;
showProjects = false; showProjects = false;
libsUtil = libsUtil; libsUtil = libsUtil;
didInfo = didInfo;
async mounted() { async mounted() {
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; this.isRegistered = !!settings?.isRegistered;
this.allContacts = await db.contacts.toArray();
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); const allAccounts = await accountsDB.accounts.toArray();
if (this.numAccounts === 0) { this.allMyDids = allAccounts.map((acc) => acc.did);
if (allAccounts.length === 0) {
console.error("No accounts found."); console.error("No accounts found.");
this.errNote("You need an identifier to load your projects."); this.errNote("You need an identifier to load your projects.");
} else { } else {
this.currentIid = await this.getIdentity(activeDid);
await this.loadOffers(); await this.loadOffers();
} }
} catch (err) { } catch (err) {
@@ -295,13 +324,9 @@ 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 } as AxiosRequestConfig); const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
@@ -341,39 +366,18 @@ export default class ProjectsView extends Vue {
async loadMoreProjectData(payload: boolean) { async loadMoreProjectData(payload: boolean) {
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(`beforeId=${latestProject.rowid}`);
this.currentIid,
`beforeId=${latestProject.rowid}`,
);
} }
} }
/** /**
* 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(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;
} }
/** /**
@@ -385,7 +389,7 @@ export default class ProjectsView extends Vue {
const route = { const route = {
path: "/project/" + encodeURIComponent(id), path: "/project/" + encodeURIComponent(id),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
/** /**
@@ -396,14 +400,14 @@ export default class ProjectsView extends Vue {
const route = { const route = {
name: "new-edit-project", name: "new-edit-project",
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
onClickLoadClaim(jwtId: string) { onClickLoadClaim(jwtId: string) {
const route = { const route = {
path: "/claim/" + encodeURIComponent(jwtId), path: "/claim/" + encodeURIComponent(jwtId),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
/** /**
@@ -411,17 +415,38 @@ 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 = await 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 } as AxiosRequestConfig); 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); // add one-by-one as they retrieve project names, potentially from the server
for (const offer of resp.data.data) {
if (offer.fulfillsPlanHandleId) {
const project = await getPlanFromCache(
offer.fulfillsPlanHandleId,
this.axios,
this.apiServer,
this.activeDid,
);
const projectName = project?.name as string;
console.log(
"now have name for",
offer.fulfillsPlanHandleId,
projectName,
);
this.projectNameFromHandleId[offer.fulfillsPlanHandleId] =
projectName;
console.log(
"now have a real name for",
offer.fulfillsPlanHandleId,
this.projectNameFromHandleId[offer.fulfillsPlanHandleId],
);
}
this.offers = this.offers.concat([offer]);
}
} else { } else {
console.error( console.error(
"Bad server response & data for offers:", "Bad server response & data for offers:",
@@ -462,20 +487,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(`&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(urlExtra: string = "") {
const identity = identifier || this.currentIid; const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${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

@@ -124,7 +124,6 @@ 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); this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
@@ -134,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,
@@ -165,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,
); );

View File

@@ -138,28 +138,26 @@
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 { Router } from "vue-router";
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 },
@@ -177,7 +175,7 @@ export default class QuickActionBvcBeginView extends Vue {
apiServer = ""; apiServer = "";
claimCountByUser = 0; claimCountByUser = 0;
claimCountWithHidden = 0; claimCountWithHidden = 0;
claimsToConfirm: GenericCredWrapper[] = []; claimsToConfirm: GenericCredWrapper<GenericVerifiableCredential>[] = [];
claimsToConfirmSelected: string[] = []; claimsToConfirmSelected: string[] = [];
description = "breakfast"; description = "breakfast";
loadingConfirms = true; loadingConfirms = true;
@@ -213,16 +211,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 +
@@ -239,7 +228,8 @@ export default class QuickActionBvcBeginView extends Vue {
} }
await response.json().then((data) => { await response.json().then((data) => {
const dataByOthers = R.reject( const dataByOthers = R.reject(
(claim: GenericCredWrapper) => claim.issuer === this.activeDid, (claim: GenericCredWrapper<GenericVerifiableCredential>) =>
claim.issuer === this.activeDid,
data, data,
); );
const dataByOthersWithoutHidden = R.reject( const dataByOthersWithoutHidden = R.reject(
@@ -270,13 +260,11 @@ export default class QuickActionBvcBeginView extends Vue {
const route = { const route = {
path: "/claim/" + encodeURIComponent(jwtId), path: "/claim/" + encodeURIComponent(jwtId),
}; };
this.$router.push(route); (this.$router as Router).push(route);
} }
async record() { async record() {
try { try {
const identity = await libsUtil.getIdentity(this.activeDid);
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); 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
@@ -288,9 +276,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,
@@ -324,7 +311,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,

View File

@@ -105,6 +105,7 @@ import {
LRectangle, LRectangle,
LTileLayer, LTileLayer,
} from "@vue-leaflet/vue-leaflet"; } from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@@ -198,7 +199,7 @@ export default class DiscoverView extends Vue {
}, },
}; };
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [newSearchBox], searchBoxes: [newSearchBox],
}); });
this.searchBox = newSearchBox; this.searchBox = newSearchBox;
@@ -213,7 +214,7 @@ export default class DiscoverView extends Vue {
}, },
7000, 7000,
); );
this.$router.back(); (this.$router as Router).back();
} catch (err) { } catch (err) {
this.$notify( this.$notify(
{ {
@@ -245,7 +246,7 @@ export default class DiscoverView extends Vue {
public async forgetSearchBox() { public async forgetSearchBox() {
try { try {
await db.open(); await db.open();
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
searchBoxes: [], searchBoxes: [],
filterFeedByNearby: false, filterFeedByNearby: false,
}); });

View File

@@ -33,30 +33,65 @@
<p class="text-center mb-4"> <p class="text-center mb-4">
<b class="text-red-600">BEWARE!</b> Anyone who has this seed phrase will <b class="text-red-600">BEWARE!</b> Anyone who has this seed phrase will
be able impersonate you and take over any digital holdings based on it. be able impersonate you and take over any digital holdings based on it.
Reveal it when you are somewhere only you can see your screen, and Reveal it when you are somewhere private, when only you can see your
record it somewhere only you have access. screen, and record it somewhere only you have access. A password manager
<i>Don't take a screenshot or send it to any online service.</i> is a good idea, and so is a piece of paper in a vault.
<i
>We recommend you do NOT take a screenshot or send it to any online
service.</i
>
</p> </p>
<p v-if="numAccounts > 1"> <p v-if="numAccounts > 1">
<b class="text-orange-600">Note:</b> You have more than one identifier <b class="text-orange-600">Note:</b> You have more than one identifier
stored in this browser. If they are all based on the same seed as the stored in this browser. If they are all based on the same seed as the
current identifier, this one backup is sufficient; however, if you have current identifier, this one backup is sufficient, as long as you also
different seeds for other identifiers, you will have to back them up record the derivation path. However, if you have different seeds for
separately. other identifiers, you will have to back them up separately.
</p> </p>
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4"> <div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
{{ activeAccount.mnemonic }}
<button
v-show="!showCopiedSeed"
@click="
doCopyTwoSecRedo(
activeAccount.mnemonic as string,
() => (showCopiedSeed = !showCopiedSeed),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showCopiedSeed" class="text-sm text-green-500">
Copied
</span>
<br />
<br />
Derivation Path: {{ activeAccount.derivationPath }}
<button
v-show="!showCopiedDeri"
@click="
doCopyTwoSecRedo(
activeAccount.derivationPath as string,
() => (showCopiedDeri = !showCopiedDeri),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showCopiedDeri" class="text-sm text-green-500"
>Copied</span
>
</p>
<button <button
v-else
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="showSeedPhrase" @click="showSeed = true"
> >
Reveal my Seed Phrase Reveal my Seed Phrase
</button> </button>
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
{{ activeAccount.mnemonic }}
</p>
</div> </div>
</div> </div>
<div v-else>You do not have an active identifier.</div> <div v-else>You do not have an active identifier.</div>
@@ -64,8 +99,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@@ -79,6 +115,8 @@ export default class SeedBackupView extends Vue {
activeAccount: Account | null | undefined = null; activeAccount: Account | null | undefined = null;
numAccounts = 0; numAccounts = 0;
showCopiedDeri = false;
showCopiedSeed = false;
showSeed = false; showSeed = false;
// 'created' hook runs when the Vue instance is first created // 'created' hook runs when the Vue instance is first created
@@ -106,8 +144,12 @@ export default class SeedBackupView extends Vue {
} }
} }
showSeedPhrase() { // call fn, copy text to the clipboard, then redo fn after 2 seconds
this.showSeed = true; doCopyTwoSecRedo(text: string, fn: () => void) {
fn();
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
} }
} }
</script> </script>

View File

@@ -48,6 +48,17 @@
</div> </div>
<div v-else class="text-center mb-4"> <div v-else class="text-center mb-4">
<p>No image found.</p> <p>No image found.</p>
<p class="mt-4">
If you shared an image, the cause is usually that you do not have the
recent version of this app, or that the app has not refreshed the
service code underneath. To fix this, first make sure you have latest
version by comparing your version at the bottom of "Help" with the
version at the bottom of https://timesafari.app/help in a browser. After
that, it may eventually work, but you can speed up the process by
clearing your data cache (in the browser on mobile, even if you
installed it) and/or reinstalling the app (after backing up all your
data, of course).
</p>
</div> </div>
</section> </section>
</template> </template>
@@ -55,6 +66,7 @@
<script lang="ts"> <script lang="ts">
import axios from "axios"; import axios from "axios";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationRaw, Router } from "vue-router";
import PhotoDialog from "@/components/PhotoDialog.vue"; import PhotoDialog from "@/components/PhotoDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@@ -65,8 +77,8 @@ import {
} from "@/constants/app"; } from "@/constants/app";
import { db } from "@/db/index"; import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getIdentity } from "@/libs/util";
import { accessToken } from "@/libs/crypto"; import { accessToken } from "@/libs/crypto";
import { base64ToBlob, SHARED_PHOTO_BASE64_KEY } from "@/libs/util";
@Component({ components: { PhotoDialog, QuickNav } }) @Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue { export default class SharedPhotoView extends Vue {
@@ -86,14 +98,19 @@ export default class SharedPhotoView extends Vue {
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;
const temp = await db.temp.get("shared-photo"); const temp = await db.temp.get(SHARED_PHOTO_BASE64_KEY);
const imageB64 = temp?.blobB64 as string;
if (temp) { if (temp) {
this.imageBlob = temp.blob; this.imageBlob = base64ToBlob(imageB64);
// clear the temp image // clear the temp image
db.temp.delete("shared-photo"); db.temp.delete(SHARED_PHOTO_BASE64_KEY);
this.imageFileName = this.$route.query.fileName as string; this.imageFileName = (this.$route as Router).query[
"fileName"
] as string;
} else {
console.error("No appropriate image found in temp storage.", temp);
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error("Got an error loading an identifier:", err); console.error("Got an error loading an identifier:", err);
@@ -112,15 +129,17 @@ export default class SharedPhotoView extends Vue {
async recordGift() { async recordGift() {
await this.sendToImageServer("GiveAction").then((url) => { await this.sendToImageServer("GiveAction").then((url) => {
if (url) { if (url) {
this.$router.push({ const route = {
name: "gifted-details", name: "gifted-details",
// this might be wrong since "name" goes with params, but it works so test well when you change it
query: { query: {
destinationNameAfter: "home", destinationPathAfter: "/",
hideBackButton: true, hideBackButton: true,
imageUrl: url, imageUrl: url,
recipientDid: this.activeDid, recipientDid: this.activeDid,
}, },
}); } as RouteLocationRaw;
(this.$router as Router).push(route);
} }
}); });
} }
@@ -128,10 +147,10 @@ export default class SharedPhotoView extends Vue {
recordProfile() { recordProfile() {
(this.$refs.photoDialog as PhotoDialog).open( (this.$refs.photoDialog as PhotoDialog).open(
async (imgUrl) => { async (imgUrl) => {
db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl, profileImageUrl: imgUrl,
}); });
this.$router.push({ name: "account" }); (this.$router as Router).push({ name: "account" });
}, },
IMAGE_TYPE_PROFILE, IMAGE_TYPE_PROFILE,
true, true,
@@ -143,7 +162,7 @@ export default class SharedPhotoView extends Vue {
async cancel() { async cancel() {
this.imageBlob = undefined; this.imageBlob = undefined;
this.imageFileName = undefined; this.imageFileName = undefined;
this.$router.push({ name: "home" }); (this.$router as Router).push({ name: "home" });
} }
async sendToImageServer(imageType: string) { async sendToImageServer(imageType: string) {
@@ -152,10 +171,10 @@ export default class SharedPhotoView extends Vue {
let result; let result;
try { try {
// send the image to the server // send the image to the server
const identifier = await getIdentity(this.activeDid as string); const token = await accessToken(this.activeDid);
const token = await accessToken(identifier);
const headers = { const headers = {
Authorization: "Bearer " + token, Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
}; };
const formData = new FormData(); const formData = new FormData();
formData.append( formData.append(

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,30 +88,48 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
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 as Router).push({ name: "new-identifier" });
}
public async onClickNewPasskey() {
const keyName =
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : "");
await registerSaveAndActivatePasskey(keyName);
(this.$router as Router).push({ name: "account" });
} }
public onClickNo() { public onClickNo() {
this.$router.push({ name: "import-account" }); (this.$router as Router).push({ name: "import-account" });
} }
public onClickDerive() { public onClickDerive() {
this.$router.push({ name: "import-derive" }); (this.$router as Router).push({ name: "import-derive" });
} }
} }
</script> </script>

View File

@@ -157,7 +157,7 @@
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2> <h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target". Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" /> <input type="file" data-testid="fileInput" @change="uploadFile" />
<router-link <router-link
v-if="showFileNextStep()" v-if="showFileNextStep()"
:to="{ :to="{
@@ -165,6 +165,7 @@
query: { fileName }, 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" class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
data-testid="fileUploadButton"
> >
Go to Shared Page Go to Shared Page
</router-link> </router-link>
@@ -173,59 +174,65 @@
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-bold mb-4">Passkeys</h2> <h2 class="text-xl font-bold mb-4">Passkeys</h2>
See console for results. See console for results.
<br/> <br />
Active DID: {{ activeDid }} See existing passkeys in Chrome at: chrome://settings/passkeys
{{ credIdHex ? "has passkey ID" : "has no passkey ID" }} <br />
Active DID: {{ activeDid || "nothing, which" }}
{{ credIdHex ? "has a passkey ID" : "has no passkey ID" }}
<div> <div>
Register Register Passkey
<button <button
@click="register()" @click="register()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
Simplewebauthn Simplewebauthn
</button> </button>
</div> </div>
<div> <div>
Create Create JWT
<button <button
@click="createJwtSimplewebauthn()" @click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
Simplewebauthn Simplewebauthn
</button> </button>
<button <button
@click="createJwtNavigator()" @click="createJwtNavigator()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
Navigator Navigator
</button> </button>
</div> </div>
<div v-if="jwt"> <div v-if="jwt">
Verify Verify New JWT
<button <button
@click="verifySimplewebauthn()" @click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
Simplewebauthn Simplewebauthn
</button> </button>
<button <button
@click="verifyWebCrypto()" @click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
WebCrypto WebCrypto
</button> </button>
<button <button
@click="verifyP256()" @click="verifyP256()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
p256 - broken p256 - broken
</button> </button>
</div> </div>
<div v-else>Verify New JWT -- requires creation first</div>
<button <button
@click="verifyMyJwt()" @click="verifyMyJwt()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
> >
Verify Mine Verify Hard-Coded JWT
</button> </button>
</div> </div>
</section> </section>
@@ -236,18 +243,26 @@ import { Buffer } from "buffer/";
import { Base64URLString } from "@simplewebauthn/types"; import { Base64URLString } from "@simplewebauthn/types";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
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 { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as vcLib from "@/libs/crypto/vc";
import { import {
createPeerDid,
PeerSetup, PeerSetup,
registerCredential,
verifyJwtP256, verifyJwtP256,
verifyJwtSimplewebauthn, verifyJwtSimplewebauthn,
verifyJwtWebCrypto, verifyJwtWebCrypto,
} from "@/libs/didPeer"; } from "@/libs/crypto/vc/passkeyDidPeer";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import {
AccountKeyInfo,
blobToBase64,
getAccount,
registerAndSavePasskey,
SHARED_PHOTO_BASE64_KEY,
} from "@/libs/util";
const inputFileNameRef = ref<Blob>(); const inputFileNameRef = ref<Blob>();
@@ -263,6 +278,8 @@ const TEST_PAYLOAD = {
@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 // for file import
fileName?: string; fileName?: string;
@@ -294,7 +311,7 @@ export default class Help extends Vue {
} }
async uploadFile(event: Event) { async uploadFile(event: Event) {
inputFileNameRef.value = event.target.files[0]; inputFileNameRef.value = event.target?.["files"][0];
// https://developer.mozilla.org/en-US/docs/Web/API/File // https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing // ... plus it has a `type` property from my testing
const file = inputFileNameRef.value; const file = inputFileNameRef.value;
@@ -306,12 +323,13 @@ export default class Help extends Vue {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type, type: file.type,
}); });
const blobB64 = await blobToBase64(blob);
this.fileName = file.name as string; this.fileName = file.name as string;
const temp = await db.temp.get("shared-photo"); const temp = await db.temp.get(SHARED_PHOTO_BASE64_KEY);
if (temp) { if (temp) {
await db.temp.update("shared-photo", { blob }); await db.temp.update(SHARED_PHOTO_BASE64_KEY, { blobB64 });
} else { } else {
await db.temp.add({ id: "shared-photo", blob }); await db.temp.add({ id: SHARED_PHOTO_BASE64_KEY, blobB64 });
} }
} }
}; };
@@ -324,21 +342,41 @@ export default class Help extends Vue {
} }
public async register() { public async register() {
const cred = await registerCredential(this.userName); const DEFAULT_USERNAME = AppString.APP_NAME + " Tester";
const publicKeyBytes = cred.publicKeyBytes; if (!this.userName) {
this.activeDid = createPeerDid(publicKeyBytes as Uint8Array); this.$notify(
this.credIdHex = cred.credIdHex as string; {
group: "modal",
await accountsDB.open(); type: "confirm",
await accountsDB.accounts.add({ title: "No Name",
dateCreated: new Date().toISOString(), text: "You should have a name to attach to this passkey. Would you like to enter your own name first?",
did: this.activeDid, onNo: async () => {
passkeyCredIdHex: this.credIdHex, this.userName = DEFAULT_USERNAME;
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), },
});`` onYes: async () => {
(this.$router as Router).push({ name: "new-edit-account" });
},
noText: "try again and use " + DEFAULT_USERNAME,
},
-1,
);
return;
}
const account = await registerAndSavePasskey(
AppString.APP_NAME + " - " + this.userName,
);
this.activeDid = account.did;
this.credIdHex = account.passkeyCredIdHex;
} }
public async createJwtSimplewebauthn() { 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.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtSimplewebauthn( this.jwt = await this.peerSetup.createJwtSimplewebauthn(
this.activeDid as string, this.activeDid as string,
@@ -349,6 +387,13 @@ export default class Help extends Vue {
} }
public async createJwtNavigator() { 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.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtNavigator( this.jwt = await this.peerSetup.createJwtNavigator(
this.activeDid as string, this.activeDid as string,
@@ -360,44 +405,46 @@ export default class Help extends Vue {
public async verifyP256() { public async verifyP256() {
const decoded = await verifyJwtP256( const decoded = await verifyJwtP256(
this.credIdHex as Base64URLString, this.credIdHex as string,
this.activeDid as string, this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer, this.peerSetup?.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array, this.peerSetup?.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString, this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString, this.peerSetup?.signature as Base64URLString,
); );
console.log("decoded", decoded); console.log("decoded", decoded);
} }
public async verifySimplewebauthn() { public async verifySimplewebauthn() {
const decoded = await verifyJwtSimplewebauthn( const decoded = await verifyJwtSimplewebauthn(
this.credIdHex as Base64URLString, this.credIdHex as string,
this.activeDid as string, this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer, this.peerSetup?.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array, this.peerSetup?.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString, this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString, this.peerSetup?.signature as Base64URLString,
); );
console.log("decoded", decoded); console.log("decoded", decoded);
} }
public async verifyWebCrypto() { public async verifyWebCrypto() {
const decoded = await verifyJwtWebCrypto( const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString, this.credIdHex as string,
this.activeDid as string, this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer, this.peerSetup?.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array, this.peerSetup?.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString, this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString, this.peerSetup?.signature as Base64URLString,
); );
console.log("decoded", decoded); console.log("decoded", decoded);
} }
public async verifyMyJwt() { public async verifyMyJwt() {
const did =
"did:peer:0zKMFjvUgYrM1hXwDciYHiA9MxXtJPXnRLJvqoMNAKoDLX9pKMWLb3VDsgua1p2zW1xXRsjZSTNsfvMnNyMS7dB4k7NAhFwL3pXBrBXgyYJ9ri";
const jwt = const jwt =
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE"; "eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
const pieces = jwt.split("."); const pieces = jwt.split(".");
console.log("pieces", typeof pieces[1], pieces);
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString()); const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64"); const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
const clientJSON = Buffer.from( const clientJSON = Buffer.from(
@@ -408,8 +455,8 @@ export default class Help extends Vue {
const challenge = clientData.challenge; const challenge = clientData.challenge;
const signatureB64URL = pieces[2]; const signatureB64URL = pieces[2];
const decoded = await verifyJwtWebCrypto( const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString, this.credIdHex as string,
this.activeDid as string, did,
authData, authData,
challenge, challenge,
payload["ClientDataJSONB64URL"], payload["ClientDataJSONB64URL"],

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -566,14 +566,27 @@ async function getNotificationCount() {
return result; return result;
} }
async function blobToBase64String(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result); // potential problem if it returns an ArrayBuffer?
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// Store the image blob and go immediate to a page to upload it. // Store the image blob and go immediate to a page to upload it.
// @param photo - image Blob to store for later retrieval after redirect // @param photo - image Blob to store for later retrieval after redirect
async function savePhoto(photo) { async function savePhoto(photo) {
try { try {
const photoBase64 = await blobToBase64String(photo);
const db = await openIndexedDB("TimeSafari"); const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("temp", "readwrite"); const transaction = db.transaction("temp", "readwrite");
const store = transaction.objectStore("temp"); const store = transaction.objectStore("temp");
await updateRecord(store, { id: "shared-photo", blob: photo }); await updateRecord(store, {
id: "shared-photo-base64",
blobB64: photoBase64,
});
transaction.oncomplete = () => db.close(); transaction.oncomplete = () => db.close();
} catch (error) { } catch (error) {
console.error("safari-notifications logMessage IndexedDB error", error); console.error("safari-notifications logMessage IndexedDB error", error);

View File

@@ -0,0 +1,16 @@
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 12,
sourceType: 'module',
requireConfigFile: false,
},
plugins: [],
rules: {
quotes: [
'error',
'single',
{ avoidEscape: true, allowTemplateLiterals: true },
],
},
};

View File

@@ -0,0 +1,81 @@
import { test, expect } from '@playwright/test';
test('Confirm usage of test API (may fail if you are running your own Time Safari)', async ({ page }, testInfo) => {
// Load account view
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
// look into the config file: if it starts Time Safari, it might say which server it should set by default
const webServer = testInfo.config.webServer;
const endorserWords = webServer?.command.split(' ');
const ENDORSER_ENV_NAME = 'VITE_DEFAULT_ENDORSER_API_SERVER';
const endorserTerm = endorserWords?.find(word => word.startsWith(ENDORSER_ENV_NAME + '='));
const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1);
const endorserServer = endorserTermInConfig || 'https://test-api.endorser.ch';
await expect(page.getByRole('textbox').nth(1)).toHaveValue(endorserServer);
});
test('Check activity feed', async ({ page }) => {
// Load app homepage
await page.goto('./');
// Check that initial 10 activities have been loaded
await page.locator('ul#listLatestActivity li:nth-child(10)');
// Scroll down a bit to trigger loading additional activities
await page.locator('ul#listLatestActivity li:nth-child(50)').scrollIntoViewIfNeeded();
});
test('Check discover results', async ({ page }) => {
// Load Discover view
await page.goto('./discover');
// Check that initial 10 projects have been loaded
await page.locator('ul#listDiscoverResults li.border-b:nth-child(10)');
// Scroll down a bit to trigger loading additional projects
await page.locator('ul#listDiscoverResults li.border-b:nth-child(20)').scrollIntoViewIfNeeded();
});
test('Check no-ID messaging in homepage', async ({ page }) => {
// Load app homepage
await page.goto('./');
// Check 'someone must register you' notice
await expect(page.getByText('To share, someone must register you.')).toBeVisible();
});
test('Check no-ID messaging in account', async ({ page }) => {
// Load account view
await page.goto('./account');
// Check 'someone must register you' notice
await expect(page.locator('#noticeBeforeShare')).toBeVisible();
// Check 'a friend needs to register you' notice
await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible();
// Check that there is no ID
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty();
});
test('Check ID generation', async ({ page }) => {
// Load Account view
await page.goto('./account');
// Check that ID is empty
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty();
// Load homepage to trigger ID generation (?)
await page.goto('./');
// Wait for activity feed to start loading, as a delay
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
// Go back to Account view
await page.goto('./account');
// Check that ID is now generated
await expect(page.locator('#sectionIdentityDetails code.truncate')).toContainText('did:ethr:');
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Check usage limits', async ({ page }) => {
// Check without ID first
await page.goto('./account');
await expect(page.locator('div.bg-slate-100.rounded-md').filter({ hasText: 'Usage Limits' })).toBeHidden();
// Import user 01
await importUser(page, '01');
// Verify that "Usage Limits" section is visible
await expect(page.locator('#sectionUsageLimits')).toBeVisible();
await expect(page.getByText('Your claims counter resets')).toBeVisible();
await expect(page.getByText('Your registration counter resets')).toBeVisible();
await expect(page.getByText('Your image counter resets')).toBeVisible();
await expect(page.getByRole('button', { name: 'Recheck Limits' })).toBeVisible();
});

View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Create new project, then search for it', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
while (randomString.length < 16) {
randomString += Math.random().toString(36).substring(2, 18);
}
const finalRandomString = randomString.substring(0, 16);
// Standard texts
const standardTitle = "Idea ";
const standardDescription = "Description of Idea ";
// Combine texts with the random string
const finalTitle = standardTitle + finalRandomString;
const finalDescription = standardDescription + finalRandomString;
// Import user 00
await importUser(page, '00');
// Pause for 5 seconds
await page.waitForTimeout(5000); // I have to wait, otherwise the (+) button to add a new project doesn't appear
// Create new project
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.getByRole('button').click();
await page.getByPlaceholder('Idea Name').fill(finalTitle); // Add random suffix
await page.getByPlaceholder('Description').fill(finalDescription);
await page.getByPlaceholder('Website').fill('https://example.com');
await page.getByPlaceholder('Start Date').fill('2025-12-01');
await page.getByPlaceholder('Start Time').fill('12:00');
await page.getByRole('button', { name: 'Save Project' }).click();
// Check texts
await expect(page.locator('h2')).toContainText(finalTitle);
await expect(page.locator('#Content')).toContainText(finalDescription);
// Search for newly-created project in /projects
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await expect(page.locator('ul#listProjects li.border-b:nth-child(1)')).toContainText(finalRandomString); // Assumes newest project always appears first in the Projects tab list
// Search for newly-created project in /discover
await page.goto('./discover');
await page.getByPlaceholder('Search…').fill(finalRandomString);
await page.locator('#QuickSearch button').click();
await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(1)')).toContainText(finalRandomString);
});

View File

@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record something given', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
while (randomString.length < 16) {
randomString += Math.random().toString(36).substring(2, 18);
}
const finalRandomString = randomString.substring(0, 16);
// Generate a random non-zero single-digit number
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Standard title prefix
const standardTitle = "Gift ";
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
// Import user 00
await importUser(page, '00');
// Record something given
await page.goto('./');
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// Refresh home view and check gift
await page.goto('./');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
const page1Promise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'View on the Public Server' }).click();
const page1 = await page1Promise;
});

View File

@@ -0,0 +1,69 @@
import path from 'path';
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record item given from image-share', async ({ page }) => {
let randomString = Math.random().toString(36).substring(2, 8);
// Combine title prefix with the random string
const finalTitle = `Gift ${randomString} from image-share`;
await importUser(page, '00');
// Record something given
await page.goto('./test');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByTestId('fileInput').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, '..', 'public', 'img', 'icons', 'android-chrome-192x192.png'));
await page.getByTestId('fileUploadButton').click();
// on shared photo page, choose the gift option
await page.getByRole('button').filter({ hasText: /gift/i }).click();
await page.getByTestId('imagery').getByRole('img').isVisible();
await page.getByPlaceholder('What was received').fill(finalTitle);
await page.getByRole('spinbutton').fill('2');
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// Refresh home view and check gift
await page.goto('./');
const item1 = page.locator('li').filter({ hasText: finalTitle });
await expect(item1.getByRole('img')).toBeVisible();
});
// // I believe there's a way to test this service worker feature.
// // The following is what I got from ChatGPT. I wonder if it doesn't work because it's not registering the service worker correctly.
//
// test('Trigger a photo-sharing fetch event in service worker with POST to /share-target', async ({ page }) => {
// await importUser(page, '00');
//
// // Create a FormData object with a photo
// const photoPath = path.join(__dirname, '..', 'public', 'img', 'icons', 'android-chrome-192x192.png');
// const photoContent = await fs.readFileSync(photoPath);
// const [response] = await Promise.all([
// page.waitForResponse(response => response.url().includes('/share-target')), // also check for response.status() === 303 ?
// page.evaluate(async (photoContent) => {
// const formData = new FormData();
// formData.append('photo', new Blob([photoContent], { type: 'image/png' }), 'test-photo.jpg');
//
// const response = await fetch('/share-target', {
// method: 'POST',
// body: formData,
// });
//
// return response;
// }, photoContent)
// ]);
//
// // Verify the response redirected to /shared-photo
// //expect(response.status).toBe(303);
// console.log('response headers', response.headers());
// console.log('response status', response.status());
// console.log('response url', response.url());
// expect(response.url()).toContain('/shared-photo');
// });

View File

@@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Add contact, record gift, confirm gift', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
while (randomString.length < 16) {
randomString += Math.random().toString(36).substring(2, 18);
}
const finalRandomString = randomString.substring(0, 16);
// Generate a random non-zero single-digit number
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Standard title prefix
const standardTitle = "Gift ";
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
// Contact name
const contactName = 'Contact 00';
// Import user 01
await importUser(page, '01');
// Add new contact 00
await page.goto('./contacts');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"]')).toBeVisible();
// Why doesn't the alert box come up every time?
// await page.locator('div[role="alert"] button:has-text("Yes")').click();
// Verify added contact
await expect(page.locator('li.border-b')).toContainText('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
// Rename contact
await page.locator('li.border-b h2 > button[title="Edit"]').click();
await expect(page.locator('div.dialog-overlay > div.dialog').filter({ hasText: 'Edit Name' })).toBeVisible();
await page.getByPlaceholder('Name', { exact: true }).fill(contactName);
await page.locator('.dialog > .flex > button').first().click();
// Confirm that home shows contact in "Record Something…"
await page.goto('./');
await expect(page.locator('#sectionRecordSomethingGiven ul li').filter({ hasText: contactName }).nth(0)).toBeVisible();
// Record something given by new contact
await page.getByRole('heading', { name: contactName }).click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton', { id: 'inputGivenAmount' }).fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// Refresh home view and check gift
await page.goto('./');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
// Switch to user 00
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
await page.getByRole('link', { name: 'Switch Identifier' }).click();
await page.getByRole('link', { name: 'Add Another Identity…' }).click();
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Go to home view and look for gift
await page.goto('./');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').click();
// Confirm gift as user 00
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await expect(page.getByText('Confirmation submitted.')).toBeVisible();
// Refresh claim page, Confirm button should be hidden
await page.reload();
await expect(page.getByRole('button', { name: 'Confirm' })).toBeHidden();
});

View File

@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record an offer', async ({ page }) => {
// Generate a random string of 6 characters, skipping the "0." at the beginning
const randomString = Math.random().toString(36).substring(2, 8);
// Standard title prefix
const finalTitle = `Offering of ${randomString}`;
const randomNonZeroNumber = Math.floor(Math.random() * 998) + 1;
// Create new ID for default user
await importUser(page);
// Select a project
await page.goto('./discover');
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
// Record an offer
await page.getByTestId('offerButton').click();
await page.getByTestId('inputDescription').fill(finalTitle);
await page.getByTestId('inputOfferAmount').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That offer was recorded.')).toBeVisible();
// go to the offer and check the values
await page.goto('./projects');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
const serverPagePromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'View on the Public Server' }).click();
const serverPage = await serverPagePromise;
await serverPage.getByText(finalTitle);
await serverPage.getByText('did:none:HIDDEN');
// Now update that offer
// find the edit page and check the old values again
await page.goto('./projects');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click();
await page.getByTestId('editClaimButton').click();
await page.locator('heading', { hasText: 'What was offered' }).isVisible();
const itemDesc = await page.getByTestId('itemDescription');
await expect(itemDesc).toHaveValue(finalTitle);
const amount = await page.getByTestId('inputOfferAmount');
await expect(amount).toHaveValue(randomNonZeroNumber.toString());
// update the values
await itemDesc.fill('Updated ' + finalTitle);
await amount.fill(String(randomNonZeroNumber + 1));
await page.getByRole('button', { name: 'Sign & Send' }).click();
// go to the offer claim again and check the updated values
await page.goto('./projects');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click();
const newItemDesc = await page.getByTestId('description');
await expect(newItemDesc).toHaveText(finalTitle);
// go to edit page
await page.getByTestId('editClaimButton').click();
const newAmount = await page.getByTestId('inputOfferAmount');
await expect(newAmount).toHaveValue(randomNonZeroNumber.toString());
});

View File

@@ -0,0 +1,34 @@
import { expect } from '@playwright/test';
export async function importUser(page, id) {
let seedPhrase, userName, did;
// Set seed phrase and DID based on user ID
switch(id) {
case '01':
seedPhrase = 'island fever beef wine urban aim vacant quit afford total poem flame service calm better adult neither color gaze forum month sister imitate excite';
userName = 'User One';
did = 'did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39';
break;
default: // to user 00
seedPhrase = 'rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage';
userName = 'User Zero';
did = 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F';
}
// Import ID
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill(seedPhrase);
await page.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sectionUsageLimits')).toContainText('You have done');
await expect(page.locator('#sectionUsageLimits')).toContainText('You have uploaded');
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').fill(userName);
await page.getByRole('button', { name: 'Save Changes' }).click();
// Check DID
await expect(page.getByRole('code')).toContainText(did);
}

View File

@@ -26,8 +26,8 @@
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"src/**/*.vue", "src/**/*.vue",
"tests/**/*.ts", "test-playwright/**/*.ts",
"tests/**/*.tsx" "test-playwright/**/*.tsx"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"

View File

@@ -16,6 +16,8 @@ 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, 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 // 192x192 and 512x512 are important for Chrome to show that it's installable