Compare commits

..

1 Commits

Author SHA1 Message Date
Matthew Aaron Raymer
9e4046a69d Adding class-based Vue Plugin for Vue stub 2022-12-19 12:41:04 +08:00
57 changed files with 4105 additions and 10169 deletions

View File

@@ -20,85 +20,10 @@ npm run build
npm run lint
```
### 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 /start and create or 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 Vue configuration
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## Scenarios
- Create a new identity as prompted. Go to "Your Identity" screen and copy the ID to the clipboard.
- Go back to /start and import test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` with this 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).)
- Go to "Your Contacts" screen and add the ID you copied to the clipboard, and hit "+" to add them.
- Click on the "Registration Unknown" button and register that person to be able to make claims as them.
### 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).
## Dependencies
See https://tea.xyz
| Project | Version |
| ---------- | --------- |
| nodejs.org | ^16.0.0 |
| npmjs.com | ^8.0.0 |
## Other
### Reference Material
```
// reference material from https://github.com/trentlarson/endorser-mobile/blob/8dc8e0353e0cc80ffa7ed89ded15c8b0da92726b/src/utility/idUtility.ts#L83
// Import an existing ID
export const importAndStoreIdentifier = async (mnemonic: string, mnemonicPassword: string, toLowercase: boolean, previousIdentifiers: Array<IIdentifier>) => {
@@ -197,13 +122,4 @@ export const createAndStoreIdentifier = async (mnemonicPassword) => {
return importAndStoreIdentifier(mnemonic, mnemonicPassword, false, [])
}
```
## Kudos
Gifts make the world go 'round!
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
* [Many tools & libraries]() such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
```

View File

@@ -1,45 +0,0 @@
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.

8025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,57 +9,46 @@
},
"dependencies": {
"@ethersproject/hdnode": "^5.7.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": "^3.0.0",
"@tweenjs/tween.js": "^21.0.0",
"@veramo/core": "^5.2.0",
"@veramo/credential-w3c": "^5.2.0",
"@veramo/data-store": "^5.2.0",
"@veramo/did-manager": "^5.1.2",
"@veramo/did-provider-ethr": "^5.1.2",
"@veramo/did-resolver": "^5.2.0",
"@veramo/key-manager": "^5.1.2",
"@vueuse/core": "^10.2.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",
"@zxing/text-encoding": "^0.9.0",
"axios": "^1.4.0",
"buffer": "^6.0.3",
"class-transformer": "^0.5.1",
"core-js": "^3.31.0",
"dexie": "^3.2.4",
"dexie-export-import": "^4.0.7",
"did-jwt": "^7.2.2",
"ethereum-cryptography": "^2.0.0",
"core-js": "^3.26.1",
"dexie": "^3.2.2",
"ethereum-cryptography": "^1.1.2",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.0.0",
"js-generate-password": "^0.1.9",
"localstorage-slim": "^2.4.0",
"luxon": "^3.3.0",
"merkletreejs": "^0.3.10",
"moment": "^2.29.4",
"papaparse": "^5.4.1",
"js-generate-password": "^0.1.7",
"localstorage-slim": "^2.3.0",
"luxon": "^3.1.1",
"merkletreejs": "^0.3.9",
"papaparse": "^5.3.2",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.1.0",
"qr-code-generator-vue3": "^1.4.21",
"ramda": "^0.29.0",
"readable-stream": "^4.4.0",
"pinia-plugin-persistedstate": "^3.0.1",
"ramda": "^0.28.0",
"readable-stream": "^4.2.0",
"reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2",
"three": "^0.153.0",
"vue": "^3.3.4",
"vue-axios": "^3.5.2",
"vue": "^3.2.45",
"vue-class-component": "^8.0.0-0",
"vue-facing-decorator": "^2.1.20",
"vue-property-decorator": "^9.1.2",
"vue-router": "^4.2.2",
"web-did-resolver": "^2.0.24"
"vue-router": "^4.1.6",
"web-did-resolver": "^2.0.21"
},
"devDependencies": {
"@types/ramda": "^0.29.2",
"@types/three": "^0.152.1",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"@types/ramda": "^0.28.20",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-pwa": "~5.0.8",
@@ -67,15 +56,15 @@
"@vue/cli-plugin-typescript": "~5.0.8",
"@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"@vue/eslint-config-typescript": "^11.0.2",
"autoprefixer": "^10.4.13",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.15.0",
"postcss": "^8.4.24",
"prettier": "^2.8.8",
"tailwindcss": "^3.3.2",
"typescript": "~5.1.3"
"eslint-plugin-vue": "^9.8.0",
"postcss": "^8.4.19",
"prettier": "^2.8.0",
"tailwindcss": "^3.2.4",
"typescript": "~4.9.3"
}
}

View File

@@ -1,115 +0,0 @@
tasks:
- replace user-affecting console.log & console.error with error messages (eg. catches)
- if there's no identity, handle it on pages which expect an identity (eg. project -- look for JSON.parse identity calls)
- .1 show an appropriate message when there are no contacts
- 8 Move to vue-facing-decorator
- 01 design ideas for simple gives on the first page
- .1 remove commitments from ProjectView UI
- 01 add list of 'give' records for a project on ProjectView UI
- 02 Discover page - display results (currently in console.log), spin when searching
- 08 search by location, endpoint, etc assignee:trent
- 01 add a location for a project via map pin :
- give attribute to use assignee:trent
- 01 remove all the "form" fields (or at least investigate to see if that page refresh is desired)
- 08 Scan QR code to import into contacts.
- contacts v1 :
- 01 Import contact info a la QR code.
- 01 Import all the non-sensitive data (ie. contacts & settings).
- .2 move all "identity" references to temporary account access assignee:trent
- contacts v+ :
- .2 show error to user when adding a duplicate contact
- 01 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 alert popup code a component (to cut down duplicate code; see "in many files")
- 01 Change "nav" tabs across the bottom into a component (eliminating duplicate code).
- .5 Fix how icons show on top of bottom bar on ContactAmounts page
- .2 Hide "Advanced" section in Account page by default
- show pop-up confirming that settings & contacts have been downloaded
- Ensure each action sent to the server has a confirmation - registration
- Home Feed & Quick Give screen :
- 01 save the feed-viewed status in settings storage ("afterQuery")
- 01 quick action - send action, maybe choose via canvas tool https://github.com/konvajs/vue-konva
- .5 customize favicon
- .2 Hide "Advanced" section in Account page by default
- 04 allow user to download claims, mine + ones I can see about me from others
- 24 Move to Vite
- 40 notifications :
- push
- Discuss whether the remaining tasks are worthwhile before MVP release.
- 01 fix images on project page, on discovery page
- .2 fix "Rotary" and static icon to the right on project page
- stats v1 :
- 01 show numeric stats
- 01 link to world for specific stats
- .5 don't load another instance of a bush if it already exists
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
- 4-8 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
- Do we want split first name & last name?
- remove 'about' page
- Release Minimum Viable Product :
- 08 thorough testing for errors & edge cases
- Turn off stats-world or ensure it's usable (eg. cannot zoom out too far and lose world, cannot screenshot).
- Add disclaimers.
- Rename DB to TimeSafari.
- Switch default server to the public server.
- Deploy to a server.
- Ensure public server has limits that work for group adoption.
- Test PWA features on Android and iOS.
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- 40 notifications v+ :
- pull, w/ scheduled runs
- linking between projects or plans :
- terminology:
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
- Stats :
- 01 point out user's location on the world
- 01 present a credential selected from the stats
- 04 show gives spreading to other places
- badge for most gives/receives/confirms per day/week/month
- badge for amount given/offered to your project
- set a goal of given/offers
- automated tests, eg. cypress
- Notifications (wake on the phone, push notifications)
- Connect with phone contacts
- Multiple identities
- Peer DID
- DIDComm
- Write to or read from a different ledger (eg. private ACDC, attest.sh)
log:
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 KiB

View File

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

Binary file not shown.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

View File

@@ -9,10 +9,3 @@
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

@@ -1,47 +0,0 @@
<template>
<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 { Component, Prop, Vue } from "vue-facing-decorator";
@Component
export default class AlertMessage extends Vue {
@Prop alertTitle = "";
@Prop alertMessage = "";
isAlertVisible = this.alertMessage;
public onClickClose() {
this.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>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>

View File

@@ -1,108 +0,0 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-lg text-center">
{{ message }} {{ giver?.name || "somebody not specified" }}
</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row">
<span class="py-4">Hours</span>
<input
type="text"
class="block w-8 rounded border border-slate-400 ml-4 text-center"
v-model="hours"
/>
<div class="flex flex-col px-1">
<div>
<fa icon="square-caret-up" size="2xl" @click="increment()" />
</div>
<div>
<fa icon="square-caret-down" size="2xl" @click="decrement()" />
</div>
</div>
</div>
<p class="text-right">Sign & Send to publish to the world</p>
<div class="text-right">
<button class="rounded border border-slate-400" @click="confirm">
<span class="m-2">Sign & Send</span>
</button>
&nbsp;
<button class="rounded border border-slate-400" @click="cancel">
<span class="m-2">Cancel</span>
</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: ["message"],
data() {
return {
giver: null,
description: "",
hours: "0",
visible: false,
};
},
methods: {
open(giver) {
// giver: GiverInputInfo
this.giver = giver;
this.visible = true;
},
close() {
this.visible = false;
},
increment() {
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
},
decrement() {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
},
confirm() {
this.close();
this.$emit("dialog-result", {
action: "confirm",
giver: this.giver,
hours: parseFloat(this.hours),
description: this.description,
});
this.description = "";
this.giver = null;
this.hours = "0";
},
cancel() {
this.close();
this.$emit("dialog-result", { action: "cancel" });
},
},
};
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 50%;
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<div ref="scrollContainer">
<slot />
<div ref="sentinel" style="height: 1px"></div>
</div>
</template>
<script lang="ts">
import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
@Component
export default class InfiniteScroll extends Vue {
@Prop({ default: 200 })
readonly distance!: number;
private observer!: IntersectionObserver;
private isInitialRender = true;
updated() {
if (!this.observer) {
const options = {
root: null,
rootMargin: `0px 0px ${this.distance}px 0px`,
threshold: 1.0,
};
this.observer = new IntersectionObserver(
this.handleIntersection,
options
);
this.observer.observe(this.$refs.sentinel as HTMLElement);
}
}
// 'beforeUnmount' hook runs before unmounting the component
beforeUnmount() {
if (this.observer) {
this.observer.disconnect();
}
}
@Emit("reached-bottom")
handleIntersection(entries: IntersectionObserverEntry[]) {
const entry = entries[0];
if (entry.isIntersecting) {
return true;
}
return false;
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>

View File

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

View File

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

View File

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

View File

@@ -1,251 +0,0 @@
import axios from "axios";
import * as R from "ramda";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
const ANIMATION_DURATION_SECS = 10;
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
const apiServer = settings?.apiServer;
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");
if (!identity) {
throw new Error("No identity found.");
}
const token = await accessToken(identity);
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const resp = await axios.get(url, { headers: headers });
if (resp.status === 200) {
const landmarks = resp.data.data;
const minDate = landmarks[landmarks.length - 1].issuedAt;
const maxDate = landmarks[0].issuedAt;
world.setExposedWorldProperties("startTime", minDate.replace("T", " "));
world.setExposedWorldProperties("endTime", maxDate.replace("T", " "));
const minTimeMillis = new Date(minDate).getTime();
const fullTimeMillis =
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1; // avoid divide by zero
// ratio of animation time to real time
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis;
// load plant model first because it takes a second
const loader = new GLTFLoader();
// choose the right plant
const modelLoc = "/models/lupine_plant/scene.gltf", // push with pokies
modScale = 0.1;
//const modelLoc = "/models/round_bush/scene.gltf", // green & pink
// modScale = 1;
//const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers
// modScale = 2;
//const modelLoc = "/models/a_bush/scene.gltf", // purple leaves
// modScale = 15;
// calculate positions for each claim, especially because some are random
const locations = landmarks.map((claim) =>
locForGive(claim, world.PLATFORM_SIZE, world.PLATFORM_EDGE_FOR_UNKNOWNS)
);
// eslint-disable-next-line @typescript-eslint/no-this-alias
loader.load(
modelLoc,
function (gltf) {
gltf.scene.scale.set(0, 0, 0);
for (let i = 0; i < landmarks.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts)
const claim = landmarks[i];
const newPlant = SkeletonUtils.clone(gltf.scene);
const loc = locations[i];
newPlant.position.set(loc.x, 0, loc.z);
world.scene.add(newPlant);
const timeDelayMillis =
fakeRealRatio *
(new Date(claim.issuedAt).getTime() - minTimeMillis);
new TWEEN.Tween(newPlant.scale)
.delay(timeDelayMillis)
.to({ x: modScale, y: modScale, z: modScale }, 5000)
.start();
world.bushes = [...world.bushes, newPlant];
}
},
undefined,
function (error) {
console.error(error);
}
);
// calculate when lights shine on appearing claim area
for (let i = 0; i < landmarks.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts)
const claim = landmarks[i];
const loc = locations[i];
const light = createLight();
light.position.set(loc.x, 20, loc.z);
light.target.position.set(loc.x, 0, loc.z);
loop.updatables.push(light);
scene.add(light);
scene.add(light.target);
// now figure out the timing and shine a light
const timeDelayMillis =
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis);
new TWEEN.Tween(light)
.delay(timeDelayMillis)
.to({ intensity: 100 }, 10)
.chain(
new TWEEN.Tween(light.position)
.to({ y: 5 }, 5000)
.onComplete(() => {
scene.remove(light);
light.dispose();
})
)
.start();
world.lights = [...world.lights, light];
}
} else {
console.error(
"Got bad server response status & data of",
resp.status,
resp.data
);
vue.setAlert(
"Error With Server",
"There was an error retrieving your claims from the server."
);
}
} catch (error) {
console.error("Got exception contacting server:", error);
vue.setAlert(
"Error With Server",
"There was a problem retrieving your claims from the server."
);
}
}
/**
*
* @param giveClaim
* @returns {x:float, z:float} where -50 <= x & z < 50
*/
function locForGive(giveClaim, platformWidth, borderWidth) {
let loc;
if (giveClaim?.claim?.recipient?.identifier) {
// this is directly to a person
loc = locForEthrDid(giveClaim.claim.recipient.identifier);
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
} else if (giveClaim?.object?.isPartOf?.identifier) {
// this is probably to a project
const objId = giveClaim.object.isPartOf.identifier;
if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) {
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length));
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
}
}
if (!loc) {
// it must be outside our known addresses so let's put it somewhere random on the side
const leftSide = Math.random() < 0.5;
loc = {
x: leftSide
? -platformWidth / 2 - borderWidth / 2
: platformWidth / 2 + borderWidth / 2,
z: Math.random() * platformWidth - platformWidth / 2,
};
}
return loc;
}
/**
* Generate a deterministic x & z location based on the randomness of an ID.
*
* We'd like the location to fully map back to the original ID.
* This typically means we use half the ID for the x and half for the z.
*
* ... in this case: a ULID.
* We'll use the first half (13 characters) for the x coordinate and next 13 for the z.
* We recognize that this is only 3 characters = 15 bits = 32768 unique values
* for the random part for the first half. We also recognize that those random
* bits may be shared with previous ULIDs if they were generated in the same
* millisecond, and therefore much of the evenness of the distribution depends
* on the other dimension.
*
* Also: since the first 10 characters are time-based, we're going to reverse
* the order of the characters to make the randomness more evenly distributed.
* This is reversing the order of the 5-bit characters, not each of the bits.
* Also wik: the first characters of the second half might be the same as
* previous ULIDs if they were generated in the same millisecond. So it's
* best to have that last character be the most significant bit so that there
* is a more even distribution in that dimension.
*
* @param ulid
* @returns {x: float, z: float} where 0 <= x & z < 100
*/
function locForUlid(ulid) {
const xChars = ulid.substring(0, 13).split("").reverse().join("");
const zChars = ulid.substring(13, 26).split("").reverse().join("");
// from https://github.com/ulid/javascript/blob/5e9727b527aec5b841737c395a20085c4361e971/lib/index.ts#L21
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32
// We're currently only using 1024 possible x and z values
// because the display is pretty low-fidelity at this point.
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0]);
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0]);
const x = (100 * rawX) / 1024;
const z = (100 * rawZ) / 1024;
return { x, z };
}
/**
* See locForUlid. Similar, but for ethr DIDs.
* @param did
* @returns {x: float, z: float} where 0 <= x & z < 100
*/
function locForEthrDid(did) {
// "did:ethr:0x..."
if (did.length < 51) {
return { x: 0, z: 0 };
} else {
const randomness = did.substring("did:ethr:0x".length);
// We'll use all the randomness for fully unique x & z values.
// But we'll only calculate this view with the first byte since our rendering resolution is low.
const xOff = parseInt(Number("0x" + randomness.substring(0, 2)), 10);
const x = (xOff * 100) / 256;
// ... and since we're reserving 20 bytes total for x, start z with character 20,
// again with one byte.
const zOff = parseInt(Number("0x" + randomness.substring(20, 22)), 10);
const z = (zOff * 100) / 256;
return { x, z };
}
}
function createLight() {
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0);
// eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {};
return light;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,6 @@
import BaseDexie, { Table } from "dexie";
import BaseDexie from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactsSchema } from "./tables/contacts";
import {
MASTER_SETTINGS_KEY,
Settings,
SettingsSchema,
} from "./tables/settings";
import { AppString } from "@/constants/app";
// a separate DB because the seed is super-sensitive data
type SensitiveTables = {
accounts: Table<Account>;
};
type NonsensitiveTables = {
contacts: Table<Contact>;
settings: Table<Settings>;
};
import { accountsSchema, AccountsTable } from "./tables/accounts";
/**
* In order to make the next line be acceptable, the program needs to have its linter suppress a rule:
@@ -27,44 +10,23 @@ type NonsensitiveTables = {
*
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
*/
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);
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);
/**
* Needed to enable a special webpack setting to allow *await* below:
* https://stackoverflow.com/questions/72474803/error-the-top-level-await-experiment-is-not-enabled-set-experiments-toplevelaw
*/
/**
* Create password and place password in localStorage.
*
* It's good practice to keep the data encrypted at rest, so we'll do that even
* if the secret is stored right next to the app.
*/
// create password and place password in localStorage
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (localStorage.getItem("secret") == null) {
localStorage.setItem("secret", secret);
}
//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,
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,
});
});
console.log(secret);
encrypted(db, { secretKey: secret });
db.version(1).stores(schema);

View File

@@ -1,18 +1,18 @@
import { Table } from "dexie";
export type Account = {
id?: number; // auto-generated by Dexie
dateCreated: string;
derivationPath: string;
did: string;
// stringified JSON containing underlying key material of type IIdentifier
// https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts
identity: string;
publicKeyHex: string;
id?: number;
publicKey: string;
mnemonic: string;
identity: string;
dateCreated: number;
};
export type AccountsTable = {
accounts: Table<Account>;
};
// mark encrypted field by starting with a $ character
// 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",
export const accountsSchema = {
accounts: "++id, publicKey, $mnemonic, $identity, dateCreated",
};

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ 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";
/**
*
@@ -39,12 +37,6 @@ export const newIdentifier = (
};
};
/**
*
*
* @param {string} mnemonic
* @return {*} {[string, string, string, string]}
*/
export const deriveAddress = (
mnemonic: string
): [string, string, string, string] => {
@@ -65,86 +57,9 @@ export const deriveAddress = (
*
* @return {*} {string}
*/
export const generateSeed = (): string => {
export const createIdentifier = (): 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

@@ -1,195 +0,0 @@
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt";
import { Axios, AxiosResponse } from "axios";
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 GiverInputInfo {
did?: string;
name?: string;
}
export interface ClaimResult {
success: { claimId: string; handleId: string };
error: { code: string; message: string };
}
export interface GenericClaim {
"@context": string;
"@type": string;
issuedAt: string;
// "any" because arbitrary objects can be subject of agreement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
claim: 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;
fulfills?: { "@type": string; identifier: 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 };
}
export interface InternalError {
error: string; // for system logging
userMessage?: string; // for user display
}
// This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN";
export function isHiddenDid(did) {
return did === HIDDEN_DID;
}
/**
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
**/
export function didInfo(did, identifiers, contacts) {
const myId = R.find((i) => i.did === did, identifiers);
if (myId) {
return "You";
} else {
const contact = R.find((c) => c.did === did, contacts);
if (contact) {
return contact.name || "Someone Unnamed in Contacts";
} else if (!did) {
return "Unpecified Person";
} else if (isHiddenDid(did)) {
return "Someone Not In Network";
} else {
return "Someone Not In Contacts";
}
}
}
/**
* For result, see https://endorser.ch:3000/api-docs/#/claims/post_api_v2_claim
*
* @param identity
* @param fromDid may be null
* @param toDid
* @param description may be null; should have this or hours
* @param hours may be null; should have this or description
*/
export async function createAndSubmitGive(
axios: Axios,
apiServer: string,
identity: IIdentifier,
fromDid: string,
toDid: string,
description: string,
hours: number,
fulfillsProjectHandleId?: string
): Promise<AxiosResponse<ClaimResult> | InternalError> {
// Make a claim
const vcClaim: GiveVerifiableCredential = {
"@context": "https://schema.org",
"@type": "GiveAction",
recipient: { identifier: toDid },
};
if (fromDid) {
vcClaim.agent = { identifier: fromDid };
}
if (description) {
vcClaim.description = description;
}
if (hours) {
vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" };
}
if (fulfillsProjectHandleId) {
vcClaim.fulfills = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
// 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) {
return new Promise<InternalError>((resolve, reject) => {
reject({
error: "No private key",
message:
"Your identifier " +
identity.did +
" is not configured correctly. Use a different identifier.",
});
});
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = apiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return axios.post(url, payload, { headers });
}
// from https://stackoverflow.com/a/175787/845494
//
export function isNumeric(str: string): boolean {
return !isNaN(+str);
}
export function numberOrZero(str: string): number {
return isNumeric(str) ? +str : 0;
}

100
src/libs/veramo/appSlice.ts Normal file
View File

@@ -0,0 +1,100 @@
/* import * as R from "ramda";
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { IIdentifier } from "@veramo/core";
import { Contact } from "../entity/contact";
import { Settings } from "../entity/settings";
import * as utility from "../utility/utility";
const MAX_LOG_LENGTH = 2000000;
export const DEFAULT_ENDORSER_API_SERVER = "https://endorser.ch:3000";
export const DEFAULT_ENDORSER_VIEW_SERVER = "https://endorser.ch";
export const LOCAL_ENDORSER_API_SERVER = "http://127.0.0.1:3000";
export const LOCAL_ENDORSER_VIEW_SERVER = "http://127.0.0.1:3001";
export const TEST_ENDORSER_API_SERVER = "https://test.endorser.ch:8000";
export const TEST_ENDORSER_VIEW_SERVER = "https://test.endorser.ch:8080";
// for contents set in reducers
interface Payload<T> {
type: string;
payload: T;
}
interface LogMsg {
log: boolean;
msg: string;
}
export const appSlice = createSlice({
name: "app",
initialState: {
// This is nullable because it is cached state from the DB...
// it'll be null if we haven't even loaded from the DB yet.
settings: null as Settings,
// This is nullable because it is cached state from the DB...
// it'll be null if we haven't even loaded from the DB yet.
identifiers: null as Array<IIdentifier> | null,
// This is nullable because it is cached state from the DB...
// it'll be null if we haven't even loaded from the DB yet.
contacts: null as Array<Contact> | null,
viewServer: DEFAULT_ENDORSER_VIEW_SERVER,
logMessage: "",
advancedMode: false,
testMode: false,
},
reducers: {
addIdentifier: (state, contents: Payload<IIdentifier>) => {
state.identifiers = state.identifiers.concat([contents.payload]);
},
addLog: (state, contents: Payload<LogMsg>) => {
if (state.logMessage.length > MAX_LOG_LENGTH) {
state.logMessage =
"<truncated>\n..." +
state.logMessage.substring(
state.logMessage.length - MAX_LOG_LENGTH / 2
);
}
if (contents.payload.log) {
console.log(contents.payload.msg);
state.logMessage += "\n" + contents.payload.msg;
}
},
setAdvancedMode: (state, contents: Payload<boolean>) => {
state.advancedMode = contents.payload;
},
setContacts: (state, contents: Payload<Array<Contact>>) => {
state.contacts = contents.payload;
},
setContact: (state, contents: Payload<Contact>) => {
const index = R.findIndex(
(c) => c.did === contents.payload.did,
state.contacts
);
state.contacts[index] = contents.payload;
},
setHomeScreen: (state, contents: Payload<string>) => {
state.settings.homeScreen = contents.payload;
},
setIdentifiers: (state, contents: Payload<Array<IIdentifier>>) => {
state.identifiers = contents.payload;
},
setSettings: (state, contents: Payload<Settings>) => {
state.settings = contents.payload;
},
setTestMode: (state, contents: Payload<boolean>) => {
state.testMode = contents.payload;
},
setViewServer: (state, contents: Payload<string>) => {
state.viewServer = contents.payload;
},
},
});
export const appStore = configureStore({ reducer: appSlice.reducer });
*/

View File

@@ -1,97 +1,55 @@
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 {
faBurst,
faCalendar,
faChevronLeft,
faCircle,
faCircleCheck,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faCopy,
faEllipsisVertical,
faEye,
faEyeSlash,
faFileLines,
faFloppyDisk,
faFolderOpen,
faGift,
faHand,
faHouseChimney,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faRotate,
faFolderOpen,
faHand,
faCircleUser,
faCopy,
faShareNodes,
faSpinner,
faSquareCaretDown,
faSquareCaretUp,
faTrashCan,
faQrcode,
faUser,
faUsers,
faXmark,
faPen,
faTrashCan,
faCalendar,
faEllipsisVertical,
faSpinner,
faCircleCheck,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faBurst,
faCalendar,
faChevronLeft,
faCircle,
faCircleCheck,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faCopy,
faEllipsisVertical,
faEye,
faEyeSlash,
faFileLines,
faFloppyDisk,
faFolderOpen,
faGift,
faHand,
faHouseChimney,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faRotate,
faFolderOpen,
faHand,
faCircleUser,
faCopy,
faShareNodes,
faSpinner,
faSquareCaretDown,
faSquareCaretUp,
faTrashCan,
faQrcode,
faUser,
faUsers,
faXmark
faPen,
faTrashCan,
faCalendar,
faEllipsisVertical,
faSpinner,
faCircleCheck
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
createApp(App)
.component("fa", FontAwesomeIcon)
.use(VueDexiePlugin())
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.mount("#app");

View File

@@ -0,0 +1,18 @@
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,42 +1,33 @@
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { accountsDB } from "@/db";
/**
*
* @param to :RouteLocationNormalized
* @param from :RouteLocationNormalized
* @param next :NavigationGuardNext
*/
const enterOrStart = async (to, from, next) => {
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
next();
} else {
next({ name: "start" });
}
};
import { useAppStore } from "../store/app";
import HomeView from "../views/HomeView.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: () =>
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
beforeEnter: enterOrStart,
component: HomeView,
},
{
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",
component: () =>
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/confirm-contact",
@@ -46,29 +37,6 @@ 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: "/contact-qr",
name: "contact-qr",
component: () =>
import(
/* webpackChunkName: "contact-qr" */ "../views/ContactQRScanShowView.vue"
),
},
{
path: "/contacts",
name: "contacts",
component: () =>
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/scan-contact",
name: "scan-contact",
@@ -83,12 +51,6 @@ 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",
@@ -121,14 +83,6 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue"
),
},
{
path: "/new-identifier",
name: "new-identifier",
component: () =>
import(
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
),
},
{
path: "/project",
name: "project",
@@ -140,36 +94,48 @@ const routes: Array<RouteRecordRaw> = [
name: "projects",
component: () =>
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/seed-backup",
name: "seed-backup",
path: "/commitments",
name: "commitments",
component: () =>
import(
/* webpackChunkName: "seed-backup" */ "../views/SeedBackupView.vue"
),
},
{
path: "/start",
name: "start",
component: () =>
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
},
{
path: "/statistics",
name: "statistics",
component: () =>
import(
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
/* webpackChunkName: "commitments" */ "../views/CommitmentsView.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;

22
src/store/account.ts Normal file
View File

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

View File

@@ -1,61 +0,0 @@
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 =
settings?.apiServer || AppString.TEST_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 z-50">
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
<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>
<!-- Contacts -->
<!-- Commitments -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
:to="{ name: 'commitments' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
<fa icon="hand" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
@@ -54,128 +54,91 @@
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>
<!-- Registration notice -->
<!-- We won't show any loading indicator; we'll just pop the message in once we know they need it. -->
<!-- Friend referral requirement notice -->
<div
v-if="!loadingLimits && !limits?.nextWeekBeginDateTime"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
>
<p class="mb-4">
<b>Note:</b> Before you can publicly announce a new project or time
commitment, a friend needs to register you.
<b>Important:</b> before you can create a new project or commit time to
one, you need a friend to register you.
</p>
<router-link
:to="{ name: 'contact-qr' }"
<button
id="btnShowQR"
class="inline-block text-md uppercase bg-amber-600 text-white px-4 py-2 rounded-md"
>
Share Your Info
</router-link>
Share Your ID
</button>
</div>
<!-- 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-start items-center mb-1">
<code class="truncate">{{ activeDid }}</code>
<button
@click="
doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied!</span>
<span class="whitespace-nowrap ml-4">
<router-link
:to="{ name: 'contact-qr' }"
<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>
<button
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md"
>
<fa icon="share-nodes" class="fa-fw"></fa>
</button>
<button
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md ml-1"
>
<fa icon="qrcode" class="fa-fw"></fa>
</router-link>
</button>
</span>
</div>
<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="
doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showB64Copy">Copied!</span>
</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="
doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubCopy">Copied!</span>
<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>
<div class="text-slate-500 text-sm font-bold">Derivation Path</div>
<div class="text-sm text-slate-500 flex justify-start items-center mb-1">
<code class="truncate">{{ derivationPath }}</code>
<button
@click="
doCopyTwoSecRedo(derivationPath, () => (showDerCopy = !showDerCopy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDerCopy">Copied!</span>
<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>
</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">Data</h3>
<h3 class="text-sm uppercase font-semibold mb-3">Contact Actions</h3>
<router-link
:to="{ name: 'seed-backup' }"
<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>
<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
</router-link>
<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"
@click="exportDatabase()"
>Backup Other Data</a
>
Download Settings & Contacts (excluding Identifier Data)
</a>
<a ref="downloadLink" />
<!-- QR code popup -->
<dialog id="dlgQR" class="backdrop:bg-black/75 rounded-md">
@@ -183,7 +146,7 @@
<div class="text-slate-500 text-center">
<b>ID:</b> <code>did:peer:kl45kj41lk451kl3</code>
</div>
<img src="/img/sample-qr-code.png" class="w-full mb-3" />
<img src="img/sample-qr-code.png" class="w-full mb-3" />
<button
value="cancel"
@@ -199,340 +162,91 @@
</button>
</form>
</dialog>
<h3 class="text-sm uppercase font-semibold mb-3">Advanced</h3>
<label
for="toggleShowAmounts"
class="flex items-center cursor-pointer mb-6"
@click="handleChange"
>
<!-- toggle -->
<div class="relative">
<!-- input -->
<input
type="checkbox"
v-model="showContactGives"
name="showContactGives"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
<!-- label -->
<div class="ml-2">Show amounts given with contacts</div>
</label>
<div class="flex py-2">
<button class="text-center text-md text-blue-500" @click="checkLimits()">
Check Limits
</button>
<!-- show spinner if loading limits -->
<div v-if="loadingLimits" class="ml-2">
Checking... <fa icon="spinner" class="fa-spin"></fa>
</div>
<div class="ml-2">
{{ limitsMessage }}
</div>
<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 class="flex py-2">
Claim Server
<input
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
v-model="apiServerInput"
/>
<button
v-if="apiServerInput != apiServer"
class="px-4 rounded bg-red-500 border border-slate-400"
@click="onClickSaveApiServer()"
>
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
</button>
<button
class="px-4 rounded bg-slate-200 border border-slate-400"
@click="setApiServerInput(Constants.PROD_ENDORSER_API_SERVER)"
>
Use Prod
</button>
<button
class="px-4 rounded bg-slate-200 border border-slate-400"
@click="setApiServerInput(Constants.TEST_ENDORSER_API_SERVER)"
>
Use Test
</button>
<button
class="px-4 rounded bg-slate-200 border border-slate-400"
@click="setApiServerInput(Constants.LOCAL_ENDORSER_API_SERVER)"
>
Use Local
</button>
</div>
<div v-if="numAccounts > 0" class="flex py-2">
Switch Identifier
<span v-for="accountNum in numAccounts" :key="accountNum">
<button class="text-blue-500 px-2" @click="switchAccount(accountNum)">
#{{ accountNum }}
</button>
</span>
</div>
<div>
<button class="text-blue-500">
<router-link
:to="{ name: 'statistics' }"
class="block text-center py-3"
>
See Achievements & Statistics
</router-link>
</button>
</div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import "dexie-export-import";
import { Options, Vue } from "vue-class-component";
import { createIdentifier, deriveAddress, newIdentifier } from "../libs/crypto";
import { IIdentifier } from "@veramo/core";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { db } from "../db";
import { AppString } from "@/constants/app";
import { db, accountsDB } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { AxiosError } from "axios/index";
import AlertMessage from "@/components/AlertMessage";
// 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({ components: { AlertMessage } })
@Options({
components: {},
})
export default class AccountViewView extends Vue {
Constants = AppString;
activeDid = "";
apiServer = "";
apiServerInput = "";
derivationPath = "";
firstName = "";
lastName = "";
numAccounts = 0;
mnemonic = "";
address = "";
privateHex = "";
publicHex = "";
publicBase64 = "";
limits: RateLimits | null = null;
limitsMessage = "";
loadingLimits = true; // might as well now that we do it on mount, to avoid flashing the registration message
showContactGives = false;
showDidCopy = false;
showDerCopy = false;
showB64Copy = false;
showPubCopy = false;
// call fn, copy text to the clipboard, then redo fn after 2 seconds
doCopyTwoSecRedo(text, fn) {
fn();
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
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
UPORT_ROOT_DERIVATION_PATH = "";
async created() {
// Uncomment this 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();
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.firstName = settings?.firstName || "";
this.lastName = settings?.lastName || "";
this.showContactGives = !!settings?.showContactGivesInline;
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
this.numAccounts = accounts.length;
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
this.publicHex = identity.keys[0].publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.derivationPath = identity.keys[0].meta.derivationPath;
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
});
this.checkLimits();
} catch (err) {
this.alertMessage =
"Clear your cache and start over (after data backup).";
console.error("Telling user to clear cache at page create because:", err);
this.alertTitle = "Error Creating Account";
}
}
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).";
console.error(
"Telling user to clear cache after contact setting update because:",
err
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
);
this.alertTitle = "Error Updating Contact Setting";
}
}
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.";
} catch (error) {
this.alertTitle = "Export Error";
this.alertMessage = "See console logs for more info.";
console.error("Export Error:", error);
}
}
async checkLimits() {
this.loadingLimits = true;
this.limitsMessage = "";
const url = this.apiServer + "/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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
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;
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();
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("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;
this.limitsMessage = data?.error?.message || "Bad server response.";
}
this.loadingLimits = false;
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,
identity: JSON.stringify(newId),
dateCreated: new Date().getTime(),
});
}
const accounts = await db.accounts.toArray();
console.log(accounts[0]);
const identity = JSON.parse(accounts[0].identity);
this.address = identity.did;
this.publicHex = identity.keys[0].publicKeyHex;
//appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... created new ID..."}))
//appStore.dispatch(appSlice.actions.addLog({log: false, msg: "... stored new ID..."}))
} catch (err) {
console.log("Error!");
console.log(err);
}
}
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,
};
}
async onClickSaveApiServer() {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
apiServer: this.apiServerInput,
});
this.apiServer = this.apiServerInput;
}
setApiServerInput(value) {
this.apiServerInput = value;
}
// This same popup code is in many files.
alertMessage = "";
alertTitle = "";
}
</script>

View File

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

View File

@@ -1,359 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
><fa icon="circle-user" class="fa-fw"></fa
></router-link>
</li>
</ul>
</nav>
<section id="Content" class="p-6 pb-24">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Given with {{ contact?.name }}
</h1>
<!-- Results List -->
<div>
<div class="border-b border-slate-300 flex">
<div class="w-1/4"></div>
<div class="w-1/4">from them</div>
<div class="w-1/4"></div>
<div class="w-1/4">to them</div>
</div>
<div
class="border-b border-slate-300 flex"
v-for="record in giveRecords"
:key="record.id"
>
<div class="w-1/4">
{{ new Date(record.issuedAt).toLocaleString() }}
</div>
<div class="w-1/4">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" class="tooltip">
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
<span class="tooltiptext">Confirmed</span>
</span>
<button v-else class="tooltip" @click="confirm(record)">
<fa icon="circle" class="text-blue-600 fa-fw ml-1" />
<span class="tooltiptext">Unconfirmed</span>
</button>
</div>
<br />
{{ record.description }}
</span>
</div>
<div class="w-1/8">
<span v-if="record.agentDid == contact.did">
<fa icon="long-arrow-alt-left" class="text-slate-900 fa-fw ml-1" />
</span>
<span v-else>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<fa icon="long-arrow-alt-right" class="text-slate-900 fa-fw ml-1" />
</span>
</div>
<div class="w-1/4">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
<span v-if="record.amountConfirmed" class="tooltip">
<fa icon="circle-check" class="text-green-600 fa-fw ml-1" />
<span class="tooltiptext">Confirmed</span>
</span>
<button v-else class="tooltip" @click="cannotConfirmMessage()">
<fa icon="circle" class="text-slate-600 fa-fw ml-1" />
<span class="tooltiptext">Unconfirmed</span>
</button>
</div>
<br />
{{ record.description }}
</span>
</div>
</div>
</div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
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 {
AgreeVerifiableCredential,
GiveServerRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer";
import * as didJwt from "did-jwt";
import { AxiosError } from "axios";
import AlertMessage from "@/components/AlertMessage";
@Component({ components: { AlertMessage } })
export default class ContactsView extends Vue {
activeDid = "";
apiServer = "";
contact: Contact | null = null;
giveRecords: Array<GiveServerRecord> = [];
alertTitle = "";
alertMessage = "";
// 'created' hook runs when the Vue instance is first created
async created() {
try {
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 || "";
this.apiServer = settings?.apiServer || "";
if (this.activeDid && this.contact) {
this.loadGives(this.activeDid, this.contact);
}
} catch (err) {
this.alertTitle = "Error";
this.alertMessage =
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.";
}
}
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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
// load all the time I have given to them
try {
let result = [];
const url =
this.apiServer +
"/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.error(
"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.";
}
const url2 =
this.apiServer +
"/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.error(
"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.";
}
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;
}
}
async confirm(record: GiveServerRecord) {
// Make claim
// I use clone here because otherwise it gets a Proxy object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const origClaim: GiveVerifiableCredential = R.clone(record.fullClaim);
if (record.fullClaim["@context"] == SCHEMA_ORG_CONTEXT) {
delete origClaim["@context"];
}
origClaim["identifier"] = record.handleId;
const vcClaim: AgreeVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "AgreeAction",
object: origClaim,
};
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
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 url = this.apiServer + "/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;
}
}
}
cannotConfirmMessage() {
this.alertTitle = "Not Allowed";
this.alertMessage = "Only the recipient can confirm final receipt.";
}
}
</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>

View File

@@ -1,145 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Contact Info
</h1>
<!--
Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
-->
<QRCodeVue3
:value="this.qrValue"
:cornersSquareOptions="{ type: 'extra-rounded' }"
:dotsOptions="{ type: 'square' }"
class="flex justify-center"
/>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as R from "ramda";
import { SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt";
import AlertMessage from "@/components/AlertMessage";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
@Component({
components: {
QRCodeVue3,
AlertMessage,
},
})
export default class ContactQRScanShow extends Vue {
activeDid = "";
apiServer = "";
qrValue = "";
// '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.apiServer = settings?.apiServer || "";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
if (!account) {
this.alertMessage = "You have no identity yet.";
} else {
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
const publicKeyHex = identity.keys[0].publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
const contactInfo = {
iat: Date.now(),
iss: this.activeDid,
own: {
name: (settings?.firstName || "") + " " + (settings?.lastName || ""),
publicEncKey,
},
};
const alg = undefined;
const privateKeyHex: string = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex);
// create a JWT for the request
const vcJwt: string = await didJwt.createJWT(contactInfo, {
alg: alg,
issuer: identity.did,
signer: signer,
});
const viewPrefix = "https://endorser.ch/contact?jwt=";
this.qrValue = viewPrefix + vcJwt;
}
}
// This same popup code is in many files.
alertTitle = "";
alertMessage = "";
}
</script>

View File

@@ -1,888 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
><fa icon="users" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
><fa icon="circle-user" class="fa-fw"></fa
></router-link>
</li>
</ul>
</nav>
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Contacts
</h1>
<div class="flex justify-between py-2">
<span />
<span>
<router-link
:to="{ name: 'help' }"
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
>
Help
</router-link>
</span>
</div>
<!-- New Contact -->
<div class="mb-4 flex">
<input
type="text"
placeholder="DID, Name, Public Key"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
v-model="contactInput"
/>
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="onClickNewContact()"
>
<fa icon="plus" class="fa-fw"></fa>
</button>
</div>
<div class="flex justify-between" v-if="showGiveNumbers">
<div class="w-full text-right">
Hours to Add:
<input
class="border border rounded border-slate-400 w-24 text-right"
type="text"
placeholder="1"
v-model="hourInput"
/>
<br />
<input
class="border border rounded border-slate-400 w-48"
type="text"
placeholder="Description"
v-model="hourDescriptionInput"
/>
<br />
<br />
<button
href=""
class="text-center text-md text-white px-1.5 py-2 rounded-md mb-6"
v-bind:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
>
{{
showGiveTotals
? "Total"
: showGiveConfirmed
? "Confirmed"
: "Unconfirmed"
}}
</button>
</div>
</div>
<!-- Results List -->
<ul class="">
<li
class="border-b border-slate-300"
v-for="contact in contacts"
:key="contact.did"
>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
{{ contact.name || "(no name)" }}
</h2>
<div class="text-sm truncate">{{ contact.did }}</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64): {{ contact.publicKeyBase64 }}
</div>
<button
v-if="contact.seesMe"
class="tooltip"
@click="setVisibility(contact, false)"
>
<fa icon="eye" class="text-slate-900 fa-fw ml-1" />
<span class="tooltiptext">They can see you</span>
</button>
<button v-else class="tooltip" @click="setVisibility(contact, true)">
<span class="tooltiptext">They cannot see you</span>
<fa icon="eye-slash" class="text-slate-900 fa-fw ml-1" />
</button>
<button class="tooltip" @click="checkVisibility(contact)">
<span class="tooltiptext">Check Visibility</span>
<fa icon="rotate" class="text-slate-900 fa-fw ml-1" />
</button>
<button v-if="contact.registered" class="tooltip">
<span class="tooltiptext">Registered</span>
<fa icon="person-circle-check" class="text-slate-900 fa-fw ml-1" />
</button>
<button v-else @click="register(contact)" class="tooltip">
<span class="tooltiptext">Registration Unknown</span>
<fa
icon="person-circle-question"
class="text-slate-900 fa-fw ml-1"
/>
</button>
<button @click="deleteContact(contact)" class="px-9 tooltip">
<span class="tooltiptext">Delete!</span>
<fa icon="trash-can" class="text-red-600 fa-fw ml-1" />
</button>
<div v-if="showGiveNumbers" class="float-right">
<div class="float-right">
<div class="tooltip">
to:
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenByMeConfirmed[contact.did] || 0)
+ (givenByMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenByMeConfirmed[contact.did] || 0)
: (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<span
v-if="givenByMeDescriptions[contact.did]"
class="tooltiptext-left"
>
{{ givenByMeDescriptions[contact.did] }}
</span>
<button
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
@click="onClickAddGive(activeDid, contact.did)"
>
+
</button>
</div>
<div class="tooltip px-2">
from:
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
? ((givenToMeConfirmed[contact.did] || 0)
+ (givenToMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
? (givenToMeConfirmed[contact.did] || 0)
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
}}
<span
v-if="givenToMeDescriptions[contact.did]"
class="tooltiptext-left"
>
{{ givenToMeDescriptions[contact.did] }}
</span>
<button
class="text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-6"
@click="onClickAddGive(contact.did, activeDid)"
>
+
</button>
</div>
<router-link
:to="{
name: 'contact-amounts',
query: { contactDid: contact.did },
}"
class="tooltip"
>
<fa icon="file-lines" class="text-slate-600 fa-fw ml-1" />
<span class="tooltiptext-left">See All Given Activity</span>
</router-link>
</div>
</div>
</div>
</li>
</ul>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</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 { Component, Vue } from "vue-facing-decorator";
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;
@Component({
components: { AlertMessage },
})
export default class ContactsView extends Vue {
activeDid = "";
apiServer = "";
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.apiServer = settings?.apiServer || "";
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 || "null");
if (!identity) {
console.error(
"Attempted to load Give records with no identity available."
);
return;
}
// load all the time I have given
try {
const url =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(identity.did);
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
const resp = await this.axios.get(url, { headers });
//console.log("All gifts you've given:", resp.data);
if (resp.status === 200) {
const contactDescriptions: Record<string, string> = {};
const contactConfirmed: Record<string, number> = {};
const contactUnconfirmed: Record<string, number> = {};
const allData: Array<GiveServerRecord> = resp.data.data;
for (const give of allData) {
if (give.unit == "HUR") {
const recipDid: string = give.recipientDid;
if (give.amountConfirmed) {
const prevAmount = contactConfirmed[recipDid] || 0;
contactConfirmed[recipDid] = prevAmount + give.amount;
} else {
const prevAmount = contactUnconfirmed[recipDid] || 0;
contactUnconfirmed[recipDid] = prevAmount + give.amount;
}
if (!contactDescriptions[recipDid] && give.description) {
// Since many make the tooltip too big, we'll just use the latest.
contactDescriptions[recipDid] = give.description;
}
}
}
//console.log("Done retrieving gives", contactConfirmed);
this.givenByMeDescriptions = contactDescriptions;
this.givenByMeConfirmed = contactConfirmed;
this.givenByMeUnconfirmed = contactUnconfirmed;
} else {
console.error(
"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.";
}
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
}
// load all the time I have received
try {
const url =
this.apiServer +
"/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.error(
"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.";
}
} catch (error) {
this.alertTitle = "Error With Server";
this.alertMessage = error as string;
}
}
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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
// Make a claim
const vcClaim: RegisterVerifiableCredential = {
"@context": "https://schema.org",
"@type": "RegisterAction",
agent: { identifier: identity.did },
object: SERVICE_ID,
participant: { identifier: contact.did },
};
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
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 url = this.apiServer + "/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?.embeddedRecordError) {
this.alertTitle = "Registration Still Unknown";
let message = "There was some problem with the registration.";
if (typeof resp.data.success.embeddedRecordError == "string") {
message += " " + resp.data.success.embeddedRecordError;
}
this.alertMessage = message;
} else 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.";
}
} 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;
}
}
}
}
async setVisibility(contact: Contact, visibility: boolean) {
const url =
this.apiServer +
"/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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
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.error("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;
}
}
} catch (err) {
this.alertTitle = "Error With Server";
this.alertMessage = err as string;
}
}
async checkVisibility(contact: Contact) {
const url =
this.apiServer +
"/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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
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.";
} 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;
}
}
} catch (err) {
this.alertTitle = "Error With Server";
this.alertMessage = err as string;
}
}
// 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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
// 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;
} else if (!parseFloat(this.hourInput)) {
this.alertTitle = "Input Error";
this.alertMessage = "Giving 0 hours does nothing.";
} else if (!identity) {
this.alertTitle = "Status Error";
this.alertMessage = "No identity is available.";
} 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 url = this.apiServer + "/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.";
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;
}
}
}
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;
}
}
// This same popup code is in many files.
alertTitle = "";
alertMessage = "";
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 z-50">
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
@@ -19,17 +19,15 @@
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
:to="{ name: 'project' }"
class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
</li>
<!-- Contacts -->
<!-- Commitments -->
<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 :to="{ name: '' }" class="block text-center py-3 px-1"
><fa icon="hand" class="fa-fw"></fa
></router-link>
</li>
<!-- Profile -->
@@ -51,20 +49,18 @@
</h1>
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="search()">
<form id="QuickSearch" class="mb-4 flex">
<input
type="text"
v-model="searchTerms"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
/>
<button
@click="search()"
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</button>
</div>
</form>
<!-- Result Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300">
@@ -153,85 +149,14 @@
</a>
</li>
</ul>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Options, Vue } from "vue-class-component";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as R from "ramda";
import { accessToken } from "@/libs/crypto";
import AlertMessage from "@/components/AlertMessage";
@Component({
components: { AlertMessage },
@Options({
components: {},
})
export default class DiscoverView extends Vue {
activeDid = "";
apiServer = "";
searchTerms = "";
// 'mounted' hook runs after initial render
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
}
public async search() {
const headers = { "Content-Type": "application/json" };
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, allAccounts);
//console.log("about to parse from", this.activeDid, account?.identity);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
} else {
// it's OK without auth... we just won't get any identifiers
}
const claimContents =
"claimContents=" + encodeURIComponent(this.searchTerms);
const claimType = "claimType=PlanAction";
const queryParams = [claimContents, claimType].join("&");
return fetch(this.apiServer + "/api/v2/report/claims?" + queryParams, {
method: "GET",
headers: headers,
})
.then(async (response) => {
if (response.status !== 200) {
const details = await response.text();
throw details;
}
return response.json();
})
.then((results) => {
if (results.data) {
console.log(results.data);
} else {
throw JSON.stringify(results);
}
})
.catch((e) => {
console.log("Error with feed load:", e);
this.alertMessage =
e.userMessage || "There was an error retrieving projects.";
this.alertTitle = "Error";
});
}
alertMessage = "";
alertTitle = "";
}
export default class DiscoverView extends Vue {}
</script>

View File

@@ -1,236 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-400">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Help
</h1>
<div>
<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">What is the philosophy here?</h2>
<p>
We are building networks of people who want to grow a gifting society.
First of all, you can record ways you've seen people give, and that
leaves a permanent record -- one that came from you, and the recipient
can prove it was for them. This is personally gratifying, but it extends
to broader work: volunteers can get confirmation of activity and
selectively show off their contributions and network.
</p>
<p>
You can also record projects and plans and invite others to collaborate.
Soon you'll be able to see when others are interested and see how much
they're willing to contribute, even if there are conditions.
</p>
<p>
This app uses the power of cryptography to build a reputation, recording
activity that you can share at your discretion. You put some activity
public, but your sensitive information is not shared with anyone,
including our services. This is in contrast to Meta and Google, who hold
your data and allow you use it. Those services are useful, but they have
the control; this app gives you the control.
</p>
<h2 class="text-xl font-semibold">How do I take my first action?</h2>
<p>
You need someone to register you -- usually the person who told you
about this app, on the Contacts
<fa icon="circle-user" class="fa-fw" /> page. After they register you,
and after you have contacts, you can select any contact on the home page
and record your appreciation for... whatever. That is a claim recorded
on a custom ledger. The day after being registered, you'll be able to
register others; later, you can create projects, too.
</p>
<p>
Note that there are limits to how many each person can register, so you
may have to wait.
</p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p>
There are two sets of data to backup: the identifier secrets and the
other data that isn't quite a secret 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>
<li>
If you have other identifiers, switch to each one and repeat those
steps.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data?
</h2>
<ul class="list-disc list-inside">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
</li>
<li>
Click on "Download Settings...". That will save a file to your
downloads folder. That is your backup, so put it someplace where you
won't lose it.
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
<p>
There are two parts to restore your data: the identity secrets and the
other data such as settings, contacts, etc.
</p>
<div class="px-4">
<h2 class="text-xl font-semibold">
How do I restore my identifier (secret) data?
</h2>
<ul class="list-disc list-inside">
<li>
<router-link class="text-blue-500" to="/import-account">
Go to the import page
</router-link>
and enter the seed phrase you backed up.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I restore my other (non-identifier-secret) data?
</h2>
<ul class="list-disc list-inside">
<li>Make sure you have your backup file (above), then contact us.</li>
</ul>
</div>
<h2 class="text-xl font-semibold">
How do I add someone to my contacts?
</h2>
<p>
Tell them to copy their ID, which typically starts with "did:ethr:...",
and send it to you. Go to the Contacts
<fa icon="circle-user" class="fa-fw" /> page and enter that into the top
form. You may add a name by adding a comma followed by their name; you
may also add their public key by adding another comma followed by the
key.
</p>
<h2 class="text-xl font-semibold">
I know there is a record from someone, so why can't I see that info?
</h2>
<p>
If you don't see anything associated with a person, this is typically
because they have not given you permission to see their information. Ask
them to add you to their contact list and make sure the eye next to your
name is open like this
<fa icon="eye" class="fa-fw" /> and not closed like this
<fa icon="eye-slash" class="fa-fw" />.
</p>
<p>
Sometimes the reason you don't see something is because the search time
is limited. Go to the bottom and make sure to load all the data on a
list. If you still don't see it, try a search or view on a different
page.
</p>
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p>
Go
<router-link to="start" class="text-blue-500">
create another identity here.
</router-link>
</p>
<h2 class="text-xl font-semibold">What is your privacy policy?</h2>
<p>
See
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
the Endorser Service Privacy Policy.
</a>
</p>
<h2 class="text-xl font-semibold">What app version is this?</h2>
<p>
{{ package.version }}
</p>
<h2 class="text-xl font-semibold">
For any other questions, including remove your data:
</h2>
<p>
Contact us through
<a href="https://communitycred.org">CommunityCred.org</a>.
</p>
</div>
</section>
</template>
<script lang="ts">
import * as Package from "../../package.json";
import { Component, Vue } from "vue-facing-decorator";
@Component
export default class Help extends Vue {
package = Package;
}
</script>

View File

@@ -1,341 +1,15 @@
<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 bg-slate-400 text-white">
<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-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>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Time Safari
</h1>
<div class="mb-8">
<h1 class="text-2xl">Quick Action</h1>
<p>Choose a contact to whom to show appreciation:</p>
<!-- similar contact selection code is in multiple places -->
<div class="px-4">
<button
v-for="contact in allContacts"
:key="contact.did"
@click="openDialog(contact)"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
>
{{ contact.name }}
</button>
<span v-if="allContacts.length > 0">&nbsp;or&nbsp;</span>
<button @click="openDialog()" class="text-blue-500">
someone not specified
</button>
</div>
</div>
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
message="Received from"
>
</GiftedDialog>
<div>
<h1 class="text-2xl">Latest Activity</h1>
<span :class="{ hidden: isHiddenSpinner }">
<fa icon="spinner" class="fa-spin-pulse"></fa>
Loading&hellip;
</span>
<ul>
<li
class="border-b border-slate-300 py-2"
v-for="record in feedData"
:key="record.jwtId"
>
<div
class="border-b border-dashed border-slate-400 text-orange-400 py-2 mb-2 font-bold uppercase text-sm"
v-if="record.jwtId == feedLastViewedId"
>
You've seen all claims below:
</div>
<div class="flex">
<fa
icon="gift"
class="fa-fw flex-none pt-1 pr-2 text-slate-500"
></fa>
<!-- icon values: "coins" = money; "clock" = time; "gift" = others -->
<span class="">{{ this.giveDescription(record) }}</span>
</div>
</li>
</ul>
</div>
</section>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
<section></section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Options, Vue } from "vue-class-component";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import AlertMessage from "@/components/AlertMessage";
import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src
@Options({
components: { GiftedDialog, AlertMessage },
components: {
HelloWorld,
},
})
export default class HomeView extends Vue {
activeDid = "";
allAccounts: Array<Account> = [];
allContacts: Array<Contact> = [];
apiServer = "";
feedAllLoaded = false;
feedData = [];
feedPreviousOldestId = null;
feedLastViewedId = null;
isHiddenSpinner = true;
alertTitle = "";
alertMessage = "";
// 'created' hook runs when the Vue instance is first created
async created() {
try {
await accountsDB.open();
this.allAccounts = await accountsDB.accounts.toArray();
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedId = settings?.lastViewedClaimId;
this.updateAllFeed();
} catch (err) {
this.alertTitle = "Error";
this.alertMessage =
err.userMessage ||
"There was an error retrieving the latest sweet, sweet action.";
}
}
updateAllFeed = async () => {
this.isHiddenSpinner = false;
await this.retrieveClaims(this.apiServer, null, this.feedPreviousOldestId)
.then(async (results) => {
if (results.data.length > 0) {
this.feedData = this.feedData.concat(results.data);
//console.log("Feed data:", this.feedData);
this.feedAllLoaded = results.hitLimit;
this.feedPreviousOldestId =
results.data[results.data.length - 1].jwtId;
if (
this.feedLastViewedId == null ||
this.feedLastViewedId < results.data[0].jwtId
) {
// save it to storage
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
lastViewedClaimId: results.data[0].jwtId,
});
// but not for this page because we need to remember what it was before
}
}
})
.catch((e) => {
console.log("Error with feed load:", e);
this.alertMessage =
e.userMessage || "There was an error retrieving feed data.";
this.alertTitle = "Error";
});
this.isHiddenSpinner = true;
};
retrieveClaims = async (endorserApiServer, identifier, beforeId) => {
//const afterQuery = afterId == null ? "" : "&afterId=" + afterId;
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const headers = { "Content-Type": "application/json" };
if (this.activeDid) {
const account = R.find(
(acc) => acc.did === this.activeDid,
this.allAccounts
);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
} else {
// it's OK without auth... we just won't get any identifiers
}
return fetch(this.apiServer + "/api/v2/report/gives?" + beforeQuery, {
method: "GET",
headers: headers,
})
.then(async (response) => {
if (response.status !== 200) {
const details = await response.text();
throw details;
}
return response.json();
})
.then((results) => {
if (results.data) {
return results;
} else {
throw JSON.stringify(results);
}
});
};
giveDescription(giveRecord) {
let claim = giveRecord.fullClaim;
if (claim.claim) {
// it's probably a Verified Credential
claim = claim.claim;
}
// agent.did is for legacy data, before March 2023
const giver =
claim.agent?.identifier || claim.agent?.did || giveRecord.issuer;
const giverInfo = didInfo(giver, this.allAccounts, this.allContacts);
const gaveAmount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: claim.description || "something unknown";
// recipient.did is for legacy data, before March 2023
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
const gaveRecipientInfo = gaveRecipientId
? " to " + didInfo(gaveRecipientId, this.allAccounts, this.allContacts)
: "";
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
}
displayAmount(code, amt) {
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
}
currencyShortWordForCode(unitCode, single) {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
openDialog(giver) {
this.$refs.customDialog.open(giver);
}
handleDialogResult(result) {
if (result.action === "confirm") {
return new Promise((resolve) => {
this.recordGive(result.contact?.did, result.description, result.hours);
resolve();
});
} else {
// action was "cancel" so do nothing
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param hours may be 0
*/
recordGive(giverDid, description, hours) {
if (this.activeDid == null) {
this.alertTitle = "Error";
this.alertMessage =
"You must select an identity before you can record a give.";
return;
}
if (!description && !hours) {
this.alertTitle = "Error";
this.alertMessage =
"You must enter a description or some number of hours.";
return;
}
const account = R.find(
(acc) => acc.did === this.activeDid,
this.allAccounts
);
//console.log("about to parse from", this.activeDid, account?.identity);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours
)
.then((result) => {
if (result.status != 201 || result.data?.error) {
console.log("Error with give result:", result);
this.alertTitle = "Error";
this.alertMessage =
result.data?.error?.message ||
"There was an error recording the give.";
} else {
this.alertTitle = "Success";
this.alertMessage = "That gift was recorded.";
//this.updateAllFeed(); // full update is overkill but we should show something
}
})
.catch((e) => {
// axios throws errors on 400 responses
console.log("Error with give caught:", e);
this.alertTitle = "Error";
this.alertMessage =
e.userMessage ||
e.response?.data?.error?.message ||
"There was an error recording the give.";
});
}
}
export default class HomeView extends Vue {}
</script>

View File

@@ -14,88 +14,42 @@
</h1>
</div>
<!-- Import Account 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>
<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>
</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.error("Error saving mnemonic & updating settings:", err);
}
}
}
}
</script>

View File

@@ -18,20 +18,17 @@
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>
@@ -39,7 +36,6 @@
<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>
@@ -50,42 +46,9 @@
<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 {
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();
}
}
export default class NewEditAccountView extends Vue {}
</script>

View File

@@ -10,265 +10,69 @@
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] Plan
[New/Edit] Project
</h1>
</div>
<!-- Project Details -->
<!-- Image - (see design model) Empty -->
<form>
<!-- Image - (see design model) Empty -->
<div>
{{ errorMessage }}
</div>
<!-- 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>
<input
type="text"
placeholder="Project Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="projectName"
/>
<input
type="text"
placeholder="Project Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<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>
<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>
<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 }">
<!-- icon no worky? -->
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
Saving&hellip;</span
<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"
>
</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>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
Cancel
</button>
</div>
</form>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Options, Vue } from "vue-class-component";
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";
import AlertMessage from "@/components/AlertMessage";
interface VerifiableCredential {
"@context": string;
"@type": string;
name: string;
description: string;
identifier?: string;
}
@Component({
components: { AlertMessage },
@Options({
components: {},
})
export default class NewEditProjectView extends Vue {
activeDid = "";
apiServer = "";
projectName = "";
description = "";
errorMessage = "";
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 || "";
this.apiServer = settings?.apiServer || "";
if (this.projectId) {
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.error("Error: no account was found.");
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
this.LoadProject(identity);
}
}
}
async LoadProject(identity: IIdentifier) {
const url =
this.apiServer +
"/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 });
if (resp.status === 200) {
const claim = resp.data.claim;
this.projectName = claim.name;
this.description = claim.description;
}
} catch (error) {
console.error("Got error retrieving that project", 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 url = this.apiServer + "/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 });
// 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",
};
that.$router.push(route);
},
2000,
this
);
}
} catch (error) {
let userMessage = "There was an error saving the project.";
const serverError = error as AxiosError;
if (serverError) {
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
console.log(serverError);
this.alertTitle = "User Message";
userMessage = serverError.response.data.error.message; // This is info for the user.
this.alertMessage = userMessage;
} else {
this.alertTitle = "Server Message";
this.alertMessage = JSON.stringify(serverError.toJSON());
}
} else {
console.error(
"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 async onSaveProjectClick() {
this.isHiddenSave = true;
this.isHiddenSpinner = false;
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.error("Error: there is no account.");
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
this.SaveProject(identity);
}
}
public onCancelClick() {
this.$router.back();
}
// This same popup code is in many files.
alertTitle = "";
alertMessage = "";
}
export default class NewEditProjectView extends Vue {}
</script>

View File

@@ -1,126 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Identity
</h1>
<div class="flex justify-center py-12">
<span />
<span v-if="loading">
<span class="text-xl">Creating...&nbsp;</span>
<fa
icon="spinner"
class="fa-spin fa-spin-pulse"
color="green"
size="128"
></fa>
</span>
<span v-else>
<span class="text-xl">Created!</span>
<fa
icon="burst"
class="fa-beat px-12"
color="green"
style="
--fa-animation-duration: 1s;
--fa-animation-direction: reverse;
--fa-animation-iteration-count: 1;
--fa-beat-scale: 6;
"
></fa>
</span>
<span />
</div>
</section>
</template>
<script lang="ts">
import "dexie-export-import";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component
export default class AccountViewView extends Vue {
loading = true;
async mounted() {
await accountsDB.open();
const mnemonic = generateSeed();
// address is 0x... ETH address, without "did:eth:"
const [address, privateHex, publicHex, derivationPath] =
deriveAddress(mnemonic);
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
this.loading = false;
setTimeout(() => {
this.$router.push({ name: "account" });
}, 1000);
}
}
</script>

View File

@@ -1,36 +1,30 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
<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"
<a href="" class="block text-center py-3 px-1"
><fa icon="house-chimney" class="fa-fw"></fa
></router-link>
></a>
</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"
<a href="search.html" class="block text-center py-3 px-1"
><fa icon="magnifying-glass" class="fa-fw"></fa
></router-link>
></a>
</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"
<a href="" class="block text-center py-3 px-1"
><fa icon="folder-open" class="fa-fw"></fa
></router-link>
></a>
</li>
<!-- Contacts -->
<!-- Commitments -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
<a href="" class="block text-center py-3 px-1"
><fa icon="hand" class="fa-fw"></fa
></router-link>
></a>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md text-slate-500">
@@ -62,96 +56,45 @@
><fa icon="ellipsis-vertical" class="fa-fw"></fa
></a>
View Plan
View Project
</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">{{ name }}</h2>
<h2 class="text-xl font-semibold">Canyon cleanup</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
>{{ timeSince }}
</span>
><fa icon="calendar" class="fa-fw text-slate-400"></fa> 8 days
ago</span
>
</div>
<div class="text-sm text-slate-500">
<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>
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>
</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>
<button
@click="openDialog({ name: 'you', did: activeDid })"
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
>
I gave...
</button>
<div>
<p>... or choose a contact who gave:</p>
<!-- similar contact selection code is in multiple places -->
<div class="px-4">
<button
v-for="contact in allContacts"
:key="contact.did"
@click="openDialog(contact)"
class="text-blue-500"
>
&nbsp;{{ contact.name }},
</button>
<span v-if="allContacts.length > 0">&nbsp;or&nbsp;</span>
<button @click="openDialog()" class="text-blue-500">
someone not specified
</button>
</div>
</div>
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
message="Received from"
>
</GiftedDialog>
<!-- Commit -->
<!--
<router-link
:to="{ name: 'new-edit-commitment' }"
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
>Make Commitment</router-link
>
-->
<!-- Commitments -->
<!--
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">Commitments</h3>
@@ -178,199 +121,14 @@
</li>
</ul>
</div>
-->
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as moment from "moment";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Options, Vue } from "vue-class-component";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { accountsDB, db } from "@/db";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { createAndSubmitGive } from "@/libs/endorserServer";
import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core";
import AlertMessage from "@/components/AlertMessage";
@Component({
components: { GiftedDialog, AlertMessage },
@Options({
components: {},
})
export default class ProjectViewView extends Vue {
activeDid = "";
allContacts: Array<Contact> = [];
apiServer = "";
expanded = false;
name = "";
description = "";
truncatedDesc = "";
truncateLength = 40;
timeSince = "";
projectId = localStorage.getItem("projectId") || ""; // handle ID
errorMessage = "";
onEditClick() {
localStorage.setItem("projectId", this.projectId as string);
const route = {
name: "new-edit-project",
};
this.$router.push(route);
}
expandText() {
this.expanded = true;
}
collapseText() {
this.expanded = false;
}
async LoadProject(identity: IIdentifier) {
const url =
this.apiServer +
"/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 });
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.error("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);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.error("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 || "null");
if (!identity) {
throw new Error("No identity found.");
}
this.LoadProject(identity);
}
}
openDialog(contact) {
this.$refs.customDialog.open(contact);
}
handleDialogResult(result) {
if (result.action === "confirm") {
return new Promise((resolve) => {
this.recordGive(result.contact?.did, result.description, result.hours);
resolve();
});
} else {
// action was not "confirm" so do nothing
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param hours may be 0
*/
async recordGive(giverDid, description, hours) {
if (this.activeDid == null) {
this.alertTitle = "Error";
this.alertMessage =
"You must select an identity before you can record a give.";
return;
}
if (!description && !hours) {
this.alertTitle = "Error";
this.alertMessage =
"You must enter a description or some number of hours.";
return;
}
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours,
this.projectId
)
.then((result) => {
if (result.status != 201 || result.data?.error) {
console.log("Error with give result:", result);
this.alertTitle = "Error";
this.alertMessage =
result.data?.error?.message ||
"There was an error recording the give.";
} else {
this.alertTitle = "Success";
this.alertMessage = "That gift was recorded.";
//this.updateAllFeed(); // full update is overkill but we should show something
}
})
.catch((e) => {
// axios throws errors on 400 responses
console.log("Error with give caught:", e);
this.alertTitle = "Error";
this.alertMessage =
e.userMessage ||
e.response?.data?.error?.message ||
"There was an error recording the give.";
});
}
// This same popup code is in many files.
alertMessage = "";
alertTitle = "";
}
export default class ProjectViewView extends Vue {}
</script>

View File

@@ -1,271 +1,3 @@
<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 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>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<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>
</InfiniteScroll>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
<section id="Content" class="p-6 pb-24"></section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { IIdentifier } from "@veramo/core";
import InfiniteScroll from "@/components/InfiniteScroll";
import AlertMessage from "@/components/AlertMessage";
/**
* Represents data about a project
**/
interface ProjectData {
/**
* Name of the project
**/
name: string;
/**
* Description of the project
**/
description: string;
/**
* URL referencing information about the project
**/
handleId: string;
/**
* The Identier of the project
**/
rowid: string;
}
@Component({
components: { InfiniteScroll, AlertMessage },
})
export default class ProjectsView extends Vue {
apiServer = "";
projects: ProjectData[] = [];
current: IIdentifier;
isLoading = false;
alertTitle = "";
alertMessage = "";
/**
* Core project data loader
* @param url the url used to fetch the data
* @param token Authorization token
**/
async dataLoader(url: string, token: string) {
const headers: { [key: string]: string } = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
try {
this.isLoading = true;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const plans: ProjectData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId = plan.fullIri, rowid } = plan;
this.projects.push({ name, description, handleId, rowid });
}
} else {
console.log(resp.status);
}
} catch (error) {
console.error("Got error loading projects:", error.message);
this.alertTitle = "Error";
this.alertMessage = "Got an error loading projects:" + error.message;
} finally {
this.isLoading = false;
}
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1];
const url = `${this.apiServer}/api/v2/report/plansByIssuer?beforeId=${latestProject.rowid}`;
const token = await accessToken(this.current);
await this.dataLoader(url, token);
}
}
/**
* Handle clicking on a project entry found in the list
* @param id of the project
**/
onClickLoadProject(id: string) {
localStorage.setItem("projectId", id);
const route = {
name: "project",
};
this.$router.push(route);
}
/**
* Load projects initially
* @param identity of the user
**/
async LoadProjects(identity: IIdentifier) {
const url = `${this.apiServer}/api/v2/report/plansByIssuer`;
const token: string = await accessToken(identity);
await this.dataLoader(url, token);
}
/**
* 'created' hook runs when the Vue instance is first created
**/
async created() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts === 0) {
console.error("Problem! You need a profile!");
this.alertTitle = "Error!";
this.alertMessage = "Problem! You need a profile!";
} else {
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error("No identity found.");
}
this.current = identity;
this.LoadProjects(identity);
}
} catch (err) {
console.log(err);
this.alertTitle = "Error!";
this.alertMessage = "Problem! You need a profile!";
}
}
/**
* Handling clicking on the new project button
**/
onClickNewProject(): void {
localStorage.removeItem("projectId");
const route = {
name: "new-edit-project",
};
this.$router.push(route);
}
}
</script>

View File

@@ -1,130 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Seed Backup
</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>
<div v-if="activeAccount">
<p>
BEWARE: Anyone who gets hold of this mnemonic seed phrase will be able
impersonate you and take over any digital holdings based on it. So only
reveal it when you are in a private place out of sight of other eyes,
and only record it in something private -- don't take a screenshot or
send it to any online service.
</p>
<button
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
@click="showSeedPhrase"
>
Click here when you're ready to see it.
</button>
<p v-if="showSeed">{{ activeAccount.mnemonic }}</p>
</div>
<div v-else>You do not have an active identity.</div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB, db } from "@/db";
import * as R from "ramda";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import AlertMessage from "@/components/AlertMessage";
@Component({ components: { AlertMessage } })
export default class SeedBackupView extends Vue {
activeAccount = null;
showSeed = false;
alertMessage = "";
alertTitle = "";
// 'created' hook runs when the Vue instance is first created
async created() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
} catch (err) {
console.error("Got an error loading an identity:", err);
this.alertTitle = "Error Loading Account";
this.alertMessage = "Got an error loading your seed data.";
}
}
showSeedPhrase() {
this.showSeed = true;
}
}
</script>

View File

@@ -35,7 +35,7 @@ import { Options, Vue } from "vue-class-component";
})
export default class StartView extends Vue {
public onClickYes() {
this.$router.push({ name: "new-identifier" });
this.$router.push({ name: "account" });
}
public onClickNo() {

View File

@@ -1,215 +0,0 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<ul class="flex text-2xl p-2 gap-2">
<!-- Home Feed -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
</router-link>
</li>
<!-- Search -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
</router-link>
</li>
<!-- Projects -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
</router-link>
</li>
<!-- Contacts -->
<li class="basis-1/5 rounded-md text-slate-500">
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
</router-link>
</li>
<!-- Profile -->
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
<router-link
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
</router-link>
</li>
</ul>
</nav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Achievements & Statistics
</h1>
<div>
Here is a view of the activity you can see.
<ul class="list-disc list-inside">
<li>Each identity and claim has a unique position.</li>
<!-- eslint-disable prettier/prettier --><!-- If we format prettier then there is extra space at the start of the line. -->
<li>Each will show at their time of appearance relative to all others.</li>
<li>Note that the ones on the left and right edges are randomized
because their data isn't all visible to you.
</li>
<!-- eslint-enable -->
</ul>
</div>
<div class="mt-3">
<div v-if="worldProperties.startTime">
<label>Time Range:&nbsp;</label>
{{ worldProperties.startTime }}
-
{{ worldProperties.endTime }}
</div>
<div v-if="worldProperties.animationDurationSeconds">
<label>Animation Time:&nbsp;</label>
{{ worldProperties.animationDurationSeconds }} seconds
</div>
</div>
<button class="float-right" @click="captureGraphics()">Screenshot</button>
<div id="scene-container" class="h-screen"></div>
<AlertMessage
:alertTitle="alertTitle"
:alertMessage="alertMessage"
></AlertMessage>
</section>
</template>
<script lang="ts">
import { SVGRenderer } from "three/addons/renderers/SVGRenderer.js";
import { Component, Vue } from "vue-facing-decorator";
import { World } from "@/components/World/World.js";
import AlertMessage from "@/components/AlertMessage";
interface WorldProperties {
startTime?: string;
endTime?: string;
}
@Component({ components: { AlertMessage, World } })
export default class StatisticsView extends Vue {
world: World;
worldProperties: WorldProperties = {};
// 'mounted' hook runs after initial render
mounted() {
try {
const container = document.querySelector("#scene-container");
console.log(container);
const newWorld = new World(container, this);
newWorld.start();
this.world = newWorld;
} catch (err) {
console.log(err);
}
}
public captureGraphics() {
/**
// from https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#examples
// Adds a blank image
const dataBlob = document
.querySelector("#scene-container")
.firstChild.toBlob((blob) => {
const newImg = document.createElement("img");
const url = URL.createObjectURL(blob);
newImg.onload = () => {
// no longer need to read the blob so it's revoked
URL.revokeObjectURL(url);
};
newImg.src = url;
document.body.appendChild(newImg);
});
**/
/**
// Yields a blank page with the iframe below
const dataUrl = document
.querySelector("#scene-container")
.firstChild.toDataURL("image/png");
**/
/**
// Yields a blank page with the iframe below
const dataUrl = this.world.renderer.domElement.toDataURL("image/png");
**/
/**
// Show the image in a new tab
const iframe = `
<iframe
src="${dataUrl}"
frameborder="0"
style="border:0; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%;"
allowfullscreen>
</iframe>`;
const win = window.open();
win.document.open();
win.document.write(iframe);
win.document.close();
**/
// from https://stackoverflow.com/a/17407392/845494
// This yields a file with funny formatting.
//const image = const dataUrl.replace("image/png", "image/octet-stream");
/**
// Yields a blank image at the bottom of the page
// from https://discourse.threejs.org/t/save-screenshot-on-server/39900/3
const img = new Image();
img.src = this.world.renderer.domElement.toDataURL();
document.body.appendChild(img);
**/
/**
* This yields an SVG that only shows white and black highlights
// from https://stackoverflow.com/questions/27632621/exporting-from-three-js-scene-to-svg-or-other-vector-format
**/
const rendererSVG = new SVGRenderer();
rendererSVG.setSize(window.innerWidth, window.innerHeight);
rendererSVG.render(this.world.scene, this.world.camera);
//document.body.appendChild(rendererSVG.domElement);
ExportToSVG(rendererSVG, "test.svg");
}
public setWorldProperty(propertyName, propertyValue) {
this.worldProperties[propertyName] = propertyValue;
}
alertTitle = "";
alertMessage = "";
}
function ExportToSVG(rendererSVG, filename) {
const XMLS = new XMLSerializer();
const svgfile = XMLS.serializeToString(rendererSVG.domElement);
const svgData = svgfile;
const preface = '<?xml version="1.0" standalone="no"?>\r\n';
const svgBlob = new Blob([preface, svgData], {
type: "image/svg+xml;charset=utf-8",
});
const svgUrl = URL.createObjectURL(svgBlob);
const downloadLink = document.createElement("a");
downloadLink.href = svgUrl;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
</script>

View File

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