Compare commits

..

122 Commits

Author SHA1 Message Date
9d00df2438 add help blurb for registration, and other minor doc tweaks 2023-04-07 16:32:26 -06:00
8a0c3c16ed add confirmation before confirming hours, plus some other verbiage 2023-04-04 13:06:52 -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
30 changed files with 5587 additions and 1923 deletions

View File

@@ -20,10 +20,70 @@ 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 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 +182,4 @@ export const createAndStoreIdentifier = async (mnemonicPassword) => {
return importAndStoreIdentifier(mnemonic, mnemonicPassword, false, [])
}
```
```

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.

4064
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,46 +9,53 @@
},
"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",
"@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",
"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",
"@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 +64,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"
}
}

44
project.yaml Normal file
View File

@@ -0,0 +1,44 @@
- 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)
- contacts v1 :
- .2 warn about amounts when you cannot see them
- .1 add confirmation message when 'copy' succeeds
- .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
- Next Viable Product afterward
- Connect with phone contacts
- Multiple identities
- Peer DID
- DIDComm

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

@@ -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,17 @@ const routes: Array<RouteRecordRaw> = [
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
},
{
path: "/commitments",
name: "commitments",
path: "/start",
name: "start",
component: () =>
import(
/* webpackChunkName: "commitments" */ "../views/CommitmentsView.vue"
),
import(/* webpackChunkName: "start" */ "../views/StartView.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,312 @@
</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 Registration and Claim 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 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,388 @@
<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="confirmGiven(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 confirmGiven(record: GiveServerRecord) {
if (!confirm("Are you sure you want to mark this as confirmed?")) {
return;
}
// 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>

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

@@ -0,0 +1,908 @@
<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>
<!-- 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 class="tooltiptext-left">
{{
givenByMeDescriptions[contact.did]
? "Most recently: " + givenByMeDescriptions[contact.did]
: "(None given yet.)"
}}
</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 class="tooltiptext-left">
{{
givenToMeDescriptions[contact.did]
? "Most recently: " + givenToMeDescriptions[contact.did]
: "(None received yet.)"
}}
</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) {
const recipDid: string = give.recipientDid;
if (recipDid && give.unit == "HUR") {
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;
}
}
}
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 -->

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

@@ -0,0 +1,159 @@
<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-4 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 py-4">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 py-4">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 py-4">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 py-4">
How do I get registered to make claims?
</h2>
<p>
Contact a current user. They will be able to register you on their
Contacts <fa icon="users" class="fa-fw" /> screen. Note that they have a
limited number of registrations.
</p>
<h2 class="text-xl font-semibold py-4">
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="users" 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>
</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>