Compare commits

..

144 Commits

Author SHA1 Message Date
2feea0d645 remove console.log 2023-05-19 08:11:34 -06:00
8f3a11bb98 in stats-world, fix the location computations to be based on Give attributes 2023-05-18 20:34:57 -06:00
beb7821f58 in stats-world, refactor claim-loading code to separate file (no logic changes) 2023-05-18 18:54:11 -06:00
712b25bc71 add error message to stats page 2023-05-18 18:23:17 -06:00
f039f98b61 on stats-world: adjust some lighting and position, and add an attempt at a screen-capture 2023-05-18 18:04:10 -06:00
f7a149444a tweak timing & spacing of stats-world, and log relevant tasks 2023-05-17 21:13:01 -06:00
58e962a3bd load stats-world bushes and make 'em grow! 2023-05-17 20:46:40 -06:00
7160aa3cc5 remove test cube from stats-world 2023-05-17 18:22:46 -06:00
786f0bd94a make the lights wait to turn on in stats-world 2023-05-17 18:20:55 -06:00
b5db2b4140 refactor variable names (no logic changes) 2023-05-17 17:42:26 -06:00
faa7959929 make all lights move in stats-world 2023-05-17 17:40:10 -06:00
3dd1b6f6f0 add some other screen actions (kinda ugly); add load of claims 2023-05-17 08:46:10 -06:00
9b1c51ba15 adjust stats-world perspective & size & colors 2023-05-16 18:56:50 -06:00
8c6c32ed20 finish removal from last commit 2023-05-16 16:40:55 -06:00
0eaf72b83b remove yet more randomness that has no effect in stats-world 2023-05-16 16:30:33 -06:00
41d3ad56f5 remove some random numbers that don't seem to make a difference in stats-world 2023-05-16 16:26:27 -06:00
0227d32f15 add a simple box in stats-world 2023-05-16 16:25:32 -06:00
b5ab485354 enable zooming on the stats-world 2023-05-16 16:17:34 -06:00
02ae78de7b totally remove code to make terrain bob up and down 2023-05-16 07:50:26 -06:00
64f3dbd138 play with sizing, and stop bobbing and rotation 2023-05-16 07:49:44 -06:00
f603882d42 modify some things to remove warnings on server and in browser console 2023-05-16 06:39:36 -06:00
a9844e6e78 add beginning of visualization for statistics, unmodified from blog code 2023-05-16 06:22:19 -06:00
fb7d51ac4c Merge pull request 'add help blurb for when we don't see info about someone that we should see' (#16) from help-visibility into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#16
2023-05-11 21:21:44 -04:00
85031f84c0 add help blurb for when we don't see info about someone that we should see 2023-05-07 19:52:40 -06:00
Matthew Aaron Raymer
7208a0fad1 Title: Updates to packages, new decorators, and implement slider
Description:  It has been a while since packages have been updated.  I've switched to the vue-facing-decorators since the old decorators are deprecated.  Also, using a model for the checkbox value and a change handler on the surrounding label instead of the checkbox itself.
2023-04-02 18:29:25 +08:00
Jose Olarte III
48ac2685b7 New toggle-style contact amounts control 2023-04-02 10:57:21 +08:00
Jose Olarte III
504da70fec Alerts position fixed to viewport 2023-04-02 10:39:35 +08:00
Jose Olarte III
67a1a07cab Increased nav z-index to clear other abs/fixed position elements 2023-04-02 10:35:05 +08:00
Jose Olarte III
1974570c01 Added truncation to ID displays 2023-03-30 17:10:13 +08:00
3b35fe7ff3 update tasks 2023-03-29 16:28:44 -06:00
Matthew Aaron Raymer
59e1311d23 Minor linting issue 2023-03-29 15:44:31 +08:00
1d47a90836 Merge pull request 'add separate screen for amount confirmations' (#15) from separate-dbs into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#15
2023-03-28 03:50:57 -04:00
76e2249b5e Merge branch 'master' into separate-dbs 2023-03-28 03:50:39 -04:00
00182443fd adjust to change of confirmed -> amountConfirmed 2023-03-27 20:37:22 -06:00
fed1ec6397 update tasks for latest give/confirm work 2023-03-26 20:33:36 -06:00
a20e63a57e instructions for testing, including multiple identifiers 2023-03-26 19:07:01 -06:00
a3b577e2c2 allow to confirm an amount received 2023-03-26 09:04:00 -06:00
1279ff050c allow to switch between accounts 2023-03-26 08:05:32 -06:00
6c05d3105f parameterize main identifier (to prepare the way for multiple) 2023-03-25 21:43:51 -06:00
2e530518b1 fix latest transaction description tooltip 2023-03-25 20:00:58 -06:00
eadcc22e9a fix sort order of items on contact given-amounts page 2023-03-25 19:53:18 -06:00
25b9dce669 fix the on-screen total update for unconfirmed amount 2023-03-25 19:47:36 -06:00
f281e41181 add details on contact-specific page 2023-03-25 19:03:25 -06:00
9317b59231 feat: add a page for details about contact transactions (which I'll fill in soon) 2023-03-24 22:04:53 -06:00
c6bb7b9d42 fix unconfirmed give display, and add alert on success 2023-03-24 20:24:56 -06:00
27a5a3a8dd Merge pull request 'add more contact management, including registration & visibility' (#14) from separate-dbs into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#14
2023-03-24 01:30:31 -04:00
3177d0f4b3 feat: allow selection of total vs confirmed vs unconfirmed give amounts 2023-03-22 21:56:02 -06:00
cdef139468 fix: show correct description 2023-03-22 21:11:16 -06:00
a7363eadcf feat: add tooltip for latest give description 2023-03-22 21:09:15 -06:00
f7e3a036e0 Merge branch 'master' into separate-dbs 2023-03-22 18:05:30 -06:00
e17140206c feat: add description to confirmation 2023-03-22 17:55:39 -06:00
7a7c5b6ba1 add registration check and the ability to register someone 2023-03-21 20:45:53 -06:00
55c0eb6114 add functions for visibility to contacts 2023-03-21 18:49:40 -06:00
0ea123e028 fix highlights on bottom row 2023-03-20 19:46:17 -06:00
fdac4f2665 allow deletion of a contact 2023-03-20 19:29:58 -06:00
5c75ad80af add correct encodings for public keys, plus some instructions for entering a contact 2023-03-20 18:58:55 -06:00
d293d0c3e2 show/hide given totals based on setting rather than URL parameter 2023-03-20 09:19:40 -06:00
c47d6d8ae4 Merge pull request 'separate account from other data for backup/restore' (#13) from separate-dbs into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#13
2023-03-20 01:45:22 -04:00
07bba55a30 add help page; add tasks for rest of "contact giving" functions 2023-03-19 19:47:37 -06:00
4cec3859ea refactor: misc tweaks for types, lint, etc 2023-03-19 18:29:02 -06:00
c7fa6823bc add DB export 2023-03-19 18:23:15 -06:00
34a50d75b3 remove the handle for test-user registration, and add easier instructions 2023-03-19 17:33:18 -06:00
45b54db01e separate account from other data for backup/restore 2023-03-19 16:25:19 -06:00
fb44c8aa48 Merge pull request 'Add settings table.' (#12) from db-set-and-bak into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#12
2023-03-19 13:30:27 -04:00
ee32c1aef4 Merge branch 'master' into db-set-and-bak 2023-03-19 11:30:00 -06:00
ae96d88680 Merge pull request 'add contacts DB & page' (#10) from add-contacts into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#10
2023-03-19 13:10:47 -04:00
75eb712c62 Merge pull request 'show totals of GiveAction entries for contacts' (#11) from contact-tallies into add-contacts
Reviewed-on: trent_larson/kick-starter-for-time-pwa#11
2023-03-19 13:09:54 -04:00
b3cdcb010a change dateCreated to a string (from a Date object, which persists as a Date object) 2023-03-18 20:46:59 -06:00
59d621efc1 add toggle for displaying give amounts for each contact 2023-03-18 20:37:50 -06:00
afc175e3e7 add settings table to store names (and other things soon) 2023-03-18 19:56:57 -06:00
315cdc0cf1 remove unused lastView and localStorage account names 2023-03-18 18:06:58 -06:00
5f3861049e remove separate storage reference for account check 2023-03-18 17:42:27 -06:00
cfeabf05a4 refactor DB organization (prepping for more tables) 2023-03-18 17:04:14 -06:00
f6a7677bdc feat: add a description for time gifts (and refactor errors) 2023-03-15 21:04:10 -06:00
9cb10b8561 ui: change item on bottom row to 'contacts' 2023-03-15 18:49:05 -06:00
d6a5bd02f3 fix: type-checking 2023-03-14 20:31:26 -06:00
d5abfb0265 feat: load totals immediately, and prompt to verify giving amount 2023-03-14 18:42:30 -06:00
392728fd4a feat: allow entry and send of a Give to/from addresses in contact list 2023-03-14 17:03:32 -06:00
740f2f0932 Merge branch 'master' into add-contacts 2023-03-14 03:41:57 -04:00
7214882523 feat: show hours given to and received from contacts 2023-03-13 20:36:33 -06:00
53204179a2 Merge pull request 'updating tasks for the end of this stage' (#9) from trentlarson-patch-1 into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#9
2023-03-12 23:15:26 -04:00
4fdfe2f824 feat: add contacts DB & page 2023-03-12 18:30:18 -06:00
682942268d updating tasks 2023-03-05 21:19:07 -05:00
701f71e942 Merge pull request 'refactor: migrate to handleId (since fullIri will soon be deprecated)' (#8) from handleId-migrate into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#8
2023-03-01 22:36:28 -05:00
8b0b65c55b refactor: migrate to handleId (since fullIri will soon be deprecated) 2023-02-28 09:12:00 -07:00
Jose Olarte III
1378106be7 Fixes to alert visibility and icon 2023-02-27 20:16:24 +08:00
Matthew Aaron Raymer
2d78a46ef2 Added alert box to save project but need a way to trigger an error? 2023-02-16 18:07:19 +08:00
Matthew Aaron Raymer
a2d1569d93 Added Save progress and a text counter on textbox 2023-02-16 17:30:09 +08:00
Jose Olarte III
da6833a0eb Fixed search bar 2023-02-13 18:44:50 +08:00
f3f55e1636 Update 'project.yaml' 2023-02-13 02:12:45 -05:00
6c38e69f9e docs: remove completed tasks regarding IDs 2023-02-07 20:50:56 -07:00
f4dcfb8dad linting update (no functional changes) 2023-02-07 20:30:17 -07:00
Matthew Aaron Raymer
1f114bfc52 Stub for message dialog 2023-02-06 18:51:50 +08:00
1ed22c9848 Update 'project.yaml' 2023-02-03 00:48:12 -05:00
Matthew Aaron Raymer
99c38079b3 More fixes due to typing in catch 2023-02-02 17:51:03 +08:00
Matthew Aaron Raymer
54d556ac4b Fixes to get the linter peachy 2023-02-01 16:55:40 +08:00
c8feb0c35b Update 'project.yaml' 2023-01-31 23:19:16 -05:00
2c74f358c7 Update 'project.yaml' 2023-01-31 23:17:29 -05:00
3f1a0185a4 Update 'project.yaml' 2023-01-31 23:17:02 -05:00
f886be7844 Merge pull request 'update to new endpoints with the editable plan "handle"' (#7) from pull-only-plans into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#7
2023-01-31 22:46:39 -05:00
83a9dc332c chore: understandable debugging 2023-01-25 16:32:19 -07:00
5638798ca8 chore: update to match latest API for retrieving plans 2023-01-25 09:10:16 -07:00
1bedbe17c0 feat: adjust to the new endpoints for editable plan records 2023-01-23 22:14:46 -07:00
d6253ca737 Merge pull request 'Pull only PlanAction records for this issuer' (#6) from pull-only-plans into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#6
2023-01-20 00:15:27 -05:00
4664d697fd feat: restrict to only pull PlanActions for issuer 2023-01-19 21:30:00 -07:00
fa01125c84 docs: add testing info, tweak tasks 2023-01-19 21:29:45 -07:00
Matthew Aaron Raymer
68eb04c137 Added updating account name 2023-01-17 16:53:44 +08:00
Matthew Aaron Raymer
51600b65d7 Add edit project flow in anticipation of API method. Ugly Edit button needs to be replaced. 2023-01-17 16:35:38 +08:00
Matthew Aaron Raymer
997093c695 Add flow from project list to view 2023-01-17 16:13:58 +08:00
Matthew Aaron Raymer
cc57d59717 Basic Project list added 2023-01-16 18:35:06 +08:00
Matthew Aaron Raymer
01eecfd8d9 Time since 2023-01-16 17:24:48 +08:00
Matthew Aaron Raymer
64bd9a103d Name and description added to Project View 2023-01-16 16:37:57 +08:00
Matthew Aaron Raymer
c84e597047 Added the flow from New Project to View Project 2023-01-16 14:12:52 +08:00
Matthew Aaron Raymer
c61be23fee Merged in with small corrections 2023-01-10 16:30:03 +08:00
Matthew Aaron Raymer
1c0881fe14 Subject: workflow for saving of project
* Changed from passing parameters since this isn't supported anymore by Vue
* Added new AppStore values for a projectId which are accessible in the ProjectView View
2023-01-10 16:22:07 +08:00
2a7c858662 refactor: use our own SimpleSigner since library version is deprecated 2023-01-09 19:08:41 -07:00
8540a2de77 Merge pull request 'Include a way to register other test users' (#4) from test-registration into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#4
2023-01-09 16:25:34 -05:00
Matthew Aaron Raymer
41d8df2238 Added Project New Button 2023-01-09 16:05:45 +08:00
Matthew Aaron Raymer
71546ea605 Added new projects view 2023-01-09 15:57:58 +08:00
Matthew Aaron Raymer
487997b87c Adding a less complex router 2023-01-09 15:10:05 +08:00
693df1bda1 feat: switch to the SimpleSigner code for working signatures 2023-01-07 23:37:38 -07:00
d3e590822e docs: fix spelling error 2023-01-07 23:34:42 -07:00
4e1263d041 test: add function for test User #0 to register a new user 2023-01-07 23:15:39 -07:00
Matthew Aaron Raymer
ba85663048 Linted 2023-01-07 13:19:04 +08:00
9d566fa977 fix: use SimpleSigner directly from did-jwt 2023-01-06 19:25:53 -07:00
Matthew Aaron Raymer
3440f28121 Added some ideas for doing actions from the console ... Work in Progress 2023-01-06 17:56:37 +08:00
Matthew Aaron Raymer
150b35c4c7 Update with our own SimpleSigner 2023-01-06 17:30:41 +08:00
Matthew Aaron Raymer
39c931cde9 A touch of documenting process 2023-01-04 17:15:11 +08:00
Matthew Aaron Raymer
f858a4d29a Linted 2023-01-04 17:09:11 +08:00
Matthew Aaron Raymer
f021fcdb1c New Project 2023-01-04 17:05:08 +08:00
Matthew Aaron Raymer
6325bcbe35 Work in progress 2023-01-03 17:28:23 +08:00
Matthew Aaron Raymer
0ee35e4946 Added variation of accessToken method carried over from endorser-mobile 2023-01-03 12:35:41 +08:00
Matthew Aaron Raymer
f5a2d71ed3 Added some elementary Vue constructs 2023-01-02 17:55:43 +08:00
c43fdcbb7f Merge pull request 'chore: add setup file for asdf-vm.com' (#3) from setup-asdf into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#3
2023-01-02 01:53:16 -05:00
3034d66a5d docs: insert coding project task list into this repo 2023-01-01 12:48:42 -07:00
ba14fd4242 chore: add setup file for asdf-vm.com 2023-01-01 12:43:34 -07:00
236c1c2836 Merge pull request 'remove code for lowercase checks (that were for old uPort)' (#1) from remove-unused-lowercases into master
Reviewed-on: trent_larson/kick-starter-for-time-pwa#1
2022-12-31 21:03:38 -05:00
d2cea34242 fix: remove code for lowercase checks (that were for old uPort) 2022-12-29 16:13:51 -07:00
Matthew Aaron Raymer
3687e5e282 Subject line: Added v-model for input box on seed input.
Also completed recovery from seed.  Should be ready to move to Make commitment
2022-12-22 15:24:17 +08:00
Matthew Aaron Raymer
65381e103c Flesh out a bit more of the import and also refactor the router 2022-12-21 18:02:19 +08:00
Matthew Aaron Raymer
07f763e167 Fixing import function 2022-12-21 16:08:40 +08:00
Matthew Aaron Raymer
c9919987ca Added clipboard copy actions.
We need to add designs for feedback on the copy action
2022-12-19 17:51:14 +08:00
Matthew Aaron Raymer
9f94aad88a Subject line: Adding routing for post-registration.
Had to add a bogus home page (left over boilerplate home) to fill the role of an initial load state.
The app will look at the app Pinia state and if it is 'registered' and then send to the discovery page.
2022-12-19 15:58:17 +08:00
Matthew Aaron Raymer
b4557c3596 Adding markers to keep track of registration state 2022-12-19 14:19:33 +08:00
48 changed files with 6674 additions and 1921 deletions

View File

@@ -20,10 +20,72 @@ npm run build
npm run lint
```
### Clear data & restart
Clear cache for localhost, then go to http://localhost:8080/start
(because it'll generate a new one automatically if you start on the `/account` page).
### Test key contents
See [this page](openssl_signing_console.rst)
### Register new user on test server
New users require registration. This can be done with a claim payload like this
by an existing user:
```
const vcClaim = {
"@context": "https://schema.org",
"@type": "RegisterAction",
agent: { identifier: identity0.did },
object: SERVICE_ID,
participant: { identifier: newIdentity.did },
};
```
On the test server, User #0 has rights to register others, so you can start
playing one of two ways:
- Import the keys for the test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` by importing this seed phrase:
`seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control`
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
- Alternatively, register someone else under User #0 automatically:
* In the `src/views/AccountViewView.vue` file, uncomment the lines referring to "testServerRegisterUser".
* Visit the `/account` page.
### Create multiple identifiers
Go to /import-account and import a new one. Then switch identifiers on the
bottom of the Your Identity page.
### Create keys with alternate tools
See [this page](openssl_signing_console.rst)
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## Dependencies
See https://tea.xyz
| Project | Version |
| ---------- | --------- |
| nodejs.org | ^16.0.0 |
| npmjs.com | ^8.0.0 |
## Other
### Reference Material
```
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
// Import an existing ID
export const importAndStoreIdentifier = async (mnemonic: string, mnemonicPassword: string, toLowercase: boolean, previousIdentifiers: Array<IIdentifier>) => {
@@ -122,4 +184,10 @@ export const createAndStoreIdentifier = async (mnemonicPassword) => {
return importAndStoreIdentifier(mnemonic, mnemonicPassword, false, [])
}
```
```
## Kudos
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
* [Many libraries]() such as Veramo.io, Vuejs.org, threejs
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)

View File

@@ -0,0 +1,45 @@
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
Generate an ECDSA key pair using the secp256k1 curve:
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
First, create a header object as a JSON object containing the alg (algorithm) and typ (type) fields. For example:
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
Next, create a payload object as a JSON object containing the claims you want to include in the JWT. For example schema.org :
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
Encode the header and payload objects as base64Url strings. You can use the jq command line utility to do this:
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n')
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n')
Concatenate the encoded header, payload, and a secret to create the signing input:
signing_input="$header_b64.$payload_b64"
Create the signature by signing the signing input with a ES256K algorithm and your secret. You can use the openssl command line utility to do this:
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem)
Finally, encode the signature as a base64Url string and concatenate it with the signing input to create the JWT:
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
jwt="$signing_input.$signature_b64"
This JWT can then be passed in the Authorization header of a HTTP request as a bearer token, for example:
Authorization: Bearer $jwt
To verify the JWT, you can use the openssl utility with the public key:
openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
This will verify the signature and output Verified OK if the signature is valid. If the signature is not valid, it will output an error.

4120
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,46 +9,56 @@
},
"dependencies": {
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/vue-fontawesome": "^3.0.2",
"@pvermeer/dexie-encrypted-addon": "^2.0.2",
"@veramo/core": "^4.1.1",
"@veramo/credential-w3c": "^4.1.1",
"@veramo/data-store": "^4.1.1",
"@veramo/did-manager": "^4.1.1",
"@veramo/did-provider-ethr": "^4.1.2",
"@veramo/did-resolver": "^4.1.1",
"@veramo/key-manager": "^4.1.1",
"@vueuse/core": "^9.6.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@pvermeer/dexie-encrypted-addon": "^2.0.5",
"@tweenjs/tween.js": "^20.0.3",
"@veramo/core": "^5.1.2",
"@veramo/credential-w3c": "^5.1.4",
"@veramo/data-store": "^5.1.2",
"@veramo/did-manager": "^5.1.2",
"@veramo/did-provider-ethr": "^5.1.2",
"@veramo/did-resolver": "^5.1.2",
"@veramo/key-manager": "^5.1.2",
"@vueuse/core": "^9.13.0",
"@zxing/text-encoding": "^0.9.0",
"axios": "^1.3.4",
"buffer": "^6.0.3",
"class-transformer": "^0.5.1",
"core-js": "^3.26.1",
"dexie": "^3.2.2",
"ethereum-cryptography": "^1.1.2",
"core-js": "^3.29.1",
"dexie": "^3.2.3",
"dexie-export-import": "^4.0.7",
"did-jwt": "^6.11.5",
"ethereum-cryptography": "^1.2.0",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.0.0",
"js-generate-password": "^0.1.7",
"localstorage-slim": "^2.3.0",
"luxon": "^3.1.1",
"localstorage-slim": "^2.4.0",
"luxon": "^3.3.0",
"merkletreejs": "^0.3.9",
"papaparse": "^5.3.2",
"moment": "^2.29.4",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.0.1",
"pinia-plugin-persistedstate": "^3.1.0",
"ramda": "^0.28.0",
"readable-stream": "^4.2.0",
"readable-stream": "^4.3.0",
"reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2",
"vue": "^3.2.45",
"three": "^0.152.2",
"vue": "^3.2.47",
"vue-axios": "^3.5.2",
"vue-class-component": "^8.0.0-0",
"vue-facing-decorator": "^2.1.19",
"vue-property-decorator": "^9.1.2",
"vue-router": "^4.1.6",
"web-did-resolver": "^2.0.21"
"web-did-resolver": "^2.0.22"
},
"devDependencies": {
"@types/ramda": "^0.28.20",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
"@types/ramda": "^0.28.23",
"@types/three": "^0.152.0",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-pwa": "~5.0.8",
@@ -57,14 +67,14 @@
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-typescript": "^11.0.2",
"autoprefixer": "^10.4.13",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.8.0",
"postcss": "^8.4.19",
"prettier": "^2.8.0",
"tailwindcss": "^3.2.4",
"typescript": "~4.9.3"
"eslint-plugin-vue": "^9.10.0",
"postcss": "^8.4.21",
"prettier": "^2.8.7",
"tailwindcss": "^3.3.1",
"typescript": "~5.0.3"
}
}

58
project.yaml Normal file
View File

@@ -0,0 +1,58 @@
- top screens from img/screens.pdf milestone:2 :
- view all :
- add infinite scroll assignee:matthew
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- replace user-affecting console.logs with error messages (eg. catches)
- stats v1 :
- 01 show numeric stats
- 01 link to world for specific stats
- contacts v1 :
- .5 switch to prod server
- .5 Add page to show seed.
- 01 Provide a way to import the non-sensitive data.
- 01 Provide way to share your contact info.
- .2 move all "identity" references to temporary account access
- .5 make deploy for give-only features
- contacts v+ :
- .5 make advanced "show/hide amounts" button into a nice UI toggle
- .2 show error to user when adding a duplicate contact
- parse input more robustly (with CSV lib and not commas)
- refactor UI :
- .5 Alerts show at the top and can be missed, eg. account data download
- 01 Change alerts into a component (to cut down duplicate code)
- 01 Code for "nav" tabs across the bottom is duplicated on each page.
- .2 Add "copied" feedback when they click "copy" on /account
- .5 Fix how icons show on top of bottom bar on ContactAmounts page
- commit screen
- discover screen
- backup all data
- .5 customize favicon
- .5 make advanced features harder to access
- Release Minimum Viable Product :
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
- Stats :
- 01 point out user's location on the world
- 01 present a credential selected from the stats
- 04 show gives spreading to other places
- Connect with phone contacts
- Multiple identities
- Peer DID
- DIDComm
- v video for multiple identities https://youtu.be/p8L87AeD76w
- v video for adding time to contacts https://youtu.be/7Yylczevp10

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

View File

@@ -0,0 +1,11 @@
Model Information:
* title: Lupine Plant
* source: https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439
* author: rufusrockwell (https://sketchfab.com/rufusrockwell)
Model License:
* license type: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)
* requirements: Author must be credited. Commercial use is allowed.
If you use this 3D model in your project be sure to copy paste this credit wherever you share it:
This work is based on "Lupine Plant" (https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439) by rufusrockwell (https://sketchfab.com/rufusrockwell) licensed under CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)

Binary file not shown.

View File

@@ -0,0 +1,229 @@
{
"accessors": [
{
"bufferView": 2,
"componentType": 5126,
"count": 2759,
"max": [
41.3074951171875,
40.37548828125,
87.85917663574219
],
"min": [
-35.245540618896484,
-36.895416259765625,
-0.9094290137290955
],
"type": "VEC3"
},
{
"bufferView": 2,
"byteOffset": 33108,
"componentType": 5126,
"count": 2759,
"max": [
0.9999382495880127,
0.9986748695373535,
0.9985831379890442
],
"min": [
-0.9998949766159058,
-0.9975876212120056,
-0.411094069480896
],
"type": "VEC3"
},
{
"bufferView": 3,
"componentType": 5126,
"count": 2759,
"max": [
0.9987699389457703,
0.9998998045921326,
0.9577858448028564,
1.0
],
"min": [
-0.9987726807594299,
-0.9990445971488953,
-0.999801516532898,
1.0
],
"type": "VEC4"
},
{
"bufferView": 1,
"componentType": 5126,
"count": 2759,
"max": [
1.0061479806900024,
0.9993550181388855
],
"min": [
0.00279300007969141,
0.0011620000004768372
],
"type": "VEC2"
},
{
"bufferView": 0,
"componentType": 5125,
"count": 6378,
"type": "SCALAR"
}
],
"asset": {
"extras": {
"author": "rufusrockwell (https://sketchfab.com/rufusrockwell)",
"license": "CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)",
"source": "https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439",
"title": "Lupine Plant"
},
"generator": "Sketchfab-12.68.0",
"version": "2.0"
},
"bufferViews": [
{
"buffer": 0,
"byteLength": 25512,
"name": "floatBufferViews",
"target": 34963
},
{
"buffer": 0,
"byteLength": 22072,
"byteOffset": 25512,
"byteStride": 8,
"name": "floatBufferViews",
"target": 34962
},
{
"buffer": 0,
"byteLength": 66216,
"byteOffset": 47584,
"byteStride": 12,
"name": "floatBufferViews",
"target": 34962
},
{
"buffer": 0,
"byteLength": 44144,
"byteOffset": 113800,
"byteStride": 16,
"name": "floatBufferViews",
"target": 34962
}
],
"buffers": [
{
"byteLength": 157944,
"uri": "scene.bin"
}
],
"images": [
{
"uri": "textures/lambert2SG_baseColor.png"
},
{
"uri": "textures/lambert2SG_normal.png"
}
],
"materials": [
{
"alphaCutoff": 0.2,
"alphaMode": "MASK",
"doubleSided": true,
"name": "lambert2SG",
"normalTexture": {
"index": 1
},
"pbrMetallicRoughness": {
"baseColorTexture": {
"index": 0
},
"metallicFactor": 0.0
}
}
],
"meshes": [
{
"name": "Object_0",
"primitives": [
{
"attributes": {
"NORMAL": 1,
"POSITION": 0,
"TANGENT": 2,
"TEXCOORD_0": 3
},
"indices": 4,
"material": 0,
"mode": 4
}
]
}
],
"nodes": [
{
"children": [
1
],
"matrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
2.220446049250313e-16,
-1.0,
0.0,
0.0,
1.0,
2.220446049250313e-16,
0.0,
0.0,
0.0,
0.0,
1.0
],
"name": "Sketchfab_model"
},
{
"children": [
2
],
"name": "LupineSF.obj.cleaner.materialmerger.gles"
},
{
"mesh": 0,
"name": "Object_2"
}
],
"samplers": [
{
"magFilter": 9729,
"minFilter": 9987,
"wrapS": 10497,
"wrapT": 10497
}
],
"scene": 0,
"scenes": [
{
"name": "Sketchfab_Scene",
"nodes": [
0
]
}
],
"textures": [
{
"sampler": 0,
"source": 0
},
{
"sampler": 0,
"source": 1
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@@ -9,3 +9,10 @@
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
}
}
@layer components {
input:checked ~ .dot {
transform: translateX(100%);
background-color: #FFF !important;
}
}

View File

@@ -0,0 +1,104 @@
// from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80
import * as TWEEN from "@tweenjs/tween.js";
import * as THREE from "three";
import { createCamera } from "./components/camera.js";
import { createLights } from "./components/lights.js";
import { createScene } from "./components/scene.js";
import { loadLandmarks } from "./components/objects/landmarks.js";
import { createTerrain } from "./components/objects/terrain.js";
import { Loop } from "./systems/Loop.js";
import { Resizer } from "./systems/Resizer.js";
import { createControls } from "./systems/controls.js";
import { createRenderer } from "./systems/renderer.js";
const COLOR1 = "#dddddd";
const COLOR2 = "#0055aa";
class World {
constructor(container, vue) {
this.PLATFORM_BORDER = 5;
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10;
this.PLATFORM_SIZE = 100; // note that the loadLandmarks calculations may still assume 100
this.update = this.update.bind(this);
// Instances of camera, scene, and renderer
this.camera = createCamera();
this.scene = createScene(COLOR2);
this.renderer = createRenderer();
// necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.light = null;
this.lights = [];
this.bushes = [];
// Initialize Loop
this.loop = new Loop(this.camera, this.scene, this.renderer);
container.append(this.renderer.domElement);
// Orbit Controls
const controls = createControls(this.camera, this.renderer.domElement);
// Light Instance, with optional light helper
const { light } = createLights(COLOR1);
// Terrain Instance
const terrain = createTerrain({
color: COLOR1,
height: this.PLATFORM_SIZE + this.PLATFORM_BORDER * 2,
width:
this.PLATFORM_SIZE +
this.PLATFORM_BORDER * 2 +
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2,
});
this.loop.updatables.push(controls);
this.loop.updatables.push(light);
this.loop.updatables.push(terrain);
this.scene.add(light, terrain);
loadLandmarks(vue, this, this.scene, this.loop);
requestAnimationFrame(this.update);
// Responsive handler
const resizer = new Resizer(container, this.camera, this.renderer);
resizer.onResize = () => {
this.render();
};
}
update(time) {
TWEEN.update(time);
this.lights.forEach((light) => {
light.updateMatrixWorld();
light.target.updateMatrixWorld();
});
this.lights.forEach((bush) => {
bush.updateMatrixWorld();
});
requestAnimationFrame(this.update);
}
render() {
// draw a single frame
this.renderer.render(this.scene, this.camera);
}
// Animation handlers
start() {
this.loop.start();
}
stop() {
this.loop.stop();
}
}
export { World };

View File

@@ -0,0 +1,19 @@
import { PerspectiveCamera } from "three";
function createCamera() {
const camera = new PerspectiveCamera(
35, // fov = Field Of View
1, // aspect ratio (dummy value)
0.1, // near clipping plane
350 // far clipping plane
);
// move the camera back so we can view the scene
camera.position.set(0, 100, 200);
// eslint-disable-next-line @typescript-eslint/no-empty-function
camera.tick = () => {};
return camera;
}
export { createCamera };

View File

@@ -0,0 +1,14 @@
import { DirectionalLight, DirectionalLightHelper } from "three";
function createLights(color) {
const light = new DirectionalLight(color, 4);
const lightHelper = new DirectionalLightHelper(light, 0);
light.position.set(60, 100, 30);
// eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {};
return { light, lightHelper };
}
export { createLights };

View File

@@ -0,0 +1,225 @@
import axios from "axios";
import * as R from "ramda";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js";
import { AppString } from "@/constants/app";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
const ANIMATION_DURATION_SECS = 10;
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
export async function loadLandmarks(vue, world, scene, loop) {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
const token = await accessToken(identity);
const url =
endorserApiServer + "/api/v2/report/claims?claimType=GiveAction";
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const resp = await axios.get(url, { headers: headers });
if (resp.status === 200) {
const minDate = resp.data.data[resp.data.data.length - 1].issuedAt;
const maxDate = resp.data.data[0].issuedAt;
const minTimeMillis = new Date(minDate).getTime();
const fullTimeMillis = new Date(maxDate).getTime() - minTimeMillis;
// ratio of animation time to real time
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis;
// load plant model first because it takes a second
const loader = new GLTFLoader();
// choose the right plant
const modelLoc = "/models/lupine_plant/scene.gltf", // push with pokies
modScale = 0.1;
//const modelLoc = "/models/round_bush/scene.gltf", // green & pink
// modScale = 1;
//const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers
// modScale = 2;
//const modelLoc = "/models/a_bush/scene.gltf", // purple leaves
// modScale = 15;
// calculate positions for each claim, especially because some are random
const locations = resp.data.data.map((claim) =>
locForGive(claim, world.PLATFORM_SIZE, world.PLATFORM_EDGE_FOR_UNKNOWNS)
);
// eslint-disable-next-line @typescript-eslint/no-this-alias
loader.load(
modelLoc,
function (gltf) {
gltf.scene.scale.set(0, 0, 0);
for (let i = 0; i < resp.data.data.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts)
const claim = resp.data.data[i];
const newPlant = SkeletonUtils.clone(gltf.scene);
const loc = locations[i];
newPlant.position.set(loc.x, 0, loc.z);
world.scene.add(newPlant);
const timeDelayMillis =
fakeRealRatio *
(new Date(claim.issuedAt).getTime() - minTimeMillis);
new TWEEN.Tween(newPlant.scale)
.delay(timeDelayMillis)
.to({ x: modScale, y: modScale, z: modScale }, 5000)
.start();
world.bushes = [...world.bushes, newPlant];
}
},
undefined,
function (error) {
console.error(error);
}
);
// calculate when lights shine on appearing claim area
for (let i = 0; i < resp.data.data.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts)
const claim = resp.data.data[i];
const loc = locations[i];
const light = createLight();
light.position.set(loc.x, 20, loc.z);
light.target.position.set(loc.x, 0, loc.z);
loop.updatables.push(light);
scene.add(light);
scene.add(light.target);
// now figure out the timing and shine a light
const timeDelayMillis =
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis);
new TWEEN.Tween(light)
.delay(timeDelayMillis)
.to({ intensity: 100 }, 10)
.chain(
new TWEEN.Tween(light.position)
.to({ y: 5 }, 5000)
.onComplete(() => {
scene.remove(light);
light.dispose();
})
)
.start();
world.lights = [...world.lights, light];
}
} else {
console.log(
"Got bad server response status & data of",
resp.status,
resp.data
);
vue.setAlert(
"Error With Server",
"There was an error retrieving your claims from the server."
);
}
} catch (error) {
console.log("Got exception contacting server:", error);
vue.setAlert(
"Error With Server",
"There was a problem retrieving your claims from the server."
);
}
}
/**
*
* @param giveClaim
* @returns {x:float, z:float} where -50 <= x & z < 50
*/
function locForGive(giveClaim, platformWidth, borderWidth) {
let loc;
if (giveClaim?.claim?.recipient?.identifier) {
// this is directly to a person
loc = locForEthrDid(giveClaim.claim.recipient.identifier);
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
} else if (giveClaim?.object?.isPartOf?.identifier) {
// this is probably to a project
const objId = giveClaim.object.isPartOf.identifier;
if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) {
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length));
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
}
}
if (!loc) {
// it must be outside our known addresses so let's put it somewhere random
const leftSide = Math.random() < 0.5;
loc = {
x: leftSide
? -platformWidth / 2 - borderWidth / 2
: platformWidth / 2 + borderWidth / 2,
z: Math.random() * platformWidth - platformWidth / 2,
};
}
return loc;
}
/**
* Generate a deterministic x & z location based on the randomness in a ULID.
*
* We'll use the first 20 bits for the x coordinate and next 20 for the z.
* That's a bit over a trillion locations... should be enough for pretty
* unique locations for this fun kind of application within a network
* -- though they're not guaranteed unique without the full ID.
* (We're leaving 40 bits for other properties.)
*
* @param ulid
* @returns {x: float, z: float} where 0 <= x & z < 100
*/
function locForUlid(ulid) {
// The random parts of a ULID come after the first 10 characters.
const randomness = ulid.substring(10);
// That leaves 16 characters of randomness, or 80 bits.
// We're currently only using 32 possible x and z values
// because the display is pretty low-fidelity at this point.
// Similar code is below.
const x = (100 * BASE32.indexOf(randomness.substring(0, 1))) / 32;
const z = (100 * BASE32.indexOf(randomness.substring(4, 5))) / 32;
return { x, z };
}
/**
* See locForUlid
* @param did
* @returns {x: float, z: float} where 0 <= x & z < 100
*/
function locForEthrDid(did) {
// "did:ethr:0x..."
if (did.length < 51) {
return { x: 0, z: 0 };
} else {
const randomness = did.substring(11);
// We'll take the first 4 bits for 16 possible x & z values.
const xOff = parseInt(Number("0x" + randomness.substring(0, 1)), 10);
const x = (xOff * 100) / 16;
// ... and since we're reserving 20 bits total for x, start with character 5.
const zOff = parseInt(Number("0x" + randomness.substring(5, 6)), 10);
const z = (zOff * 100) / 16;
return { x, z };
}
}
function createLight() {
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0);
// eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {};
return light;
}

View File

@@ -0,0 +1,29 @@
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from "three";
export function createTerrain(props) {
const loader = new TextureLoader();
const height = loader.load("img/textures/forest-floor.png");
// w h
const geometry = new PlaneGeometry(props.width, props.height, 64, 64);
const material = new MeshLambertMaterial({
color: props.color,
flatShading: true,
map: height,
//displacementMap: height,
//displacementScale: 5,
});
const plane = new Mesh(geometry, material);
plane.position.set(0, 0, 0);
plane.rotation.x -= Math.PI * 0.5;
//Storing our original vertices position on a new attribute
plane.geometry.attributes.position.originalPosition =
plane.geometry.attributes.position.array;
// eslint-disable-next-line @typescript-eslint/no-empty-function
plane.tick = () => {};
return plane;
}

View File

@@ -0,0 +1,11 @@
import { Color, Scene } from "three";
function createScene(color) {
const scene = new Scene();
scene.background = new Color(color);
//scene.fog = new Fog(color, 60, 90);
return scene;
}
export { createScene };

View File

@@ -0,0 +1,33 @@
import { Clock } from "three";
const clock = new Clock();
class Loop {
constructor(camera, scene, renderer) {
this.camera = camera;
this.scene = scene;
this.renderer = renderer;
this.updatables = [];
}
start() {
this.renderer.setAnimationLoop(() => {
this.tick();
// render a frame
this.renderer.render(this.scene, this.camera);
});
}
stop() {
this.renderer.setAnimationLoop(null);
}
tick() {
const delta = clock.getDelta();
for (const object of this.updatables) {
object.tick(delta);
}
}
}
export { Loop };

View File

@@ -0,0 +1,33 @@
const setSize = (container, camera, renderer) => {
// These are great for full-screen, which adjusts to a window.
const height = window.innerHeight;
const width = window.innerWidth - 50;
// These are better for fitting in a container, which stays that size.
//const height = container.scrollHeight;
//const width = container.scrollWidth;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
};
class Resizer {
constructor(container, camera, renderer) {
// set initial size on load
setSize(container, camera, renderer);
window.addEventListener("resize", () => {
// set the size again if a resize occurs
setSize(container, camera, renderer);
// perform any custom actions
this.onResize();
});
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onResize() {}
}
export { Resizer };

View File

@@ -0,0 +1,38 @@
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { MathUtils } from "three";
function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas);
//enable controls?
controls.enabled = true;
controls.autoRotate = false;
//controls.autoRotateSpeed = 0.2;
// control limits
// It's recommended to set some control boundaries,
// to prevent the user from clipping with the objects.
// y axis
controls.minPolarAngle = MathUtils.degToRad(40); // default
controls.maxPolarAngle = MathUtils.degToRad(75);
// x axis
// controls.minAzimuthAngle = ...
// controls.maxAzimuthAngle = ...
//smooth camera:
// remember to add to loop updatables to work
controls.enableDamping = true;
//controls.enableZoom = false;
controls.maxDistance = 250;
//controls.enablePan = false;
controls.tick = () => controls.update();
return controls;
}
export { createControls };

View File

@@ -0,0 +1,13 @@
import { WebGLRenderer } from "three";
function createRenderer() {
const renderer = new WebGLRenderer({ antialias: true });
// turn on the physically correct lighting model
// (The browser complains: "THREE.WebGLRenderer: .physicallyCorrectLights has been removed. Set enderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.)
renderer.physicallyCorrectLights = true;
return renderer;
}
export { createRenderer };

View File

@@ -2,6 +2,8 @@
* Generic strings that could be used throughout the app.
*/
export enum AppString {
APP_NAME = "Kickstart for time",
APP_NAME = "KickStart with Time",
VERSION = "0.1",
DEFAULT_ENDORSER_API_SERVER = "https://test.endorser.ch:8000",
//DEFAULT_ENDORSER_API_SERVER = "http://localhost:3000",
}

View File

@@ -1,6 +1,22 @@
import BaseDexie from "dexie";
import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import { accountsSchema, AccountsTable } from "./tables/accounts";
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactsSchema } from "./tables/contacts";
import {
MASTER_SETTINGS_KEY,
Settings,
SettingsSchema,
} from "./tables/settings";
// a separate DB because the seed is super-sensitive data
type SensitiveTables = {
accounts: Table<Account>;
};
type NonsensitiveTables = {
contacts: Table<Contact>;
settings: Table<Settings>;
};
/**
* In order to make the next line be acceptable, the program needs to have its linter suppress a rule:
@@ -10,10 +26,14 @@ import { accountsSchema, AccountsTable } from "./tables/accounts";
*
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
*/
type DexieTables = AccountsTable;
export type Dexie<T extends unknown = DexieTables> = BaseDexie & T;
export const db = new BaseDexie("kickStarter") as Dexie;
const schema = Object.assign({}, accountsSchema);
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export const accountsDB = new BaseDexie("KickStartAccounts") as SensitiveDexie;
const SensitiveSchemas = Object.assign({}, AccountsSchema);
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
export const db = new BaseDexie("KickStart") as NonsensitiveDexie;
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
/**
* Needed to enable a special webpack setting to allow *await* below:
@@ -27,6 +47,15 @@ const secret =
if (localStorage.getItem("secret") == null) {
localStorage.setItem("secret", secret);
}
console.log(secret);
encrypted(db, { secretKey: secret });
db.version(1).stores(schema);
//console.log("IndexedDB Encryption Secret:", secret);
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(SensitiveSchemas);
db.version(1).stores(NonsensitiveSchemas);
// initialize, a la https://dexie.org/docs/Tutorial/Design#the-populate-event
db.on("populate", function () {
// ensure there's an initial entry for settings
db.settings.add({ id: MASTER_SETTINGS_KEY });
});

View File

@@ -1,18 +1,16 @@
import { Table } from "dexie";
export type Account = {
id?: number;
publicKey: string;
mnemonic: string;
id?: number; // auto-generated by Dexie
dateCreated: string;
derivationPath: string;
did: string;
identity: string;
dateCreated: number;
};
export type AccountsTable = {
accounts: Table<Account>;
publicKeyHex: string;
mnemonic: string;
};
// mark encrypted field by starting with a $ character
export const accountsSchema = {
accounts: "++id, publicKey, $mnemonic, $identity, dateCreated",
// see https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon
export const AccountsSchema = {
accounts:
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
};

11
src/db/tables/contacts.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Contact {
did: string;
name?: string;
publicKeyBase64?: string;
seesMe?: boolean;
registered?: boolean;
}
export const ContactsSchema = {
contacts: "++did, name, publicKeyBase64, registered, seesMe",
};

14
src/db/tables/settings.ts Normal file
View File

@@ -0,0 +1,14 @@
// a singleton
export type Settings = {
id: number; // there's only one entry: MASTER_SETTINGS_KEY
activeDid?: string;
firstName?: string;
lastName?: string;
showContactGivesInline?: boolean;
};
export const SettingsSchema = {
settings: "id",
};
export const MASTER_SETTINGS_KEY = 1;

View File

@@ -4,6 +4,8 @@ import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
/**
*
@@ -37,6 +39,12 @@ export const newIdentifier = (
};
};
/**
*
*
* @param {string} mnemonic
* @return {*} {[string, string, string, string]}
*/
export const deriveAddress = (
mnemonic: string
): [string, string, string, string] => {
@@ -57,9 +65,86 @@ export const deriveAddress = (
*
* @return {*} {string}
*/
export const createIdentifier = (): string => {
export const generateSeed = (): string => {
const entropy: Uint8Array = getRandomBytesSync(32);
const mnemonic = entropyToMnemonic(entropy, wordlist);
return mnemonic;
};
/**
* Retreive an access token
*
* @param {IIdentifier} identifier
* @return {*}
*/
export const accessToken = async (identifier: IIdentifier) => {
const did: string = identifier.did;
const privateKeyHex: string = identifier.keys[0].privateKeyHex as string;
const signer = SimpleSigner(privateKeyHex);
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + 60; // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
const jwt: string = await didJwt.createJWT(tokenPayload, {
alg,
issuer: did,
signer,
});
return jwt;
};
export const sign = async (privateKeyHex: string) => {
const signer = SimpleSigner(privateKeyHex);
return signer;
};
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(process.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
export function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`
);
}
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");
}

View File

@@ -0,0 +1,40 @@
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
export const SERVICE_ID = "endorser.ch";
export interface AgreeVerifiableCredential {
"@context": string;
"@type": string;
// "any" because arbitrary objects can be subject of agreement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: Record<any, any>;
}
export interface GiveServerRecord {
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
handleId: string;
issuedAt: string;
recipientDid: string;
unit: string;
}
export interface GiveVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": string;
agent: { identifier: string };
description?: string;
identifier?: string;
object: { amountOfThisGood: number; unitCode: string };
recipient: { identifier: string };
}
export interface RegisterVerifiableCredential {
"@context": string;
"@type": string;
agent: { identifier: string };
object: string;
recipient: { identifier: string };
}

View File

@@ -1,55 +1,83 @@
import { VueDexiePlugin } from "@/plugins/dexieVuePlugin";
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
import axios from "axios";
import VueAxios from "vue-axios";
import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faCalendar,
faChevronLeft,
faHouseChimney,
faMagnifyingGlass,
faFolderOpen,
faHand,
faCircle,
faCircleCheck,
faCircleQuestion,
faCircleUser,
faCopy,
faShareNodes,
faQrcode,
faUser,
faPen,
faTrashCan,
faCalendar,
faEllipsisVertical,
faEye,
faEyeSlash,
faFileLines,
faFolderOpen,
faHand,
faHouseChimney,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faRotate,
faShareNodes,
faSpinner,
faCircleCheck,
faTrashCan,
faUser,
faUsers,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faCalendar,
faChevronLeft,
faHouseChimney,
faMagnifyingGlass,
faFolderOpen,
faHand,
faCircle,
faCircleCheck,
faCircleQuestion,
faCircleUser,
faCopy,
faShareNodes,
faQrcode,
faUser,
faPen,
faTrashCan,
faCalendar,
faEllipsisVertical,
faEye,
faEyeSlash,
faFileLines,
faFolderOpen,
faHand,
faHouseChimney,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faRotate,
faShareNodes,
faSpinner,
faCircleCheck
faTrashCan,
faUser,
faUsers,
faXmark
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
createApp(App)
.component("fa", FontAwesomeIcon)
.use(VueDexiePlugin())
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.mount("#app");

View File

@@ -1,18 +0,0 @@
import Vue from 'vue'
import { PluginObject } from 'vue'
// define the plugin class
class VueDexiePlugin implements PluginObject<{}> {
// the install method is called when the plugin is installed
public static install(app: typeof Vue): void {
// define a custom property
app.$myProperty = 'Hello, World!'
// define a custom method
app.prototype.$myMethod = (): string => {
return this.$myProperty
}
}
}
export default new VueDexiePlugin()

View File

@@ -1,28 +1,28 @@
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAppStore } from "../store/app";
import HomeView from "../views/HomeView.vue";
import { accountsDB } from "@/db";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: HomeView,
component: () =>
import(/* webpackChunkName: "start" */ "../views/DiscoverView.vue"),
beforeEnter: async (to, from, next) => {
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
next();
} else {
next({ name: "start" });
}
},
},
{
path: "/about",
name: "about",
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () =>
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
},
{
path: "/start",
name: "start",
component: () =>
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
},
{
path: "/account",
name: "account",
@@ -37,6 +37,20 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "confirm-contact" */ "../views/ConfirmContactView.vue"
),
},
{
path: "/contact-amounts",
name: "contact-amounts",
component: () =>
import(
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
),
},
{
path: "/contacts",
name: "contacts",
component: () =>
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
},
{
path: "/scan-contact",
name: "scan-contact",
@@ -51,6 +65,12 @@ const routes: Array<RouteRecordRaw> = [
component: () =>
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
},
{
path: "/help",
name: "help",
component: () =>
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
},
{
path: "/import-account",
name: "import-account",
@@ -96,46 +116,25 @@ const routes: Array<RouteRecordRaw> = [
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
},
{
path: "/commitments",
name: "commitments",
path: "/start",
name: "start",
component: () =>
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
},
{
path: "/statistics",
name: "statistics",
component: () =>
import(
/* webpackChunkName: "commitments" */ "../views/CommitmentsView.vue"
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
),
},
];
/** @type {*} */
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});
router.beforeEach(async (to) => {
const publicPages = ["/start", "/account", "/import-account"];
const isPublic = publicPages.includes(to.path);
const appStore = useAppStore();
let return_path = "/start";
if (isPublic) {
switch (appStore.condition) {
case "uninitialized":
return_path = "";
break;
case "registering":
return_path = to.path;
break;
}
} else {
switch (appStore.condition) {
case "registered":
return_path = to.path;
}
}
if (return_path == "") {
return;
} else {
return return_path;
}
});
export default router;

View File

@@ -1,22 +0,0 @@
// @ts-check
import { defineStore } from "pinia";
export const useAccountStore = defineStore({
id: "account",
state: () => ({
account: JSON.parse(
typeof localStorage["account"] == "undefined"
? null
: localStorage["account"]
),
}),
getters: {
firstName: (state) => state.account.firstName,
lastName: (state) => state.account.lastName,
},
actions: {
reset() {
localStorage.removeItem("account");
},
},
});

View File

@@ -4,21 +4,17 @@ import { defineStore } from "pinia";
export const useAppStore = defineStore({
id: "app",
state: () => ({
_condition:
typeof localStorage["condition"] == "undefined"
? "uninitialized"
: localStorage["condition"],
_lastView:
typeof localStorage["lastView"] == "undefined"
? "/start"
: localStorage["lastView"],
_projectId:
typeof localStorage.getItem("projectId") === "undefined"
? ""
: localStorage.getItem("projectId"),
}),
getters: {
condition: (state) => state._condition,
projectId: (state): string => state._projectId as string,
},
actions: {
reset() {
localStorage.removeItem("condition");
async setProjectId(newProjectId: string) {
localStorage.setItem("projectId", newProjectId);
},
},
});

60
src/test/index.ts Normal file
View File

@@ -0,0 +1,60 @@
import axios from "axios";
import * as didJwt from "did-jwt";
import { AppString } from "@/constants/app";
import { db } from "../db";
import { SERVICE_ID } from "../libs/veramo/setup";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
export async function testServerRegisterUser() {
const testUser0Mnem =
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";
const [addr, privateHex, publicHex, deriPath] = deriveAddress(testUser0Mnem);
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
// Make a claim
const vcClaim = {
"@context": "https://schema.org",
"@type": "RegisterAction",
agent: { did: identity0.did },
object: SERVICE_ID,
participant: { did: settings?.activeDid },
};
// Make a payload for the claim
const vcPayload = {
sub: "RegisterAction",
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// create a signature using private key of identity
// eslint-disable-next-line
const privateKeyHex: string = identity0.keys[0].privateKeyHex!;
const signer = await didJwt.SimpleSigner(privateKeyHex);
const alg = undefined;
// create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity0.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/claim";
const headers = {
"Content-Type": "application/json",
};
const resp = await axios.post(url, payload, { headers });
console.log("User registration result:", resp);
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
@@ -26,13 +26,13 @@
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Commitments -->
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'commitments' }"
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="hand" class="fa-fw"></fa>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
@@ -54,6 +54,18 @@
Your Identity
</h1>
<div class="flex justify-between py-2">
<span />
<span>
<router-link
:to="{ name: 'help' }"
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
>
Help
</router-link>
</span>
</div>
<!-- Friend referral requirement notice -->
<div
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"
@@ -72,17 +84,15 @@
<!-- Identity Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
<h2 class="text-xl font-semibold mb-2">{{ firstName }} {{ lastName }}</h2>
<div class="text-slate-500 text-sm font-bold">ID</div>
<div
class="text-sm text-slate-500 flex justify-between items-center mb-1"
>
<span
><code>{{ address }}</code>
<fa icon="copy" class="text-slate-400 fa-fw ml-1"></fa>
</span>
<span>
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
<code class="truncate">{{ activeDid }}</code>
<button @click="copy(activeDid)" class="ml-2">
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span class="whitespace-nowrap ml-4">
<button
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md"
>
@@ -96,49 +106,53 @@
</span>
</div>
<div class="text-slate-500 text-sm font-bold">Public Key</div>
<div class="text-sm text-slate-500 mb-1">
<span
><code>{{ publicHex }}</code>
<fa icon="copy" class="text-slate-400 fa-fw ml-1"></fa>
</span>
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
<code class="truncate">{{ publicBase64 }}</code>
<button @click="copy(publicBase64)" class="ml-2">
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
</div>
<div class="text-slate-500 text-sm font-bold">Public Key (hex)</div>
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
<code class="truncate">{{ publicHex }}</code>
<button @click="copy(publicHex)" class="ml-2">
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
</div>
<div class="text-slate-500 text-sm font-bold">Derivation Path</div>
<div class="text-sm text-slate-500 mb-1">
<span
><code>{{ UPORT_ROOT_DERIVATION_PATH }}</code>
<fa icon="copy" class="text-slate-400 fa-fw ml-1"></fa>
</span>
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
<code class="truncate">{{ derivationPath }}</code>
<button @click="copy(derivationPath)" class="ml-2">
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
</div>
</div>
<router-link
:to="{ name: 'new-edit-account' }"
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
>Edit Identity</router-link
>
Edit Identity
</router-link>
<h3 class="text-sm uppercase font-semibold mb-3">Contact Actions</h3>
<a
href="contact-scan.html"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
>Scan New Contact</a
>
<h3 class="text-sm uppercase font-semibold mb-3">Identity Actions</h3>
<h3 class="text-sm uppercase font-semibold mb-3">Data</h3>
<a
href=""
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
>Backup Seed</a
>
Backup Identifier Seed
</a>
<a
href=""
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
>Backup Other Data</a
@click="exportDatabase()"
>
Download Settings & Contacts (excluding Identifier Data)
</a>
<a ref="downloadLink" />
<!-- QR code popup -->
<dialog id="dlgQR" class="backdrop:bg-black/75 rounded-md">
@@ -162,91 +176,323 @@
</button>
</form>
</dialog>
<h3 class="text-sm uppercase font-semibold mb-3">Advanced</h3>
<label
for="toggleShowAmounts"
class="flex items-center cursor-pointer mb-6"
@click="handleChange"
>
<!-- toggle -->
<div class="relative">
<!-- input -->
<input
type="checkbox"
v-model="showContactGives"
name="showContactGives"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
<!-- label -->
<div class="ml-2">Show amounts given with contacts</div>
</label>
<div class="flex py-2">
<button class="text-center text-md text-blue-500" @click="checkLimits()">
Check Limits
</button>
<div v-if="!!limits?.nextWeekBeginDateTime" class="px-9">
<span class="font-bold">Rate Limits</span>
<p>
You have done {{ limits.doneClaimsThisWeek }} claims out of
{{ limits.maxClaimsPerWeek }} for this week. Your claims counter
resets at {{ readableTime(limits.nextWeekBeginDateTime) }}
</p>
<p>
You have done {{ limits.doneRegistrationsThisMonth }} registrations
out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
registrations counter resets at
{{ readableTime(limits.nextMonthBeginDateTime) }}
</p>
</div>
</div>
<div v-if="numAccounts > 0" class="flex py-2">
Switch Account
<span v-for="accountNum in numAccounts" :key="accountNum">
<button class="text-blue-500 px-2" @click="switchAccount(accountNum)">
#{{ accountNum }}
</button>
</span>
</div>
<div>
<button class="text-blue-500 px-2">
<router-link
:to="{ name: 'statistics' }"
class="block text-center py-3 px-1"
>
See Your Statistics
</router-link>
</button>
</div>
<div v-bind:class="computedAlertClassNames()">
<button
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2"
@click="onClickClose()"
>
<fa icon="xmark"></fa>
</button>
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
<p>{{ alertMessage }}</p>
</div>
</section>
</template>
<script lang="ts">
import { Options, Vue } from "vue-class-component";
import { createIdentifier, deriveAddress, newIdentifier } from "../libs/crypto";
import { IIdentifier } from "@veramo/core";
import "dexie-export-import";
import * as R from "ramda";
import { db } from "../db";
@Options({
components: {},
})
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { AppString } from "@/constants/app";
import { db, accountsDB } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
accessToken,
deriveAddress,
generateSeed,
newIdentifier,
} from "@/libs/crypto";
import { AxiosError } from "axios/index";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
interface RateLimits {
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
@Component
export default class AccountViewView extends Vue {
mnemonic = "";
address = "";
privateHex = "";
activeDid = "";
derivationPath = "";
firstName = "";
lastName = "";
numAccounts = 0;
publicHex = "";
UPORT_ROOT_DERIVATION_PATH = "";
async created() {
const previousIdentifiers: Array<IIdentifier> = [];
const toLowercase = true;
this.mnemonic = createIdentifier();
[
this.address,
this.privateHex,
this.publicHex,
this.UPORT_ROOT_DERIVATION_PATH,
] = deriveAddress(this.mnemonic);
//appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... derived keys and address..."}))
const prevIds = previousIdentifiers || [];
if (toLowercase) {
const foundEqual = R.find(
(id: IIdentifier) => id.did.split(":")[2] === this.address,
prevIds
);
if (foundEqual) {
// appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a normal-case version of the DID since a regular version exists."}))
} else {
this.address = this.address.toLowerCase();
}
} else {
// They're not trying to convert to lowercase.
const foundLower = R.find(
(id: IIdentifier) =>
id.did.split(":")[2] === this.address.toLowerCase(),
prevIds
);
if (foundLower) {
// appStore.dispatch(appSlice.actions.addLog({log: true, msg: "Will create a lowercase version of the DID since a lowercase version exists."}))
this.address = this.address.toLowerCase();
}
}
publicBase64 = "";
limits: RateLimits | null = null;
showContactGives = false;
copy = useClipboard().copy;
handleChange() {
this.showContactGives = !this.showContactGives;
this.updateShowContactAmounts();
}
readableTime(timeStr: string) {
return timeStr.substring(0, timeStr.indexOf("T"));
}
// 'created' hook runs when the Vue instance is first created
async created() {
// Uncomment to register this user on the test server.
// To manage within the vue devtools browser extension https://devtools.vuejs.org/
// assign this to a class variable, eg. "registerThisUser = testServerRegisterUser",
// select a component in the extension, and enter in the console: $vm.ctx.registerThisUser()
//testServerRegisterUser();
const newId = newIdentifier(
this.address,
this.publicHex,
this.privateHex,
this.UPORT_ROOT_DERIVATION_PATH
);
try {
await db.open();
const num_accounts = await db.accounts.count();
if (num_accounts === 0) {
console.log("...");
await db.accounts.add({
publicKey: newId.keys[0].publicKeyHex,
mnemonic: this.mnemonic,
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.firstName = settings?.firstName || "";
this.lastName = settings?.lastName || "";
this.showContactGives = !!settings?.showContactGivesInline;
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
if (this.numAccounts === 0) {
let address = ""; // 0x... ETH address, without "did:eth:"
let privateHex = "";
const mnemonic = generateSeed();
[address, privateHex, this.publicHex, this.derivationPath] =
deriveAddress(mnemonic);
const newId = newIdentifier(
address,
this.publicHex,
privateHex,
this.derivationPath
);
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: this.derivationPath,
did: newId.did,
identity: JSON.stringify(newId),
dateCreated: new Date().getTime(),
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
this.activeDid = newId.did;
}
const accounts = await db.accounts.toArray();
console.log(accounts[0]);
const identity = JSON.parse(accounts[0].identity);
this.address = identity.did;
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
this.publicHex = identity.keys[0].publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.derivationPath = identity.keys[0].meta.derivationPath;
//appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... created new ID..."}))
//appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... stored new ID..."}))
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
});
} catch (err) {
console.log("Error!");
console.log(err);
this.alertMessage =
"Clear your cache and start over (after data backup). See console log for more info.";
console.log("Telling user to clear cache because:", err);
this.alertTitle = "Error Creating Account";
this.isAlertVisible = true;
}
}
public async updateShowContactAmounts() {
try {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
showContactGivesInline: this.showContactGives,
});
} catch (err) {
this.alertMessage =
"Clear your cache and start over (after data backup). See console log for more info.";
console.log("Telling user to clear cache because:", err);
this.alertTitle = "Error Creating Account";
this.isAlertVisible = true;
}
}
public async exportDatabase() {
try {
const blob = await db.export({ prettyJson: true });
const url = URL.createObjectURL(blob);
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
downloadAnchor.href = url;
downloadAnchor.download = db.name + "-backup.json";
downloadAnchor.click();
URL.revokeObjectURL(url);
this.alertTitle = "Download Started";
this.alertMessage = "See your downloads directory for the backup.";
this.isAlertVisible = true;
} catch (error) {
this.alertTitle = "Export Error";
this.alertMessage = "See console logs for more info.";
this.isAlertVisible = true;
console.error("Export Error:", error);
}
}
async checkLimits() {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/report/rateLimits";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.get(url, { headers });
// axios throws an exception on a 400
if (resp.status === 200) {
this.limits = resp.data;
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.alertTitle = "Error from Server";
console.log("Bad response retrieving limits: ", serverError);
// Anybody know how to access items inside "response.data" without this?
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = serverError.response?.data;
if (data.error.message) {
this.alertMessage = data.error.message;
} else {
this.alertMessage = "Bad server response. See logs for details.";
}
this.isAlertVisible = true;
}
}
async switchAccount(accountNum: number) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = accounts[accountNum - 1];
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: account.did,
});
this.activeDid = account.did;
this.derivationPath = account.derivationPath;
this.publicHex = account.publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
}
public showContactGivesClassNames() {
return {
"bg-slate-900": !this.showContactGives,
"bg-green-600": this.showContactGives,
};
}
alertMessage = "";
alertTitle = "";
isAlertVisible = false;
public onClickClose() {
this.isAlertVisible = false;
this.alertTitle = "";
this.alertMessage = "";
}
public computedAlertClassNames() {
return {
hidden: !this.isAlertVisible,
"dismissable-alert": true,
"bg-slate-100": true,
"p-5": true,
rounded: true,
"drop-shadow-lg": true,
fixed: true,
"top-3": true,
"inset-x-3": true,
"transition-transform": true,
"ease-in": true,
"duration-300": true,
};
}
}
</script>

View File

@@ -1,3 +0,0 @@
<template>
<section id="Content" class="p-6 pb-24"></section>
</template>

View File

@@ -0,0 +1,384 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
><fa icon="circle-user" class="fa-fw"></fa
></router-link>
</li>
</ul>
</nav>
<section id="Content" class="p-6 pb-24">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Given with {{ contact?.name }}
</h1>
<!-- Results List -->
<div>
<div class="border-b border-slate-300 flex">
<div class="w-1/4"></div>
<div class="w-1/4">from them</div>
<div class="w-1/4"></div>
<div class="w-1/4">to them</div>
</div>
<div
class="border-b border-slate-300 flex"
v-for="record in giveRecords"
:key="record.id"
>
<div class="w-1/4">
{{ new Date(record.issuedAt).toLocaleString() }}
</div>
<div class="w-1/4">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" class="tooltip">
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
<span class="tooltiptext">Confirmed</span>
</span>
<button v-else class="tooltip" @click="confirm(record)">
<fa icon="circle" class="text-blue-600 fa-fw ml-1" />
<span class="tooltiptext">Unconfirmed</span>
</button>
</div>
<br />
{{ record.description }}
</span>
</div>
<div class="w-1/8">
<span v-if="record.agentDid == contact.did">
<fa icon="long-arrow-alt-left" class="text-slate-900 fa-fw ml-1" />
</span>
<span v-else>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<fa icon="long-arrow-alt-right" class="text-slate-900 fa-fw ml-1" />
</span>
</div>
<div class="w-1/4">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" class="tooltip">
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
<span class="tooltiptext">Confirmed</span>
</span>
<button v-else class="tooltip" @click="cannotConfirmMessage()">
<fa icon="circle" class="text-slate-600 fa-fw ml-1" />
<span class="tooltiptext">Unconfirmed</span>
</button>
</div>
<br />
{{ record.description }}
</span>
</div>
</div>
</div>
<div v-bind:class="computedAlertClassNames()">
<button
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2"
@click="onClickClose()"
>
<fa icon="xmark"></fa>
</button>
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
<p>{{ alertMessage }}</p>
</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Options, Vue } from "vue-class-component";
import { accountsDB, db } from "@/db";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { AppString } from "@/constants/app";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import {
AgreeVerifiableCredential,
GiveServerRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer";
import * as didJwt from "did-jwt";
import { AxiosError } from "axios";
@Options({})
export default class ContactsView extends Vue {
activeDid = "";
contact: Contact | null = null;
giveRecords: Array<GiveServerRecord> = [];
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const contactDid = this.$route.query.contactDid as string;
this.contact = (await db.contacts.get(contactDid)) || null;
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
if (this.activeDid && this.contact) {
this.loadGives(this.activeDid, this.contact);
}
}
async loadGives(activeDid: string, contact: Contact) {
// only load the private keys temporarily when needed
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
// load all the time I have given to them
try {
let result = [];
const url =
endorserApiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(identity.did) +
"&recipientDid=" +
encodeURIComponent(contact.did);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
result = resp.data.data;
} else {
console.log(
"Got bad response status & data of",
resp.status,
resp.data
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your given time from the server.";
this.isAlertVisible = true;
}
const url2 =
endorserApiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(contact.did) +
"&recipientDid=" +
encodeURIComponent(identity.did);
const token2 = await accessToken(identity);
const headers2 = {
"Content-Type": "application/json",
Authorization: "Bearer " + token2,
};
const resp2 = await this.axios.get(url2, { headers: headers2 });
if (resp2.status === 200) {
result = R.concat(result, resp2.data.data);
} else {
console.log(
"Got bad response status & data of",
resp2.status,
resp2.data
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your given time from the server.";
this.isAlertVisible = true;
}
const sortedResult: Array<GiveServerRecord> = R.sort(
(a, b) =>
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
result
);
this.giveRecords = sortedResult;
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
this.isAlertVisible = true;
}
}
async confirm(record: GiveServerRecord) {
// Make claim
// I use clone here because otherwise it gets a Proxy object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const origClaim: GiveVerifiableCredential = R.clone(record.fullClaim);
if (record.fullClaim["@context"] == SCHEMA_ORG_CONTEXT) {
delete origClaim["@context"];
}
origClaim["identifier"] = record.handleId;
const vcClaim: AgreeVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "AgreeAction",
object: origClaim,
};
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
if (identity.keys[0].privateKeyHex !== null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
//console.log("Got resp data:", resp.data);
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.alertTitle = "Error With Server";
this.alertMessage = userMessage;
this.isAlertVisible = true;
}
}
}
cannotConfirmMessage() {
this.alertTitle = "Not Allowed";
this.alertMessage = "Only the recipient can confirm final receipt.";
this.isAlertVisible = true;
}
alertTitle = "";
alertMessage = "";
isAlertVisible = false;
public onClickClose() {
this.isAlertVisible = false;
this.alertTitle = "";
this.alertMessage = "";
}
public computedAlertClassNames() {
return {
hidden: !this.isAlertVisible,
"dismissable-alert": true,
"bg-slate-100": true,
"p-5": true,
rounded: true,
"drop-shadow-lg": true,
fixed: true,
"top-3": true,
"inset-x-3": true,
"transition-transform": true,
"ease-in": true,
"duration-300": true,
};
}
}
</script>
<style>
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
/* Tooltip container */
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;
width: 200px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
}
/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
visibility: visible;
}
.tooltip:hover .tooltiptext-left {
visibility: visible;
}
</style>

919
src/views/ContactsView.vue Normal file
View File

@@ -0,0 +1,919 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
><fa icon="circle-user" class="fa-fw"></fa
></router-link>
</li>
</ul>
</nav>
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Contacts
</h1>
<div class="flex justify-between py-2">
<span />
<span>
<router-link
:to="{ name: 'help' }"
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
>
Help
</router-link>
</span>
</div>
<!-- New Contact -->
<div class="mb-4 flex">
<input
type="text"
placeholder="DID, Name, Public Key"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
v-model="contactInput"
/>
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="onClickNewContact()"
>
<fa icon="plus" class="fa-fw"></fa>
</button>
</div>
<div class="flex justify-between" v-if="showGiveNumbers">
<div class="w-full text-right">
Hours to Add:
<input
class="border border rounded border-slate-400 w-24 text-right"
type="text"
placeholder="1"
v-model="hourInput"
/>
<br />
<input
class="border border rounded border-slate-400 w-48"
type="text"
placeholder="Description"
v-model="hourDescriptionInput"
/>
<br />
<br />
<button
href=""
class="text-center text-md text-white px-1.5 py-2 rounded-md mb-6"
v-bind:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
>
{{
showGiveTotals
? "Total"
: showGiveConfirmed
? "Confirmed"
: "Unconfirmed"
}}
</button>
</div>
</div>
<!-- Results List -->
<ul class="">
<li
class="border-b border-slate-300"
v-for="contact in contacts"
:key="contact.did"
>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
{{ contact.name || "(no name)" }}
</h2>
<div class="text-sm truncate">{{ contact.did }}</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64): {{ contact.publicKeyBase64 }}
</div>
<button
v-if="contact.seesMe"
class="tooltip"
@click="setVisibility(contact, false)"
>
<fa icon="eye" class="text-slate-900 fa-fw ml-1" />
<span class="tooltiptext">They can see you</span>
</button>
<button v-else class="tooltip" @click="setVisibility(contact, true)">
<span class="tooltiptext">They cannot see you</span>
<fa icon="eye-slash" class="text-slate-900 fa-fw ml-1" />
</button>
<button class="tooltip" @click="checkVisibility(contact)">
<span class="tooltiptext">Check Visibility</span>
<fa icon="rotate" class="text-slate-900 fa-fw ml-1" />
</button>
<button v-if="contact.registered" class="tooltip">
<span class="tooltiptext">Registered</span>
<fa icon="person-circle-check" class="text-slate-900 fa-fw ml-1" />
</button>
<button v-else @click="register(contact)" class="tooltip">
<span class="tooltiptext">Registration Unknown</span>
<fa
icon="person-circle-question"
class="text-slate-900 fa-fw ml-1"
/>
</button>
<button @click="deleteContact(contact)" class="px-9 tooltip">
<span class="tooltiptext">Delete!</span>
<fa icon="trash-can" class="text-red-600 fa-fw ml-1" />
</button>
<div v-if="showGiveNumbers" class="float-right">
<div class="float-right">
<div class="tooltip">
to:
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenByMeConfirmed[contact.did] || 0)
+ (givenByMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenByMeConfirmed[contact.did] || 0)
: (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<span
v-if="givenByMeDescriptions[contact.did]"
class="tooltiptext-left"
>
{{ givenByMeDescriptions[contact.did] }}
</span>
<button
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
@click="onClickAddGive(activeDid, contact.did)"
>
+
</button>
</div>
<div class="tooltip px-2">
from:
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenToMeConfirmed[contact.did] || 0)
+ (givenToMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenToMeConfirmed[contact.did] || 0)
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<span
v-if="givenToMeDescriptions[contact.did]"
class="tooltiptext-left"
>
{{ givenToMeDescriptions[contact.did] }}
</span>
<button
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
@click="onClickAddGive(contact.did, activeDid)"
>
+
</button>
</div>
<router-link
:to="{
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="tooltip"
>
<fa icon="file-lines" class="text-slate-600 fa-fw ml-1" />
<span class="tooltiptext-left">See All Given Activity</span>
</router-link>
</div>
</div>
</div>
</li>
</ul>
</section>
<div v-bind:class="computedAlertClassNames()">
<button
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2"
@click="onClickClose()"
>
<fa icon="xmark"></fa>
</button>
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
<p>{{ alertMessage }}</p>
</div>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Options, Vue } from "vue-class-component";
import { AppString } from "@/constants/app";
import { accountsDB, db } from "@/db";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import {
GiveServerRecord,
GiveVerifiableCredential,
RegisterVerifiableCredential,
SERVICE_ID,
} from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
@Options({
components: {},
})
export default class ContactsView extends Vue {
activeDid = "";
contacts: Array<Contact> = [];
contactInput = "";
// { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
givenByMeConfirmed: Record<string, number> = {};
// { "did:...": amount } entry for each contact
givenByMeUnconfirmed: Record<string, number> = {};
// { "did:...": concatenated-descriptions } entry for each contact
givenToMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
givenToMeConfirmed: Record<string, number> = {};
// { "did:...": amount } entry for each contact
givenToMeUnconfirmed: Record<string, number> = {};
hourDescriptionInput = "";
hourInput = "0";
showGiveNumbers = false;
showGiveTotals = true;
showGiveConfirmed = true;
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.showGiveNumbers = !!settings?.showContactGivesInline;
if (this.showGiveNumbers) {
this.loadGives();
}
const allContacts = await db.contacts.toArray();
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts
);
}
async loadGives() {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
if (!identity) {
console.error(
"Attempted to load Give records with no identity available."
);
return;
}
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
// load all the time I have given
try {
const url =
endorserApiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(identity.did);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const resp = await this.axios.get(url, { headers });
console.log("All gifts you've given:", resp.data);
if (resp.status === 200) {
const contactDescriptions: Record<string, string> = {};
const contactConfirmed: Record<string, number> = {};
const contactUnconfirmed: Record<string, number> = {};
const allData: Array<GiveServerRecord> = resp.data.data;
for (const give of allData) {
if (give.unit == "HUR") {
const recipDid: string = give.recipientDid;
if (give.amountConfirmed) {
const prevAmount = contactConfirmed[recipDid] || 0;
contactConfirmed[recipDid] = prevAmount + give.amount;
} else {
const prevAmount = contactUnconfirmed[recipDid] || 0;
contactUnconfirmed[recipDid] = prevAmount + give.amount;
}
if (!contactDescriptions[recipDid] && give.description) {
// Since many make the tooltip too big, we'll just use the latest.
contactDescriptions[recipDid] = give.description;
}
}
}
//console.log("Done retrieving gives", contactConfirmed);
this.givenByMeDescriptions = contactDescriptions;
this.givenByMeConfirmed = contactConfirmed;
this.givenByMeUnconfirmed = contactUnconfirmed;
} else {
console.log(
"Got bad response status & data of",
resp.status,
resp.data
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your given time from the server.";
this.isAlertVisible = true;
}
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
this.isAlertVisible = true;
}
// load all the time I have received
try {
const url =
endorserApiServer +
"/api/v2/report/gives?recipientDid=" +
encodeURIComponent(identity.did);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const resp = await this.axios.get(url, { headers });
console.log("All gifts you've recieved:", resp.data);
if (resp.status === 200) {
const contactDescriptions: Record<string, string> = {};
const contactConfirmed: Record<string, number> = {};
const contactUnconfirmed: Record<string, number> = {};
const allData: Array<GiveServerRecord> = resp.data.data;
for (const give of allData) {
if (give.unit == "HUR") {
if (give.amountConfirmed) {
const prevAmount = contactConfirmed[give.agentDid] || 0;
contactConfirmed[give.agentDid] = prevAmount + give.amount;
} else {
const prevAmount = contactUnconfirmed[give.agentDid] || 0;
contactUnconfirmed[give.agentDid] = prevAmount + give.amount;
}
if (!contactDescriptions[give.agentDid] && give.description) {
// Since many make the tooltip too big, we'll just use the latest.
contactDescriptions[give.agentDid] = give.description;
}
}
}
//console.log("Done retrieving receipts", contactConfirmed);
this.givenToMeDescriptions = contactDescriptions;
this.givenToMeConfirmed = contactConfirmed;
this.givenToMeUnconfirmed = contactUnconfirmed;
} else {
console.log(
"Got bad response status & data of",
resp.status,
resp.data
);
this.alertTitle = "Error With Server";
this.alertMessage =
"Got an error retrieving your received time from the server.";
this.isAlertVisible = true;
}
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
this.isAlertVisible = true;
}
}
async onClickNewContact(): Promise<void> {
let did = this.contactInput;
let name, publicKeyBase64;
const commaPos1 = this.contactInput.indexOf(",");
if (commaPos1 > -1) {
did = this.contactInput.substring(0, commaPos1).trim();
name = this.contactInput.substring(commaPos1 + 1).trim();
const commaPos2 = this.contactInput.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
name = this.contactInput.substring(commaPos1 + 1, commaPos2).trim();
publicKeyBase64 = this.contactInput.substring(commaPos2 + 1).trim();
}
}
// help with potential mistakes while this sharing requires copy-and-paste
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact = { did, name, publicKeyBase64 };
await db.contacts.add(newContact);
const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts
);
}
async deleteContact(contact: Contact) {
if (
confirm(
"Are you sure you want to delete " +
this.nameForDid(this.contacts, contact.did) +
" with DID " +
contact.did +
" ?"
)
) {
await db.open();
await db.contacts.delete(contact.did);
this.contacts = R.without([contact], this.contacts);
}
}
async register(contact: Contact) {
if (
confirm(
"Are you sure you want to use one of your registrations for " +
this.nameForDid(this.contacts, contact.did) +
"?"
)
) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
// Make a claim
const vcClaim: RegisterVerifiableCredential = {
"@context": "https://schema.org",
"@type": "RegisterAction",
agent: { identifier: identity.did },
object: SERVICE_ID,
recipient: { identifier: contact.did },
};
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
if (identity.keys[0].privateKeyHex !== null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
//console.log("Got resp data:", resp.data);
if (resp.data?.success?.handleId) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.alertTitle = "Registration Success";
this.alertMessage = contact.name + " has been registered.";
this.isAlertVisible = true;
}
} 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.alertTitle = "Error With Server";
this.alertMessage = userMessage;
this.isAlertVisible = true;
}
}
}
}
async setVisibility(contact: Contact, visibility: boolean) {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url =
endorserApiServer +
"/api/report/" +
(visibility ? "canSeeMe" : "cannotSeeMe");
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const payload = JSON.stringify({ did: contact.did });
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.status === 200) {
contact.seesMe = visibility;
db.contacts.update(contact.did, { seesMe: visibility });
} else {
this.alertTitle = "Error With Server";
console.log("Bad response setting visibility: ", resp.data);
if (resp.data.error?.message) {
this.alertMessage = resp.data.error?.message;
} else {
this.alertMessage = "Bad server response of " + resp.status;
}
this.isAlertVisible = true;
}
} catch (err) {
this.alertTitle = "Error With Server";
this.alertMessage = err as string;
this.isAlertVisible = true;
}
}
async checkVisibility(contact: Contact) {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url =
endorserApiServer +
"/api/report/canDidExplicitlySeeMe?did=" +
encodeURIComponent(contact.did);
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const visibility = resp.data;
contact.seesMe = visibility;
db.contacts.update(contact.did, { seesMe: visibility });
this.alertTitle = "Refreshed";
this.alertMessage =
this.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.";
this.isAlertVisible = true;
} else {
this.alertTitle = "Error With Server";
if (resp.data.error?.message) {
this.alertMessage = resp.data.error?.message;
} else {
this.alertMessage = "Bad server response of " + resp.status;
}
this.isAlertVisible = true;
}
} catch (err) {
this.alertTitle = "Error With Server";
this.alertMessage = err as string;
this.isAlertVisible = true;
}
}
// from https://stackoverflow.com/a/175787/845494
//
private isNumeric(str: string): boolean {
return !isNaN(+str);
}
private nameForDid(contacts: Array<Contact>, did: string): string {
const contact = R.find((con) => con.did == did, contacts);
return this.nameForContact(contact);
}
private nameForContact(contact?: Contact, capitalize?: boolean): string {
return contact?.name || (capitalize ? "T" : "t") + "this unnamed user";
}
async onClickAddGive(fromDid: string, toDid: string): Promise<void> {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
// if they have unconfirmed amounts, ask to confirm those first
if (toDid == identity?.did && this.givenToMeUnconfirmed[fromDid] > 0) {
if (
confirm(
"There are " +
this.givenToMeUnconfirmed[fromDid] +
" unconfirmed hours from them." +
" Would you like to confirm some of those hours?"
)
) {
this.$router.push({
name: "contact-amounts",
query: { contactDid: fromDid },
});
}
}
if (!this.isNumeric(this.hourInput)) {
this.alertTitle = "Input Error";
this.alertMessage =
"This is not a valid number of hours: " + this.hourInput;
this.isAlertVisible = true;
} else if (!parseFloat(this.hourInput)) {
this.alertTitle = "Input Error";
this.alertMessage = "Giving 0 hours does nothing.";
this.isAlertVisible = true;
} else if (!identity) {
this.alertTitle = "Status Error";
this.alertMessage = "No identity is available.";
this.isAlertVisible = true;
} else {
// ask to confirm amount
let toFrom;
if (fromDid == identity?.did) {
toFrom = "from you to " + this.nameForDid(this.contacts, toDid);
} else {
toFrom = "from " + this.nameForDid(this.contacts, fromDid) + " to you";
}
let description;
if (this.hourDescriptionInput) {
description = " with description '" + this.hourDescriptionInput + "'";
} else {
description = " with no description";
}
if (
confirm(
"Are you sure you want to record " +
this.hourInput +
" hours " +
toFrom +
description +
"?"
)
) {
this.createAndSubmitGive(
identity,
fromDid,
toDid,
parseFloat(this.hourInput),
this.hourDescriptionInput
);
}
}
}
private async createAndSubmitGive(
identity: IIdentifier,
fromDid: string,
toDid: string,
amount: number,
description: string
): Promise<void> {
// Make a claim
const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org",
"@type": "GiveAction",
agent: { identifier: fromDid },
object: { amountOfThisGood: amount, unitCode: "HUR" },
recipient: { identifier: toDid },
};
if (description) {
vcClaim.description = description;
}
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
if (identity.keys[0].privateKeyHex !== null) {
// 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 endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
//console.log("Got resp data:", resp.data);
if (resp.data?.success?.handleId) {
this.alertTitle = "Done";
this.alertMessage = "Successfully logged time to the server.";
this.isAlertVisible = true;
if (fromDid === identity.did) {
const newList = R.clone(this.givenByMeUnconfirmed);
newList[toDid] = (newList[toDid] || 0) + amount;
this.givenByMeUnconfirmed = newList;
} else {
const newList = R.clone(this.givenToMeConfirmed);
newList[fromDid] = (newList[fromDid] || 0) + amount;
this.givenToMeConfirmed = newList;
}
}
} 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.alertTitle = "Error With Server";
this.alertMessage = userMessage;
this.isAlertVisible = true;
}
}
}
public toggleShowGiveTotals() {
if (this.showGiveTotals) {
this.showGiveTotals = false;
this.showGiveConfirmed = true;
} else if (this.showGiveConfirmed) {
this.showGiveTotals = false; // stays the same
this.showGiveConfirmed = false;
} else {
this.showGiveTotals = true;
this.showGiveConfirmed = true;
}
}
alertTitle = "";
alertMessage = "";
isAlertVisible = false;
public onClickClose() {
this.isAlertVisible = false;
this.alertTitle = "";
this.alertMessage = "";
}
public computedAlertClassNames() {
return {
hidden: !this.isAlertVisible,
"dismissable-alert": true,
"bg-slate-100": true,
"p-5": true,
rounded: true,
"drop-shadow-lg": true,
fixed: true,
"top-3": true,
"inset-x-3": true,
"transition-transform": true,
"ease-in": true,
"duration-300": true,
};
}
public showGiveAmountsClassNames() {
return {
"bg-slate-500": this.showGiveTotals,
"bg-green-600": !this.showGiveTotals && this.showGiveConfirmed,
"bg-yellow-600": !this.showGiveTotals && !this.showGiveConfirmed,
};
}
}
</script>
<style>
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
/* Tooltip container */
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;
width: 200px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
}
/* How do we share with the above so code isn't duplicated? */
.tooltip .tooltiptext-left {
visibility: hidden;
width: 200px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
bottom: 0%;
right: 105%;
margin-left: -60px;
}
/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
visibility: visible;
}
.tooltip:hover .tooltiptext-left {
visibility: visible;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
@@ -19,15 +19,17 @@
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'project' }"
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Commitments -->
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: '' }" class="block text-center py-3 px-1"
><fa icon="hand" class="fa-fw"></fa
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->

165
src/views/HelpView.vue Normal file
View File

@@ -0,0 +1,165 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-400">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Help
</h1>
<div>
<h2 class="text-xl font-semibold">Introduction</h2>
<p>
This app is a window into data that you and your friends own, focused on
gifts and collaboration.
</p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p>
There are two parts to backup your data: the identifier secrets and the
other data such as settings, contacts, etc.
</p>
<div class="px-4">
<h2 class="text-xl font-semibold">
How do I backup my identifier (secret) data?
</h2>
<ul class="list-disc list-inside">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
</li>
<li>
Click on "Backup Identifier Seed" and follow the instructions.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data?
</h2>
<ul class="list-disc list-inside">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
</li>
<li>
Click on "Download Settings...". That will save a file to your
downloads folder. That is your backup, so put it someplace where you
won't lose it.
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
<p>
There are two parts to restore your data: the identity secrets and the
other data such as settings, contacts, etc.
</p>
<div class="px-4">
<h2 class="text-xl font-semibold">
How do I restore my identifier (secret) data?
</h2>
<ul class="list-disc list-inside">
<li>
You only have one identifier at a time. If you have an identifier on
Your Identity <fa icon="circle-user" class="fa-fw" /> page, you'll
need to clear it out;
<a
href="https://www.lifewire.com/how-to-clear-cache-2617980"
class="text-blue-500"
>
here are some helpful instructions.
</a>
But beware! This will also clear out your settings and contact data,
so be sure to back that up first.
</li>
<li>
<router-link class="text-blue-500" to="/">
Go to the start
</router-link>
and choose "Yes" to enter the identity you backed up.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I restore my other (non-identifier-secret) data?
</h2>
<ul class="list-disc list-inside">
<li>Make sure you have your backup file (above), then contact us.</li>
</ul>
</div>
<h2 class="text-xl font-semibold">
How do I add someone to my contacts?
</h2>
<p>
Tell them to copy their ID, which typically starts with "did:ethr:...",
and send it to you. Go to the Contacts
<fa icon="circle-user" class="fa-fw" /> page and enter that into the top
form. You may add a name by adding a comma followed by their name; you
may also add their public key by adding another comma followed by the
key.
</p>
<h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info?
</h2>
<p>
Sometimes someone else has made claims -- meaning they have given time,
or recognized time given, or declared a project -- but you cannot see
anything associated with them. The reason may be because they have not
given you permission to see their information. Ask them to add you to
their contact list and make sure the eye next to your name is open like
this
<fa icon="eye" class="fa-fw" /> and not closed like this
<fa icon="eye-slash" class="fa-fw" />.
</p>
</div>
</section>
</template>

View File

@@ -14,42 +14,89 @@
</h1>
</div>
<!-- Import Account Form -->
<form>
<p class="text-center text-xl mb-4 font-light">
Enter your seed phrase below to import your identity on this device.
</p>
<input
type="text"
placeholder="Seed Phrase"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Import Identity"
/>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</form>
<p class="text-center text-xl mb-4 font-light">
Enter your seed phrase below to import your identity on this device.
</p>
<input
type="text"
placeholder="Seed Phrase"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="mnemonic"
/>
{{ mnemonic }}
<div class="mt-8">
<button
@click="from_mnemonic()"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
>
Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Options, Vue } from "vue-class-component";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Options({
components: {},
})
export default class ImportAccountView extends Vue {
mnemonic = "";
address = "";
privateHex = "";
publicHex = "";
derivationPath = "";
public onCancelClick() {
this.$router.back();
}
public async from_mnemonic() {
const mne: string = this.mnemonic.trim().toLowerCase();
if (this.mnemonic.trim().length > 0) {
[this.address, this.privateHex, this.publicHex, this.derivationPath] =
deriveAddress(mne);
const newId = newIdentifier(
this.address,
this.publicHex,
this.privateHex,
this.derivationPath
);
try {
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: this.derivationPath,
did: newId.did,
identity: JSON.stringify(newId),
mnemonic: mne,
publicKeyHex: newId.keys[0].publicKeyHex,
});
// record that as the active DID
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
this.$router.push({ name: "account" });
} catch (err) {
console.log("Error!");
console.log(err);
}
}
}
}
</script>

View File

@@ -18,17 +18,20 @@
type="text"
placeholder="First Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="firstName"
/>
<input
type="text"
placeholder="Last Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="lastName"
/>
<div class="mt-8">
<button
type="button"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="onClickSaveChanges()"
>
Save Changes
</button>
@@ -36,6 +39,7 @@
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onClickCancel()"
>
Cancel
</button>
@@ -46,9 +50,42 @@
<script lang="ts">
import { Options, Vue } from "vue-class-component";
import { db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Options({
components: {},
})
export default class NewEditAccountView extends Vue {}
export default class NewEditAccountView extends Vue {
firstName =
localStorage.getItem("firstName") === null
? "--"
: localStorage.getItem("firstName");
lastName =
localStorage.getItem("lastName") === null
? "--"
: localStorage.getItem("lastName");
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.firstName = settings?.firstName || "";
this.lastName = settings?.lastName || "";
}
onClickSaveChanges() {
db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.firstName,
lastName: this.lastName,
});
localStorage.setItem("firstName", this.firstName as string);
localStorage.setItem("lastName", this.lastName as string);
this.$router.push({ name: "account" });
}
onClickCancel() {
this.$router.back();
}
}
</script>

View File

@@ -10,69 +10,286 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
[New/Edit] Project
[New/Edit] Plan
</h1>
</div>
<!-- Project Details -->
<form>
<!-- Image - (see design model) Empty -->
<!-- Image - (see design model) Empty -->
<!-- Image - Populated -->
<div class="relative mb-4 rounded-md overflow-hidden">
<div class="absolute top-3 right-3 flex gap-2">
<button
class="text-md font-bold uppercase bg-blue-600 text-white px-3 py-2 rounded"
>
<fa icon="pen" class="fa-fw"></fa>
</button>
<button
class="text-md font-bold uppercase bg-red-600 text-white px-3 py-2 rounded"
>
<fa icon="trash-can" class="fa-fw"></fa>
</button>
</div>
<img src="https://picsum.photos/800/400" class="w-full" />
</div>
<div>
{{ errorMessage }}
</div>
<input
type="text"
placeholder="Project Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<input
type="text"
placeholder="Project Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="projectName"
/>
<textarea
placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
rows="5"
></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
88/500 max. characters
</div>
<textarea
placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
rows="5"
v-model="description"
maxlength="500"
></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
{{ description.length }}/500 max. characters
</div>
<div class="mt-8">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
value="Save Project"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
<div class="mt-8">
<button
:disabled="isHiddenSave"
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
@click="onSaveProjectClick()"
>
<!-- SHOW if in idle state -->
<span :class="{ hidden: isHiddenSave }">Save Project</span>
<!-- SHOW if in saving state; DISABLE button while in saving state -->
<span :class="{ hidden: isHiddenSpinner }"
><i class="fa-solid fa-spinner fa-spin-pulse"></i>
Saving&hellip;</span
>
Cancel
</button>
</div>
</form>
</button>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onCancelClick()"
>
Cancel
</button>
</div>
</section>
<div v-bind:class="computedAlertClassNames()">
<button
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2"
@click="onClickClose()"
>
<fa icon="xmark"></fa>
</button>
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
<p>{{ alertMessage }}</p>
</div>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { Options, Vue } from "vue-class-component";
import { AppString } from "@/constants/app";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { useAppStore } from "@/store/app";
import { IIdentifier } from "@veramo/core";
interface VerifiableCredential {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
}
@Options({
components: {},
})
export default class NewEditProjectView extends Vue {}
export default class NewEditProjectView extends Vue {
activeDid = "";
projectName = "";
description = "";
errorMessage = "";
alertTitle = "";
alertMessage = "";
projectId = localStorage.getItem("projectId") || "";
isHiddenSave = false;
isHiddenSpinner = true;
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
if (this.projectId) {
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.log("Problem! Should have a profile!");
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
this.LoadProject(identity);
}
}
}
async LoadProject(identity: IIdentifier) {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url =
endorserApiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.get(url, { headers });
console.log(resp.status, resp.data);
if (resp.status === 200) {
const claim = resp.data.claim;
this.projectName = claim.name;
this.description = claim.description;
}
} catch (error) {
console.log(error);
}
}
private async SaveProject(identity: IIdentifier) {
// Make a claim
const vcClaim: VerifiableCredential = {
"@context": "https://schema.org",
"@type": "PlanAction",
name: this.projectName,
description: this.description,
identifier: this.projectId || undefined,
};
if (this.projectId) {
vcClaim.identifier = this.projectId;
}
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// create a signature using private key of identity
if (identity.keys[0].privateKeyHex != null) {
const privateKeyHex: string = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
console.log("Got resp data:", resp.data);
// handleId is new in server v release-1.6.0; remove fullIri when that
// version shows up here: https://endorser.ch:3000/api-docs/
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
this.errorMessage = "";
this.alertTitle = "";
this.alertMessage = "";
// handleId is new in server v release-1.6.0; remove fullIri when that
// version shows up here: https://endorser.ch:3000/api-docs/
useAppStore().setProjectId(
resp.data.success.handleId || resp.data.success.fullIri
);
setTimeout(
function (that: Vue) {
const route = {
name: "project",
};
console.log(route);
that.$router.push(route);
},
2000,
this
);
}
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
this.isAlertVisible = true;
if (serverError.message) {
this.alertTitle = "User Message";
userMessage = serverError.message; // This is info for the user.
this.alertMessage = userMessage;
} else {
this.alertTitle = "Server Message";
this.alertMessage = JSON.stringify(serverError.toJSON());
}
} else {
console.log("Here's the full error trying to save the claim:", error);
this.alertTitle = "Claim Error";
this.alertMessage = error as string;
}
// Now set that error for the user to see.
this.errorMessage = userMessage;
}
}
}
public onClickClose() {
this.isAlertVisible = false;
this.alertTitle = "";
this.alertMessage = "";
}
public async onSaveProjectClick() {
this.isHiddenSave = true;
this.isHiddenSpinner = false;
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.log("Problem! Should have a profile!");
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
this.SaveProject(identity);
}
}
public onCancelClick() {
this.$router.back();
}
isAlertVisible = false;
public computedAlertClassNames() {
return {
hidden: !this.isAlertVisible,
"dismissable-alert": true,
"bg-slate-100": true,
"p-5": true,
rounded: true,
"drop-shadow-lg": true,
fixed: true,
"top-3": true,
"inset-x-3": true,
"transition-transform": true,
"ease-in": true,
"duration-300": true,
};
}
}
</script>

View File

@@ -1,30 +1,36 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<a href="" class="block text-center py-3 px-1"
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></a>
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<a href="search.html" class="block text-center py-3 px-1"
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></a>
></router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<a href="" class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></a>
</li>
<!-- Commitments -->
<li class="basis-1/5 rounded-md text-slate-500">
<a href="" class="block text-center py-3 px-1"
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="hand" class="fa-fw"></fa
></a>
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
@@ -56,35 +62,50 @@
><fa icon="ellipsis-vertical" class="fa-fw"></fa
></a>
View Project
View Plan
</h1>
</div>
<div>
{{ errorMessage }}
</div>
<!-- Project Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<!-- Image -->
<div class="-mx-4 -mt-3 mb-3">
<img src="https://picsum.photos/800/400" class="w-full" />
</div>
<div>
<h2 class="text-xl font-semibold">Canyon cleanup</h2>
<h2 class="text-xl font-semibold">{{ name }}</h2>
<div class="flex justify-between gap-4 text-sm mb-3">
<span><fa icon="user" class="fa-fw text-slate-400"></fa> Rotary</span>
<span
><fa icon="calendar" class="fa-fw text-slate-400"></fa> 8 days
ago</span
>
><fa icon="calendar" class="fa-fw text-slate-400"></fa
>{{ timeSince }}
</span>
</div>
<div class="text-sm text-slate-500">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
accusantium doloremque laudantium
<a href="" class="uppercase text-xs font-semibold text-slate-700"
>Read More</a
>
<div v-if="!expanded">
{{ truncatedDesc }}
<a v-if="description.length >= truncateLength" @click="expandText"
>Read More</a
>
</div>
<div v-else>
{{ description }}
<a
@click="collapseText"
class="uppercase text-xs font-semibold text-slate-700"
>Read Less</a
>
</div>
</div>
</div>
<button
type="button"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="onEditClick()"
>
Edit
</button>
</div>
<!-- Commit -->
@@ -125,10 +146,103 @@
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as moment from "moment";
import * as R from "ramda";
import { Options, Vue } from "vue-class-component";
import { AppString } from "@/constants/app";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core";
@Options({
components: {},
})
export default class ProjectViewView extends Vue {}
export default class ProjectViewView extends Vue {
expanded = false;
name = "";
description = "";
truncatedDesc = "";
truncateLength = 40;
timeSince = "";
projectId = localStorage.getItem("projectId") || "";
errorMessage = "";
onEditClick() {
localStorage.setItem("projectId", this.projectId as string);
const route = {
name: "new-edit-project",
};
console.log(route);
this.$router.push(route);
}
expandText() {
this.expanded = true;
}
collapseText() {
this.expanded = false;
}
async LoadProject(identity: IIdentifier) {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
// eslint-disable-next-line prettier/prettier
const url = endorserApiServer + "/api/claim/byHandle/" + encodeURIComponent(this.projectId);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.get(url, { headers });
console.log("resp.status, resp.data", resp.status, resp.data);
if (resp.status === 200) {
const startTime = resp.data.startTime;
if (startTime != null) {
const eventDate = new Date(startTime);
const now = moment.now();
this.timeSince = moment.utc(now).to(eventDate);
}
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
} else if (resp.status === 404) {
// actually, axios throws an error so we never get here
this.errorMessage = "That project does not exist.";
}
} catch (error: unknown) {
const serverError = error as AxiosError;
if (serverError.response?.status === 404) {
this.errorMessage = "That project does not exist.";
} else {
this.errorMessage =
"Something went wrong retrieving that project." +
" See logs for more info.";
console.log("Error retrieving project:", error);
}
}
}
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.log("Problem! Should have a profile!");
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
this.LoadProject(identity);
}
}
}
</script>

View File

@@ -1,3 +1,186 @@
<template>
<section id="Content" class="p-6 pb-24"></section>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
><fa icon="circle-user" class="fa-fw"></fa
></router-link>
</li>
</ul>
</nav>
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Plans
</h1>
<!-- Quick Search -->
<form id="QuickSearch" class="mb-4 flex">
<input
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
/>
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</button>
</form>
<!-- New Project -->
<button
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()"
>
<fa icon="plus" class="fa-fw"></fa>
</button>
<!-- Results List -->
<ul class="">
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
>
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
>
<div class="flex-none w-12">
<img
src="https://picsum.photos/200/200?random=1"
class="w-full rounded"
/>
</div>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">{{ project.name }}</h2>
<div class="text-sm truncate">
{{ project.description }}
</div>
</div>
</a>
</li>
</ul>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Options, Vue } from "vue-class-component";
import { AppString } from "@/constants/app";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core";
@Options({
components: {},
})
export default class ProjectsView extends Vue {
projects: { handleId: string; name: string; description: string }[] = [];
onClickLoadProject(id: string) {
console.log("projectId", id);
localStorage.setItem("projectId", id);
const route = {
name: "project",
};
console.log(route);
this.$router.push(route);
}
async LoadProjects(identity: IIdentifier) {
const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/v2/report/plansByIssuer";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const plans = resp.data.data;
for (let i = 0; i < plans.length; i++) {
const plan = plans[i];
const data = {
name: plan.name,
description: plan.description,
// handleId is new in server v release-1.6.0; remove fullIri when that
// version shows up here: https://endorser.ch:3000/api-docs/
handleId: plan.handleId || plan.fullIri,
};
this.projects.push(data);
}
}
} catch (error) {
console.log(error);
}
}
// 'created' hook runs when the Vue instance is first created
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.log("Problem! Should have a profile!");
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const identity = JSON.parse(account?.identity || "undefined");
this.LoadProjects(identity);
}
}
onClickNewProject(): void {
localStorage.removeItem("projectId");
const route = {
name: "new-edit-project",
};
console.log(route);
this.$router.push(route);
}
}
</script>

View File

@@ -0,0 +1,212 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Statistics
</h1>
<button class="float-right" @click="captureGraphics()">Screenshot</button>
<!-- Another place to play with the sizing is in Resizer.setSize -->
<div id="scene-container" class="h-screen"></div>
<div v-bind:class="computedAlertClassNames()">
<button
class="close-button bg-slate-200 w-8 leading-loose rounded-full absolute top-2 right-2"
@click="onClickClose()"
>
<fa icon="xmark"></fa>
</button>
<h4 class="font-bold pr-5">{{ alertTitle }}</h4>
<p>{{ alertMessage }}</p>
</div>
</section>
</template>
<script lang="ts">
import { SVGRenderer } from "three/addons/renderers/SVGRenderer.js";
import { Component, Vue } from "vue-facing-decorator";
import { World } from "@/components/World/World.js";
@Component
export default class StatisticsView extends Vue {
world: World;
mounted() {
const container = document.querySelector("#scene-container");
const newWorld = new World(container, this);
newWorld.start();
this.world = newWorld;
}
public captureGraphics() {
/**
// from https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#examples
// Adds a blank image
const dataBlob = document
.querySelector("#scene-container")
.firstChild.toBlob((blob) => {
const newImg = document.createElement("img");
const url = URL.createObjectURL(blob);
newImg.onload = () => {
// no longer need to read the blob so it's revoked
URL.revokeObjectURL(url);
};
newImg.src = url;
document.body.appendChild(newImg);
});
**/
/**
// Yields a blank page with the iframe below
const dataUrl = document
.querySelector("#scene-container")
.firstChild.toDataURL("image/png");
**/
/**
// Yields a blank page with the iframe below
const dataUrl = this.world.renderer.domElement.toDataURL("image/png");
**/
/**
// Show the image in a new tab
const iframe = `
<iframe
src="${dataUrl}"
frameborder="0"
style="border:0; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%;"
allowfullscreen>
</iframe>`;
const win = window.open();
win.document.open();
win.document.write(iframe);
win.document.close();
**/
// from https://stackoverflow.com/a/17407392/845494
// This yields a file with funny formatting.
//const image = const dataUrl.replace("image/png", "image/octet-stream");
/**
// Yields a blank image at the bottom of the page
// from https://discourse.threejs.org/t/save-screenshot-on-server/39900/3
const img = new Image();
img.src = this.world.renderer.domElement.toDataURL();
document.body.appendChild(img);
**/
/**
* This yields an SVG that only shows white and black highlights
// from https://stackoverflow.com/questions/27632621/exporting-from-three-js-scene-to-svg-or-other-vector-format
**/
const rendererSVG = new SVGRenderer();
rendererSVG.setSize(window.innerWidth, window.innerHeight);
rendererSVG.render(this.world.scene, this.world.camera);
//document.body.appendChild(rendererSVG.domElement);
ExportToSVG(rendererSVG, "test.svg");
}
alertTitle = "";
alertMessage = "";
isAlertVisible = false;
public setAlert(title, message) {
this.alertTitle = title;
this.alertMessage = message;
this.isAlertVisible = true;
}
public onClickClose() {
this.isAlertVisible = false;
this.alertTitle = "";
this.alertMessage = "";
}
public computedAlertClassNames() {
return {
hidden: !this.isAlertVisible,
"dismissable-alert": true,
"bg-slate-100": true,
"p-5": true,
rounded: true,
"drop-shadow-lg": true,
fixed: true,
"top-3": true,
"inset-x-3": true,
"transition-transform": true,
"ease-in": true,
"duration-300": true,
};
}
}
function ExportToSVG(rendererSVG, filename) {
const XMLS = new XMLSerializer();
const svgfile = XMLS.serializeToString(rendererSVG.domElement);
const svgData = svgfile;
const preface = '<?xml version="1.0" standalone="no"?>\r\n';
const svgBlob = new Blob([preface, svgData], {
type: "image/svg+xml;charset=utf-8",
});
const svgUrl = URL.createObjectURL(svgBlob);
const downloadLink = document.createElement("a");
downloadLink.href = svgUrl;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
</script>

View File

@@ -7,4 +7,9 @@ module.exports = defineConfig({
topLevelAwait: true,
},
},
pwa: {
iconPaths: {
faviconSVG: 'img/icons/safari-pinned-tab.svg',
}
}
});