Compare commits

...

84 Commits

Author SHA1 Message Date
Matthew Raymer
2b8cd180a1 Refactored last viewed notification code. Appears to work now. 2023-11-29 01:05:41 -05:00
Matthew Raymer
abc4a93b34 Added counting of pending notifications 2023-11-26 07:54:57 -05:00
Matthew Raymer
36493920f3 Before refactoring 2023-11-26 07:23:42 -05:00
Matthew Raymer
96fd4f68ef Updates using noble single file modules 2023-11-23 06:05:27 -05:00
Matthew Raymer
fc37f475d5 Added noble-curves.js 2023-11-23 04:36:46 -05:00
Matthew Raymer
80b7ab2f82 adding fetching of claims. WIP: JWT verification 2023-11-18 03:43:23 -05:00
Matthew Raymer
5f2658fd01 able to sign -- let's see what happens next 2023-11-18 03:19:06 -05:00
Matthew Raymer
267ed40946 WIP: fixing secp256k1 and BN 2023-11-18 01:40:10 -05:00
Matthew Raymer
1ce9e788e9 NACL integrated 2023-11-16 04:57:28 -05:00
Matthew Raymer
fe7b30ee32 Alert dialog for odd edge-case 2023-11-16 04:03:38 -05:00
Matthew Raymer
ad3cb10722 Selected active account and did a little decoding an extraction before building JWT 2023-11-15 06:05:08 -05:00
Matthew Raymer
d6dc7fbb10 Beefing up registration and activation of the service worker. 2023-11-14 02:56:59 -05:00
Matthew Raymer
cf2fec75ea STopping here. Weird behavior while debugging. Sometimes works; sometimes now 2023-11-13 08:03:30 -05:00
Matthew Raymer
9216be523a All of salt cleared. Ready to test tomorrow 2023-11-12 06:14:26 -05:00
Matthew Raymer
7ab3cb2d1f One grain of salt 2023-11-12 05:36:59 -05:00
Matthew Raymer
aba0d832db Up to loading accounts; looping and cryptograhy next 2023-11-12 05:08:18 -05:00
Matthew Raymer
7906aab36e WIP: fixing askPermission chain 2023-11-12 04:26:05 -05:00
Matthew Raymer
9b3d2b4c52 Added stripped down nacl and nudged toward integration 2023-11-11 08:14:14 -05:00
Matthew Raymer
ceb12ef5bc Added grabbing settings 2023-11-11 05:01:03 -05:00
Matthew Raymer
805c7b4810 Added messaging to service worker. Restarting integration procss 2023-11-11 04:40:45 -05:00
Jose Olarte III
c344d37bd9 Prettified identifier and register notices up top 2023-11-09 15:45:26 +08:00
Matthew Raymer
552ad5a267 Two static scripts for push notifications 2023-11-08 06:02:07 -05:00
Matthew Raymer
910f57ec7d Update version 2023-11-08 05:53:50 -05:00
Matthew Raymer
e813315dad Fixes and updates 2023-11-08 05:52:29 -05:00
aea9626c06 Merge pull request 'web-push-permissions' (#67) from web-push-permissions into master
Reviewed-on: #67
2023-11-08 01:49:49 -05:00
Matthew Aaron Raymer
7f0f1b7fc8 Merging changes 2023-11-08 14:47:48 +08:00
Matthew Raymer
cfc4d0a947 Updates package file 2023-11-07 21:12:49 +08:00
Matthew Raymer
8684488def Stip out old approach 2023-11-07 08:04:26 -05:00
Matthew Raymer
a820a7b131 Minor formatting changes 2023-11-07 21:01:37 +08:00
30d45c0acf tweak some more tasks (so it's clear which ones require UI expertise) 2023-11-06 18:07:57 -07:00
221bb2a27c move other route into alphabetical order (no logic changes) 2023-11-06 18:04:35 -07:00
2961e29831 move routes into alphabetical order (no logic changes) 2023-11-06 09:09:34 -07:00
5ae5e110c2 refine task list 2023-11-06 09:08:56 -07:00
20c2954be1 remove unused properties 2023-11-06 08:59:31 -07:00
a848e1fa81 Merge pull request 'consolidate into GiftedDialog because the result was always the same' (#76) from more-smalls into master
Reviewed-on: #76
2023-11-06 07:56:07 -05:00
85bd807bcc allow view of feed without an identifier 2023-11-05 21:46:46 -07:00
eeece8a1b4 refactor UI & remove unused code on account screen 2023-11-05 18:15:52 -07:00
bbfc1e1007 modify naming for unnamed gifter 2023-11-05 18:05:59 -07:00
433d0c023e refactor look of identity page 2023-11-05 17:55:03 -07:00
ac6376243b refactor name setting and other small messaging & types 2023-11-05 17:30:23 -07:00
a12f033b72 refactor type that was a duplication 2023-11-05 17:02:24 -07:00
42cd7d00de fix message to user 2023-11-05 17:02:05 -07:00
c388cc8cfe remove lastName and just have a single name field 2023-11-05 16:34:18 -07:00
6d4d4e40c3 Merge branch 'master' into more-smalls 2023-11-05 15:55:13 -07:00
3b39faf173 fix linting error 2023-11-05 15:51:47 -07:00
f43ecc98aa tweak notifications on errors and other responses 2023-11-05 08:29:36 -07:00
5b7ccf9ef0 fix where the project ID was not included; fix the pause when submitting give & show toast of aknowledgement; remove 'emit' 2023-11-05 07:46:16 -07:00
9bacd4da87 consolidate into GiftedDialog because the result was always the same 2023-11-04 18:48:31 -06:00
Matthew Raymer
ee28b18b14 Added message handler 2023-11-04 08:46:48 -04:00
7450d8d1c3 Merge pull request 'more miscellany' (#75) from misc2 into master
Reviewed-on: #75
2023-11-04 00:14:20 -04:00
7490cfc557 Merge branch 'master' into misc2 2023-11-03 22:12:31 -06:00
95287e4dd0 Merge pull request 'add IDs for puppeteer test script' (#74) from ids-for-tests into master
Reviewed-on: #74
2023-11-03 21:50:08 -04:00
679d1a70e8 Merge pull request 'allow edit of a contact's name' (#73) from contact-edit into master
Reviewed-on: #73
2023-11-03 21:49:40 -04:00
047fb263dd Merge pull request 'a couple of fixes' (#72) from fixes into master
Reviewed-on: #72
2023-11-03 21:49:12 -04:00
b76cf28bc2 Merge pull request 'edit text on the help page' (#70) from misc into master
Reviewed-on: #70
2023-11-03 21:47:45 -04:00
58c091cdaa add test view to hold testing functionality 2023-11-03 16:48:34 -06:00
0df5a975f3 add bottom navigation to edit-plan page 2023-11-03 16:17:25 -06:00
94051e6ba9 revert changes that are contained in other PRs 2023-11-03 13:27:50 -06:00
8e60f53f0b fix name of class to match the file name (and avoid clash with existing class) 2023-11-03 13:23:11 -06:00
afc48a5434 add IDs for puppeteer test script 2023-11-03 13:19:42 -06:00
6eb3381a98 enhancements to contact name edit 2023-11-03 10:24:48 -06:00
2bec218cc5 allow edit of a contact's name 2023-11-02 20:50:33 -06:00
327c655fb3 add a couple more tasks 2023-11-02 09:15:21 -06:00
866aad069f add multiple new tasks based on board meeting feedback 2023-11-02 09:04:42 -06:00
7f6c938029 fix display of given items; bump version to 0.1.2 2023-11-01 09:09:20 -06:00
6d2df4a50c fix the error message on a successful result 2023-11-01 08:56:22 -06:00
7305606546 improve give descriptions, bump to v 0.1.1 2023-10-31 20:53:39 -06:00
2a9ff8aa77 add some instructions and other tasks 2023-10-31 20:19:21 -06:00
829994491c improve messages for feed summaries 2023-10-31 19:58:37 -06:00
ce06e8f0fa update derivation path to the one for time 2023-10-31 19:58:07 -06:00
1ee751eea8 Merge pull request 'add QR scanner for adding a contact' (#69) from qr-reader-rebased into master 2023-10-31 10:34:59 -04:00
Matthew Raymer
2d38183dce Some updates and nudging toward notification ui 2023-10-16 07:43:10 -04:00
Matthew Raymer
082a6eae1f Refactor db setup a bit 2023-10-16 18:49:18 +08:00
Matthew Raymer
d07fb47721 Debugging 2023-10-04 22:16:58 -04:00
Matthew Raymer
ccb6160bca Updates to additional scripts 2023-10-02 04:41:50 -04:00
116b239616 change derivation path, add task to test app updating 2023-09-15 15:00:33 -06:00
Matthew Raymer
2eaa4203aa Debugging and ironing out the flow 2023-09-12 08:03:03 -04:00
Matthew Raymer
f27a18c712 Fix db path; add new pathing for web-push; load VAPID at boot 2023-09-11 07:51:59 -04:00
f47346cc35 edit text on the help page 2023-09-09 08:13:55 -06:00
Matthew Raymer
2c4a920c3c Updates for web push workflow 2023-09-08 21:04:50 +08:00
0e02268950 update tasks 2023-09-07 19:36:49 -06:00
94d9c425ad add QR scanner for adding a contact 2023-09-07 18:59:19 -06:00
Matthew Raymer
ed91cadd9d Added permission step; UI workflow documentation 2023-09-07 20:42:15 +08:00
Matthew Raymer
a6de282aec Another upgrade of package.json 2023-09-07 18:31:55 +08:00
41 changed files with 12861 additions and 24228 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules
signature.bin
*.pem
verified.txt
myenv
*~
# local env files

14
CHANGELOG.md Normal file
View File

@@ -0,0 +1,14 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.2] - 2023.11.01
### Added
- Basics: create ID, record a give, declare a project, search, and get notifications.

View File

@@ -1,6 +1,9 @@
# kickstart-for-time-pwa
## Project setup
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
```
npm install
```
@@ -23,6 +26,12 @@ npm run build
npm run lint
```
## Tests
###
For your own web-push tests, change the 'vapid' URL in App.vue, and install apps on the same domain.
### Test key contents
See [this page](openssl_signing_console.rst)
@@ -86,15 +95,6 @@ Clear cache for localhost, then go to http://localhost:8080/start
## Dependencies
See https://tea.xyz
| Project | Version |
| ---------- | --------- |
| nodejs.org | ^16.0.0 |
| npmjs.com | ^8.0.0 |
## Other
### Reference Material

24565
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "kickstart-for-time-pwa",
"version": "0.1.0",
"version": "0.1.3",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -9,58 +9,62 @@
},
"dependencies": {
"@ethersproject/hdnode": "^5.7.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@lionello/secp256k1-js": "^1.1.0",
"@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.1",
"@veramo/core": "^5.4.1",
"@veramo/credential-w3c": "^5.4.1",
"@veramo/data-store": "^5.4.1",
"@veramo/did-manager": "^5.4.1",
"@veramo/did-provider-ethr": "^5.4.1",
"@veramo/did-resolver": "^5.4.1",
"@veramo/key-manager": "^5.4.1",
"@vueuse/core": "^10.4.1",
"@zxing/text-encoding": "^0.9.0",
"axios": "^1.4.0",
"axios": "^1.5.0",
"buffer": "^6.0.3",
"class-transformer": "^0.5.1",
"core-js": "^3.31.1",
"core-js": "^3.32.1",
"dexie": "^3.2.4",
"dexie-export-import": "^4.0.7",
"did-jwt": "^7.2.4",
"ethereum-cryptography": "^2.0.0",
"did-jwt": "^7.2.7",
"elliptic": "^6.5.4",
"ethereum-cryptography": "^2.1.2",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.0.0",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"localstorage-slim": "^2.4.0",
"luxon": "^3.3.0",
"jssha": "^3.3.1",
"localstorage-slim": "^2.5.0",
"luxon": "^3.4.3",
"merkletreejs": "^0.3.10",
"moment": "^2.29.4",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.1.0",
"pinia-plugin-persistedstate": "^3.2.0",
"qr-code-generator-vue3": "^1.4.21",
"ramda": "^0.29.0",
"readable-stream": "^4.4.2",
"reflect-metadata": "^0.1.13",
"register-service-worker": "^1.7.2",
"three": "^0.154.0",
"three": "^0.156.1",
"vue": "^3.3.4",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^2.1.20",
"vue-router": "^4.2.3",
"vue-facing-decorator": "^3.0.2",
"vue-qrcode-reader": "^5.4.1",
"vue-router": "^4.2.4",
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@types/leaflet": "^1.9.4",
"@types/ramda": "^0.29.3",
"@types/three": "^0.152.1",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@types/three": "^0.155.1",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
@@ -70,15 +74,15 @@
"@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.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0-alpha.1",
"eslint-plugin-vue": "^9.15.1",
"autoprefixer": "^10.4.15",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.24",
"prettier": "^3.0.0",
"tailwindcss": "^3.3.2",
"typescript": "~5.1.6"
"postcss": "^8.4.29",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.3",
"typescript": "~5.2.2"
}
}

View File

@@ -1,23 +1,18 @@
tasks:
- 08 Scan QR code to import into contacts assignee:matthew
- SEE - https://github.com/gruhn/vue-qrcode-reader
- in endorser-push-server - mount folder for persistent sqlite DB outside of container
- test alerts on all pages -- or refactor to new "notify" (since AlertMessage refactoring may require a change, et. ContactQRScanShowView)
- .2 bug - on contacts view, click on "to" & "from" and nothing happens
- 40 notifications :
- push, where we trigger a ServiceWorker(?) in the app to reach out and check for new data assignee:matthew
- 01 Replace Gifted/Give in ContactsView with GiftedDialog assignee:matthew
- 01 fix the Discovery map display to not show on top of bottom icons (and any other UI tweaks on the map flow) assignee-group:ui
- .1 add instructions for map location selection
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished assignee:matthew assignee-group:ui
- 01 Show pop-up or some message confirming that settings & contacts download has been initiated/finished
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s) assignee-group:ui
- SEE: https://github.com/emmanuelsw/notiwind assignee:jose assignee-group:ui
- 01 Ensure each action sent to the server has a confirmation - eg registration (ie a toast something that dismisses after 5-10s)
- Home Feed & Quick Give screen :
- 01 save the feed-viewed status in settings storage ("afterQuery")
@@ -26,29 +21,34 @@ tasks:
- 24 Move to Vite assignee:matthew
- .2 Edit Plan does not have icons across the bottom assignee-group:ui
- .5 include the hash of the latest commit, and maybe a version
- .5 switch so DiscoverView shows anywhere by default, and no number unless search is done (and maybe a better filter UI, including "mine" to consolidate with ProjectsView)
- .2 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
- .5 Add infinite scroll to gifts on the home page
- .5 bug - search for "Safari" does not find the project, but if already on the "Anywhere" tab it shows all
- .2 figure out why endorser-mobile search doesn't find recently created PlanAction
- .1 when creating a plan, select location and then make sure you can deselect on Android
- .5 add link to further project / people when a project pays ahead
- .5 add project ID to the URL, to make a project publicly-accessible
- .5 remove edit from project page for projects owned by others
- .5 add project ID to the URL of the project-view, to make a project publicly-accessible
- .5 fix where user 0 sees no txns from user 1 on contacts page but sees them on list page
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist assignee-group:ui
- .2 fix static icon to the right on project page (Matthew - I've made "Rotary" into issuer?) assignee:jose assignee-group:ui
- .2 on ProjectViewView, show different messages for "to" and "from" sections if none exist
- .2 fix rate limit verbiage (with the new one-per-day allowance) assignee:trent
- .1 remove the logic to exclude beforeId in list of plans after server has commit 26b25af605e715600d4f12b6416ed9fd7142d164
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
- .1 Make give description text box into something that expands as they type
- .1 Make contact info specific to Time Safari - rather pointing at CommunityCred.org
- Discuss whether the remaining tasks are worthwhile before MVP release.
- 04 allow user to download claims, mine + ones I can see about me from others
- .5 change the derivation path, and regenerate test IDs
- 02 allow user to create new DIDs from the same seed phrase (ie. increment derivation path)
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages assignee-group:ui
- .5 on ProjectView page, show immediate feedback when a gift is given (on list?) -- and consider the same for Home & Contacts pages
- .5 customize favicon assignee-group:ui
- .5 Do we want to combine first name & last name?
- .2 Show a warning if both giver and recipient are the same (but still allow?) assignee-group:ui
- .2 Show a warning if both giver and recipient are the same (but still allow?)
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker assignee-group:ui
- .5 Display a more appealing confirmation on the map when erasing the marker
- .5 make a VC details page
- .1 Add units or different icon to the coins (to distinguish $, BTC, hours, etc)
- .1 remove firstName (& lastName) from localStorage
- contacts v+ :
- 01 Import all the non-sensitive data (ie. contacts & settings).
@@ -57,6 +57,7 @@ tasks:
- stats v1 :
- 01 show numeric stats
- 04 show different graphic for projects vs people on world
- 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")
@@ -72,6 +73,11 @@ tasks:
- Test PWA features on Android and iOS.
blocks: ref:https://raw.githubusercontent.com/trentlarson/lives-of-gifts/master/project.yaml#kickstarter%20for%20time
- .5 show seed phrase in a QR code for transfer to another device
- 32 accept images for projects
- 32 accept images for contacts
- linking between projects or plans :
- show total time given to & from a project
- terminology:

View File

@@ -162,17 +162,22 @@
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="
close(notification.id);
turnOnNotifications();
"
>
Turn on Notifications
</button>
<div class="grid grid-cols-2 gap-2">
<button
@click="close(notification.id)"
@click="maybeLater(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
Maybe Later
</button>
<button
@click="never(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md"
>
Never
@@ -254,4 +259,248 @@
<style></style>
<script lang="ts"></script>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import axios, { AxiosError } from "axios";
interface ServiceWorkerMessage {
type: string;
data: string;
}
interface ServiceWorkerResponse {
// Define the properties and their types
success: boolean;
message?: string;
}
// Example interface for error
interface ErrorResponse {
message: string;
// Other properties as needed
}
interface VapidResponse {
data: {
vapidKey: string;
};
}
@Component
export default class App extends Vue {
b64 = "";
mounted() {
axios
.get("https://timesafari-pwa.anomalistlabs.com/web-push/vapid")
.then((response: VapidResponse) => {
this.b64 = response.data.vapidKey;
console.log(this.b64);
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("New service worker is now controlling the page");
});
})
.catch((error: AxiosError) => {
console.error("API error", error);
});
}
private sendMessageToServiceWorker(
message: ServiceWorkerMessage,
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (navigator.serviceWorker.controller) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) {
reject(event.data.error as ErrorResponse);
} else {
resolve(event.data as ServiceWorkerResponse);
}
};
navigator.serviceWorker.controller.postMessage(message, [
messageChannel.port2,
]);
} else {
reject("Service worker controller not available");
}
});
}
private askPermission(): Promise<NotificationPermission> {
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
return Promise.reject("Service worker not available.");
}
const secret = localStorage.getItem("secret");
if (!secret) {
return Promise.reject("No secret found.");
}
return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error));
}
private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = {
type: "SEND_LOCAL_DATA",
data: secret,
};
return this.sendMessageToServiceWorker(message).then((response) => {
console.log("Response from service worker:", response);
});
}
private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) {
alert("This browser does not support notifications.");
return Promise.reject("This browser does not support notifications.");
}
if (Notification.permission === "granted") {
return Promise.resolve();
}
return Promise.resolve();
}
private requestNotificationPermission(): Promise<NotificationPermission> {
return Notification.requestPermission().then((permission) => {
if (permission !== "granted") {
alert("We need notification permission to provide certain features.");
throw new Error("We weren't granted permission.");
}
return permission;
});
}
async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
console.log("Permission granted:", permission);
// Call the function and handle promises
this.subscribeToPush()
.then(() => {
console.log("Subscribed successfully.");
// Assuming the subscription object is available
return navigator.serviceWorker.ready;
})
.then((registration) => {
// Fetch the existing subscription object from the registration
return registration.pushManager.getSubscription();
})
.then((subscription) => {
if (subscription) {
console.log(subscription);
return this.sendSubscriptionToServer(subscription);
} else {
throw new Error("Subscription object is not available.");
}
})
.then(() => {
console.log("Subscription data sent to server.");
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
);
});
})
.catch((error) => {
console.error("An error occurred:", error);
// Handle error appropriately here
});
}
// Function to convert URL base64 to Uint8Array
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
};
navigator.serviceWorker.ready
.then((registration) => {
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
console.log("Push subscription successful:", subscription);
resolve();
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
options,
);
// Inform the user about the issue
alert(
"We encountered an issue setting up push notifications. " +
"If you wish to revoke notification permissions, please do so in your browser settings.",
);
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscription,
): Promise<void> {
console.log(subscription);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to send subscription to server");
}
console.log("Subscription sent to server successfully.");
});
}
never(ID: string) {
alert(ID);
}
maybeLater(ID: string) {
alert(ID);
}
}
</script>

View File

@@ -2,7 +2,7 @@
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">
{{ message }} {{ giver?.name || "somebody not specified" }}
{{ message }} {{ giver?.name || "somebody not named" }}
</h1>
<input
type="text"
@@ -51,18 +51,57 @@
</template>
<script lang="ts">
import { Vue, Component, Prop, Emit } from "vue-facing-decorator";
import { GiverInputInfo, GiverOutputInfo } from "@/libs/endorserServer";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { createAndSubmitGive, GiverInputInfo } from "@/libs/endorserServer";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component
export default class GiftedDialog extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
@Prop message = "";
@Prop projectId = "";
activeDid = "";
apiServer = "";
giver?: GiverInputInfo;
description = "";
hours = "0";
visible = false;
async created() {
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.log("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.message ||
"There was an error retrieving the latest sweet, sweet action.",
},
-1,
);
}
}
open(giver: GiverInputInfo) {
this.giver = giver;
this.visible = true;
@@ -80,27 +119,169 @@ export default class GiftedDialog extends Vue {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
}
@Emit("dialog-result")
confirm(): GiverOutputInfo {
const result = {
action: "confirm",
giver: this.giver,
hours: parseFloat(this.hours),
description: this.description,
};
cancel() {
this.close();
this.description = "";
this.giver = undefined;
this.hours = "0";
return result;
}
@Emit("dialog-result")
cancel(): GiverOutputInfo {
const result = { action: "cancel" };
async confirm() {
this.close();
return result;
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
this.recordGive(
this.giver?.did as string | undefined,
this.description,
parseFloat(this.hours),
).then(() => {
this.description = "";
this.giver = undefined;
this.hours = "0";
});
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records for DID ${activeDid} but no identity was found",
);
}
return identity;
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param hours may be 0
*/
public async recordGive(
giverDid?: string,
description?: string,
hours?: number,
) {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record a give.",
},
-1,
);
return;
}
if (!description && !hours) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must enter a description or some number of hours.",
},
-1,
);
return;
}
try {
const identity = await this.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours,
this.projectId,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.log("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "That gift was recorded.",
},
10000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error with give recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
}
</script>

View File

@@ -1,8 +1,10 @@
/**
* Generic strings that could be used throughout the app.
*
* See also ../libs/veramo/setup.ts
*/
export enum AppString {
APP_NAME = "Kick-Start with Time",
APP_NAME = "TimeSafari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
@@ -10,3 +12,13 @@ export enum AppString {
DEFAULT_ENDORSER_API_SERVER = TEST_ENDORSER_API_SERVER,
}
/**
* See notiwind package
*/
export interface NotificationIface {
group: string;
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
}

View File

@@ -9,59 +9,39 @@ import {
} from "./tables/settings";
import { AppString } from "@/constants/app";
// a separate DB because the seed is super-sensitive data
type SensitiveTables = {
accounts: Table<Account>;
};
// Define types for tables that hold sensitive and non-sensitive data
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>;
settings: Table<Settings>;
};
/**
* In order to make the next line be acceptable, the program needs to have its linter suppress a rule:
* https://typescript-eslint.io/rules/no-unnecessary-type-constraint/
*
* and change *any* to *unknown*
*
* https://9to5answer.com/how-to-bypass-warning-unexpected-any-specify-a-different-type-typescript-eslint-no-explicit-any
*/
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = Object.assign({}, AccountsSchema);
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
const SensitiveSchemas = { ...AccountsSchema };
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
const NonsensitiveSchemas = Object.assign({}, ContactsSchema, SettingsSchema);
const NonsensitiveSchemas = { ...ContactsSchema, ...SettingsSchema };
/**
* 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.
*/
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
if (localStorage.getItem("secret") == null) {
localStorage.setItem("secret", secret);
}
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(SensitiveSchemas);
// Define the schema for our databases
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
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => {
db.settings.add({
id: MASTER_SETTINGS_KEY,
apiServer: AppString.DEFAULT_ENDORSER_API_SERVER,

View File

@@ -7,5 +7,5 @@ export interface Contact {
}
export const ContactsSchema = {
contacts: "++did, name, publicKeyBase64, registered, seesMe",
contacts: "&did, name, publicKeyBase64, registered, seesMe",
};

View File

@@ -1,28 +1,47 @@
/**
* BoundingBox type describes the geographical bounding box coordinates.
*/
export type BoundingBox = {
eastLong: number;
maxLat: number;
minLat: number;
westLong: number;
eastLong: number; // Eastern longitude
maxLat: number; // Maximum (Northernmost) latitude
minLat: number; // Minimum (Southernmost) latitude
westLong: number; // Western longitude
};
// a singleton
/**
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
id: number; // there's only one entry: MASTER_SETTINGS_KEY
id: number; // Only one entry using MASTER_SETTINGS_KEY
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
firstName?: string; // User's first name
lastName?: string; // User's last name
lastViewedClaimId?: string; // Last viewed claim ID
lastNotifiedClaimId?: string; // Last notified claim ID
isRegistered?: boolean;
// Array of named search boxes defined by bounding boxes
activeDid?: string;
apiServer?: string;
firstName?: string;
lastName?: string;
lastViewedClaimId?: string;
searchBoxes?: Array<{
name: string;
bbox: BoundingBox;
}>;
showContactGivesInline?: boolean;
showContactGivesInline?: boolean; // Display contact inline or not
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
};
/**
* Schema for the Settings table in the database.
*/
export const SettingsSchema = {
settings: "id",
};
/**
* Constants.
*/
export const MASTER_SETTINGS_KEY = 1;

View File

@@ -1,5 +1,4 @@
import { IIdentifier } from "@veramo/core";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
@@ -7,7 +6,10 @@ import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/76798669'/0'/0'/0'";
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
/**
*
@@ -150,3 +152,24 @@ export function fromJose(signature: string): {
export function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
/**
@return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
Note that similar code is also contained in time-safari
*/
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText;
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
if (endorserContextLoc > -1) {
jwtText = jwtText.substring(
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
);
}
// JWT format: { header, payload, signature, data }
const jwt = didJwt.decodeJWT(jwtText);
return jwt.payload;
};

View File

@@ -6,7 +6,12 @@ import { Axios, AxiosResponse } from "axios";
import { Contact } from "@/db/tables/contacts";
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch";
// the prefix for the contact URL
export const CONTACT_URL_PREFIX = "https://endorser.ch";
// the suffix for the contact URL
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
export interface AgreeVerifiableCredential {
"@context": string;
@@ -111,6 +116,8 @@ export function isHiddenDid(did: string) {
/**
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
Similar logic is found in endorser-mobile.
**/
export function didInfo(
did: string,
@@ -118,17 +125,17 @@ export function didInfo(
allMyDids: string[],
contacts: Contact[],
): string {
if (!did) return "Someone Anonymous";
const myId = R.find(R.equals(did), allMyDids);
if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`;
const contact = R.find((c) => c.did === did, contacts);
return contact
? contact.name || "Someone Unnamed in Contacts"
: !did
? "Unspecified Person"
? contact.name || "Contact With No Name"
: isHiddenDid(did)
? "Someone Not In Network"
: "Someone Not In Contacts";
? "Someone Not In Network"
: "Someone Not In Contacts";
}
export interface ResultWithType {
@@ -224,10 +231,10 @@ export async function createAndSubmitGive(
error === null
? "Null error"
: error instanceof Error
? error.message
: typeof error === "object" && error !== null && "message" in error
? (error as { message: string }).message
: "Unknown error";
? error.message
: typeof error === "object" && error !== null && "message" in error
? (error as { message: string }).message
: "Unknown error";
return {
type: "error",

View File

@@ -1,151 +1,7 @@
// Created from the setup in https://veramo.io/docs/guides/react_native
// see also ../constants/app.ts and
// Core interfaces
/* import {
createAgent,
IDIDManager,
IResolver,
IDataStore,
IKeyManager,
} from "@veramo/core";
*/
// Core identity manager plugin
//import { DIDManager } from "@veramo/did-manager";
// Ethr did identity provider
//import { EthrDIDProvider } from "@veramo/did-provider-ethr";
// Core key manager plugin
//import { KeyManager } from "@veramo/key-manager";
// Custom key management system for RN
//import { KeyManagementSystem } from '@veramo/kms-local-react-native'
// Custom resolver
// Custom resolvers
//import { DIDResolverPlugin } from "@veramo/did-resolver";
/* import { Resolver } from "did-resolver";
import { getResolver as ethrDidResolver } from "ethr-did-resolver";
import { getResolver as webDidResolver } from "web-did-resolver";
*/
// for VCs and VPs https://veramo.io/docs/api/credential-w3c
//import { CredentialIssuer } from '@veramo/credential-w3c'
// Storage plugin using TypeOrm
/* import {
Entities,
KeyStore,
DIDStore,
IDataStoreORM,
} from "@veramo/data-store";
*/
// TypeORM is installed with @veramo/typeorm
//import { createConnection } from 'typeorm'
//import * as R from "ramda";
/*
import { Contact } from '../entity/contact'
import { Settings } from '../entity/settings'
import { PrivateData } from '../entity/privateData'
import { Initial1616938713828 } from '../migration/1616938713828-initial'
import { SettingsContacts1616967972293 } from '../migration/1616967972293-settings-contacts'
import { EncryptedSeed1637856484788 } from '../migration/1637856484788-EncryptedSeed'
import { HomeScreenConfig1639947962124 } from '../migration/1639947962124-HomeScreenConfig'
import { HandlePublicKeys1652142819353 } from '../migration/1652142819353-HandlePublicKeys'
import { LastClaimsSeen1656811846836 } from '../migration/1656811846836-LastClaimsSeen'
import { ContactRegistered1662256903367 }from '../migration/1662256903367-ContactRegistered'
import { PrivateData1663080623479 } from '../migration/1663080623479-PrivateData'
const ALL_ENTITIES = Entities.concat([Contact, Settings, PrivateData])
// Create react native DB connection configured by ormconfig.js
export const dbConnection = createConnection({
database: 'endorser-mobile.sqlite',
entities: ALL_ENTITIES,
location: 'default',
logging: ['error', 'info', 'warn'],
migrations: [ Initial1616938713828, SettingsContacts1616967972293, EncryptedSeed1637856484788, HomeScreenConfig1639947962124, HandlePublicKeys1652142819353, LastClaimsSeen1656811846836, ContactRegistered1662256903367, PrivateData1663080623479 ],
migrationsRun: true,
type: 'react-native',
})
*/
function didProviderName(netName: string) {
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
}
//const NETWORK_NAMES = ["mainnet", "rinkeby"];
const DEFAULT_DID_PROVIDER_NETWORK_NAME = "mainnet";
export const DEFAULT_DID_PROVIDER_NAME = didProviderName(
DEFAULT_DID_PROVIDER_NETWORK_NAME,
);
export const HANDY_APP = false;
// this is used as the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch";
//const INFURA_PROJECT_ID = "INFURA_PROJECT_ID";
/*
const providers = {}
NETWORK_NAMES.forEach((networkName) => {
providers[didProviderName(networkName)] = new EthrDIDProvider({
defaultKms: 'local',
network: networkName,
rpcUrl: 'https://' + networkName + '.infura.io/v3/' + INFURA_PROJECT_ID,
gas: 1000001,
ttl: 60 * 60 * 24 * 30 * 12 + 1,
})
})
const didManager = new DIDManager({
store: new DIDStore(dbConnection),
defaultProvider: DEFAULT_DID_PROVIDER_NAME,
providers: providers,
})
*/
/* const basicDidResolvers = NETWORK_NAMES.map((networkName) => [
networkName,
new Resolver({
ethr: ethrDidResolver({
networks: [
{
name: networkName,
rpcUrl:
"https://" + networkName + ".infura.io/v3/" + INFURA_PROJECT_ID,
},
],
}).ethr,
web: webDidResolver().web,
}),
]);
const basicResolverMap = R.fromPairs(basicDidResolvers)
export const DEFAULT_BASIC_RESOLVER = basicResolverMap[DEFAULT_DID_PROVIDER_NETWORK_NAME]
const agentDidResolvers = NETWORK_NAMES.map((networkName) => {
return new DIDResolverPlugin({
resolver: basicResolverMap[networkName],
})
})
let allPlugins = [
new CredentialIssuer(),
new KeyManager({
store: new KeyStore(dbConnection),
kms: {
local: new KeyManagementSystem(),
},
}),
didManager,
].concat(agentDidResolvers)
*/
//export const agent = createAgent<IDIDManager & IKeyManager & IDataStore & IDataStoreORM & IResolver>({ plugins: allPlugins })
export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet");

View File

@@ -13,6 +13,7 @@ import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowLeft,
faArrowRight,
faBan,
faBurst,
faCalendar,
faChevronLeft,
@@ -59,6 +60,7 @@ import {
library.add(
faArrowLeft,
faArrowRight,
faBan,
faBurst,
faCalendar,
faChevronLeft,

View File

@@ -3,7 +3,7 @@
import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, {
register("/additional-scripts.js", {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +

View File

@@ -33,7 +33,6 @@ const routes: Array<RouteRecordRaw> = [
name: "home",
component: () =>
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/account",
@@ -58,6 +57,14 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
),
},
{
path: "/contact-gives",
name: "contact-gives",
component: () =>
import(
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
),
},
{
path: "/contact-qr",
name: "contact-qr",
@@ -71,15 +78,6 @@ const routes: Array<RouteRecordRaw> = [
name: "contacts",
component: () =>
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/scan-contact",
name: "scan-contact",
component: () =>
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
},
{
path: "/discover",
@@ -93,6 +91,14 @@ const routes: Array<RouteRecordRaw> = [
component: () =>
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
},
{
path: "/identity-switcher",
name: "identity-switcher",
component: () =>
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
},
{
path: "/import-account",
name: "import-account",
@@ -141,14 +147,6 @@ const routes: Array<RouteRecordRaw> = [
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
),
},
{
path: "/identity-switcher",
name: "identity-switcher",
component: () =>
import(
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
),
},
{
path: "/project",
name: "project",
@@ -162,6 +160,14 @@ const routes: Array<RouteRecordRaw> = [
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/scan-contact",
name: "scan-contact",
component: () =>
import(
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
),
},
{
path: "/seed-backup",
name: "seed-backup",
@@ -185,12 +191,10 @@ const routes: Array<RouteRecordRaw> = [
),
},
{
path: "/contact-gives",
name: "contact-gives",
path: "/test",
name: "test",
component: () =>
import(
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
),
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
},
];

View File

@@ -2,7 +2,7 @@ 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 { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";

View File

@@ -52,7 +52,17 @@
<!-- 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 v-if="givenName" class="text-xl font-semibold mb-2">
{{ givenName }}
</h2>
<span v-else>
<router-link
:to="{ name: 'new-edit-account' }"
class="text-xs bg-blue-500 text-white px-1.5 py-1 rounded-md"
>
(set name)
</router-link>
</span>
<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">
@@ -67,53 +77,11 @@
</button>
<span v-show="showDidCopy">Copied!</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>
<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>
</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-2"
class="block text-center text-lg font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md mb-2"
>
Edit Identity
</router-link>
@@ -132,8 +100,10 @@
)
"
>
<!-- label -->
<div>App Notifications</div>
<!-- toggle -->
<div class="relative">
<div class="relative ml-2">
<!-- input -->
<input type="checkbox" name="toggleNotifications" class="sr-only" />
<!-- line -->
@@ -143,8 +113,6 @@
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
<!-- label -->
<div class="ml-2">App Notifications</div>
</label>
<label
for="toggleMuteNotifications"
@@ -159,8 +127,10 @@
)
"
>
<!-- label -->
<div>Mute Notifications</div>
<!-- toggle -->
<div class="relative">
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
@@ -174,8 +144,6 @@
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
<!-- label -->
<div class="ml-2">Mute Notifications</div>
</label>
</div>
@@ -192,32 +160,13 @@
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()"
>
Download Settings & Contacts (excluding Identifier Data)
Download Settings & Contacts
<br />
(excluding Identifier Data)
</a>
<a ref="downloadLink" />
<!-- QR code popup -->
<dialog id="dlgQR" class="backdrop:bg-black/75 rounded-md">
<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" />
<button
value="cancel"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
>
Copy to Clipboard
</button>
<button
value="cancel"
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
>
Close
</button>
</dialog>
<div class="flex py-2">
<div v-if="activeDid" class="flex py-2">
<button class="text-center text-md text-blue-500" @click="checkLimits()">
Check Limits
</button>
@@ -244,20 +193,84 @@
</div>
</div>
<!-- id used by puppeteer test script -->
<h3
id="advanced"
class="text-sm uppercase font-semibold mb-3"
@click="showAdvanced = !showAdvanced"
>
Advanced
</h3>
<div v-if="showAdvanced">
<!-- Deep Identity Details -->
<h2 class="text-slate-500 text-sm font-bold mb-2 py-2">
Deep Identity Details
</h2>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
<div
class="text-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>
<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>
</div>
<label
for="toggleShowAmounts"
class="flex items-center cursor-pointer mb-6"
class="flex items-center cursor-pointer py-2"
@click="handleChange"
>
<!-- label -->
<h2 class="text-slate-500 text-sm font-bold mb-2">
Show amounts given with contacts
</h2>
<!-- toggle -->
<div class="relative">
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
@@ -272,21 +285,31 @@
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">
<router-link
:to="{ name: 'identity-switcher' }"
class="block text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
>
Switch Identity / No Identity
</router-link>
<button class="text-blue-500">
<!-- id used by puppeteer test script -->
<router-link
id="switch-identity-link"
:to="{ name: 'identity-switcher' }"
class="block text-center"
>
Switch Identity / No Identity
</router-link>
</button>
</div>
<div class="flex py-2">
Claim Server
<button class="text-blue-500">
<router-link :to="{ name: 'statistics' }" class="block text-center">
See Achievements & Statistics
</router-link>
</button>
</div>
<div class="flex py-4">
<h2 class="text-slate-500 text-sm font-bold mb-2">Claim Server</h2>
<input
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
@@ -318,17 +341,6 @@
Use Local
</button>
</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>
</div>
</section>
</template>
@@ -339,11 +351,11 @@ import "dexie-export-import";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue";
import { AppString } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import QuickNav from "@/components/QuickNav.vue";
import { IIdentifier } from "@veramo/core";
import { ErrorResponse, RateLimits } from "@/libs/endorserServer";
@@ -364,14 +376,6 @@ interface IAccount {
derivationPath: string;
}
interface SettingsType {
activeDid?: string;
apiServer?: string;
firstName?: string;
lastName?: string;
showContactGivesInline?: boolean;
}
@Component({ components: { QuickNav } })
export default class AccountViewView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
@@ -382,8 +386,8 @@ export default class AccountViewView extends Vue {
apiServer = "";
apiServerInput = "";
derivationPath = "";
firstName = "";
lastName = "";
givenName = "";
isRegistered = false;
numAccounts = 0;
publicHex = "";
publicBase64 = "";
@@ -398,8 +402,6 @@ export default class AccountViewView extends Vue {
showPubCopy = false;
showAdvanced = false;
alertMessage = "";
alertTitle = "";
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
try {
@@ -424,7 +426,7 @@ export default class AccountViewView extends Vue {
}
// Return parsed identity or null if not found
return JSON.parse(account?.identity || "null");
return JSON.parse((account?.identity as string) || "null");
}
/**
@@ -505,12 +507,14 @@ export default class AccountViewView extends Vue {
* Initializes component state with values from the database or defaults.
* @param {SettingsType} settings - Object containing settings from the database.
*/
initializeState(settings: SettingsType | undefined) {
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.firstName = settings?.firstName || "";
this.lastName = settings?.lastName || "";
initializeState(settings: Settings | undefined) {
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.apiServerInput = (settings?.apiServer as string) || "";
this.givenName =
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // pre v 0.1.3
this.isRegistered = !!settings?.isRegistered;
this.showContactGives = !!settings?.showContactGivesInline;
}
@@ -527,7 +531,7 @@ export default class AccountViewView extends Vue {
) {
this.publicHex = identity.keys[0].publicKeyHex;
this.publicBase64 = Buffer.from(this.publicHex, "hex").toString("base64");
this.derivationPath = identity.keys[0].meta.derivationPath;
this.derivationPath = identity.keys[0].meta.derivationPath as string;
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: identity.did,
@@ -697,6 +701,27 @@ export default class AccountViewView extends Vue {
const resp = await this.fetchRateLimits(identity);
if (resp.status === 200) {
this.limits = resp.data;
if (!this.isRegistered) {
// the user is not known to be registered, but they are so let's record it
try {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true,
});
this.isRegistered = true;
} catch (err) {
console.log("Got an error updating settings:", err);
this.$notify(
{
group: "alert",
type: "warning",
title: "Update Error",
text: "Unable to update your settings. Check claim limits again.",
},
-1,
);
}
}
}
} catch (error) {
this.handleRateLimitsError(error);
@@ -725,8 +750,13 @@ export default class AccountViewView extends Vue {
private handleRateLimitsError(error: unknown) {
if (error instanceof AxiosError) {
const data = error.response?.data as ErrorResponse;
this.limitsMessage = data?.error?.message || "Bad server response.";
console.error("Bad response retrieving limits:", error);
this.limitsMessage =
(data?.error?.message as string) || "Bad server response.";
console.log(
"Got bad response retrieving limits, which usually means user isn't registered. Server says:",
this.limitsMessage,
//error,
);
} else if (
error instanceof Error &&
error.message ===

View File

@@ -16,10 +16,6 @@
</h1>
</div>
<!-- Quick Search -->
<!-- Initial Loading Animation -->
<!-- Results List -->
<ul class="border-t border-slate-300">
<li class="border-b border-slate-300 py-3">
@@ -70,12 +66,7 @@
</li>
</ul>
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
message="Received from"
>
</GiftedDialog>
<GiftedDialog ref="customDialog" message="Received from"> </GiftedDialog>
</section>
</template>
@@ -83,16 +74,10 @@
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
createAndSubmitGive,
CreateAndSubmitGiveResult,
ErrorResult,
GiverInputInfo,
GiverOutputInfo,
} from "@/libs/endorserServer";
import { GiverInputInfo } from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
@@ -124,10 +109,10 @@ export default class ContactGiftingView extends Vue {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -150,7 +135,7 @@ export default class ContactGiftingView extends Vue {
async created() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
@@ -173,123 +158,5 @@ export default class ContactGiftingView extends Vue {
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}
handleDialogResult(result: GiverOutputInfo) {
if (result.action === "confirm") {
return new Promise((resolve) => {
this.recordGive(
result.giver?.did,
result.description,
result.hours,
).then(() => {
resolve(null);
});
});
} else {
// action was "cancel" so do nothing
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param hours may be 0
*/
public async recordGive(
giverDid?: string,
description?: string,
hours?: number,
) {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record a give.",
},
-1,
);
return;
}
if (!description && !hours) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must enter a description or some number of hours.",
},
-1,
);
return;
}
try {
const identity = await this.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours,
);
if (this.isGiveCreationError(result)) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.log("Error with give result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error recording the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "That gift was recorded.",
},
-1,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error with give caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the Give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
// Helper functions for readability
isGiveCreationError(result: CreateAndSubmitGiveResult) {
return result.type == "error";
}
getGiveCreationErrorMessage(result: CreateAndSubmitGiveResult) {
return (result as ErrorResult).error?.userMessage;
}
}
</script>

View File

@@ -3,7 +3,7 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
@@ -17,12 +17,17 @@
:dotsOptions="{ type: 'square' }"
class="flex justify-center"
/>
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
</section>
</template>
<script lang="ts">
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import * as R from "ramda";
@@ -30,6 +35,10 @@ import { SimpleSigner } from "@/libs/crypto";
import * as didJwt from "did-jwt";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
import {
CONTACT_URL_PREFIX,
ENDORSER_JWT_URL_LOCATION,
} from "@/libs/endorserServer";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
@@ -43,6 +52,7 @@ interface Notification {
@Component({
components: {
QrcodeStream,
QRCodeVue3,
QuickNav,
},
@@ -98,7 +108,9 @@ export default class ContactQRScanShow extends Vue {
iat: Date.now(),
iss: this.activeDid,
own: {
name: (settings?.firstName || "") + " " + (settings?.lastName || ""),
name:
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey,
},
};
@@ -112,9 +124,47 @@ export default class ContactQRScanShow extends Vue {
issuer: identity.did,
signer: signer,
});
const viewPrefix = "https://endorser.ch/contact?jwt=";
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt;
}
}
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
*/
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanDetect(content: any) {
if (content[0]?.rawValue) {
console.log("onDetect", content[0].rawValue);
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
this.$router.push({ name: "contacts" });
} else {
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
-1,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
console.log("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Scan",
text: "The scan was invalid.",
},
-1,
);
}
}
</script>

View File

@@ -20,6 +20,11 @@
<!-- New Contact -->
<div class="mb-4 flex">
<span class="self-center bg-slate-500 text-white px-1.5 py-1 rounded-md">
<router-link :to="{ name: 'contact-qr' }">
<fa icon="qrcode" class="fa-fw" />
</router-link>
</span>
<input
type="text"
placeholder="DID, Name, Public Key"
@@ -62,8 +67,8 @@
showGiveTotals
? "Total"
: showGiveConfirmed
? "Confirmed"
: "Unconfirmed"
? "Confirmed"
: "Unconfirmed"
}}
</button>
<br />
@@ -88,6 +93,16 @@
class="inline-block align-text-bottom border border-slate-300 rounded"
></EntityIcon>
{{ contact.name || "(no name)" }}
<button
class="text-sm uppercase bg-slate-500 text-white px-1 rounded-md"
@click="
contactEdit = contact;
contactNewName = contact.name;
"
title="Edit"
>
<fa icon="pen" class="fa-fw" />
</button>
</h2>
<div class="text-sm truncate">{{ contact.did }}</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
@@ -204,6 +219,31 @@
</li>
</ul>
<p v-else>This identity has no contacts.</p>
<div v-if="contactEdit !== null" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Name"
v-model="contactNewName"
/>
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactEdit, contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
</div>
</div>
</section>
</template>
@@ -211,11 +251,17 @@
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { NotificationIface } from "@/constants/app";
import { IIdentifier } from "@veramo/core";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import {
accessToken,
getContactPayloadFromJwtUrl,
SimpleSigner,
} from "@/libs/crypto";
import {
GiveServerRecord,
GiveVerifiableCredential,
@@ -225,27 +271,24 @@ import {
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Buffer = require("buffer/").Buffer;
interface Notification {
group: string;
type: string;
title: string;
text: string;
}
@Component({
components: { QuickNav, EntityIcon },
})
export default class ContactsView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
contactEndorserUrl = localStorage.getItem("contactEndorserUrl") || "";
contactInput = "";
contactEdit: Contact | null = null;
contactNewName = "";
// { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
@@ -266,7 +309,7 @@ export default class ContactsView extends Vue {
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
@@ -279,12 +322,18 @@ export default class ContactsView extends Vue {
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
if (this.contactEndorserUrl) {
await this.newContactFromScan(this.contactEndorserUrl);
localStorage.removeItem("contactEndorserUrl");
this.contactEndorserUrl = "";
}
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const account = R.find((acc) => acc.did === activeDid, accounts) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -346,7 +395,7 @@ export default class ContactsView extends Vue {
{
group: "alert",
type: "danger",
title: "Error With Server",
title: "Server Error",
text:
"Got an error retrieving your " +
(useRecipient ? "given" : "received") +
@@ -405,7 +454,7 @@ export default class ContactsView extends Vue {
{
group: "alert",
type: "danger",
title: "Error With Server",
title: "Server Error",
text: error as string,
},
-1,
@@ -414,6 +463,18 @@ export default class ContactsView extends Vue {
}
async onClickNewContact(): Promise<void> {
if (!this.contactInput) {
this.$notify(
{
group: "alert",
type: "warning",
title: "No Contact",
text: "There was no contact info to add.",
},
-1,
);
return;
}
let did = this.contactInput;
let name, publicKeyBase64;
const commaPos1 = this.contactInput.indexOf(",");
@@ -432,12 +493,74 @@ export default class ContactsView extends Vue {
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,
);
return this.addContact(newContact);
}
async newContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
-1,
);
return;
} else {
return this.addContact({
did: payload.iss,
name: payload.own.name,
publicKeyBase64: payload.own.publicEncKey,
} as Contact);
}
}
async addContact(newContact: Contact) {
if (!newContact.did) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Incomplete Contact",
text: "Cannot add a contact without a DID.",
},
-1,
);
return;
}
return db.contacts
.add(newContact)
.then(() => {
const allContacts = this.contacts.concat([newContact]);
this.contacts = R.sort(
(a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
allContacts,
);
this.$notify(
{
group: "alert",
type: "success",
title: "Contact added",
text: newContact.name + " was added.",
},
-1,
);
})
.catch((err) => {
console.error("Error when adding contact to storage:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Not Added",
text: "An error prevented importing.",
},
-1,
);
});
}
async deleteContact(contact: Contact) {
@@ -467,6 +590,16 @@ export default class ContactsView extends Vue {
"?",
)
) {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
const identity = await this.getIdentity(this.activeDid);
const vcClaim: RegisterVerifiableCredential = {
@@ -549,7 +682,7 @@ export default class ContactsView extends Vue {
{
group: "alert",
type: "danger",
title: "Error With Server",
title: "Server Error",
text: userMessage,
},
-1,
@@ -574,36 +707,30 @@ export default class ContactsView extends Vue {
contact.seesMe = visibility;
db.contacts.update(contact.did, { seesMe: visibility });
} else {
console.error("Bad response setting visibility: ", resp.data);
if (resp.data.error?.message) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: resp.data.error?.message,
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: "Bad server response of " + resp.status,
},
-1,
);
}
console.error(
"Got some bad server response when setting visibility: ",
resp,
);
const message =
resp.data.error?.message || "Bad server response of " + resp.status;
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: message,
},
-1,
);
}
} catch (err) {
console.error("Got some server error when setting visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: err as string,
title: "Server Error",
text: "Check connectivity and try again.",
},
-1,
);
@@ -628,7 +755,7 @@ export default class ContactsView extends Vue {
this.$notify(
{
group: "alert",
type: "toast",
type: "info",
title: "Refreshed",
text:
this.nameForContact(contact, true) +
@@ -636,38 +763,29 @@ export default class ContactsView extends Vue {
(visibility ? "" : "not ") +
"see your activity.",
},
5000,
-1,
);
} else {
if (resp.data.error?.message) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: resp.data.error?.message,
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: "Bad server response of " + resp.status,
},
-1,
);
}
console.log("Got bad server response when checking visibility: ", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Server Error",
text: message,
},
-1,
);
}
} catch (err) {
console.log("Caught error from server request to check visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: err as string,
title: "Server Error",
text: "Check connectivity and try again.",
},
-1,
);
@@ -867,7 +985,7 @@ export default class ContactsView extends Vue {
{
group: "alert",
type: "danger",
title: "Error With Server",
title: "Server Error",
text: userMessage,
},
-1,
@@ -876,6 +994,18 @@ export default class ContactsView extends Vue {
}
}
private async onClickCancelName() {
this.contactEdit = null;
this.contactNewName = "";
}
private async onClickSaveName(contact: Contact, newName: string) {
contact.name = newName;
return db.contacts
.update(contact.did, { name: newName })
.then(() => (this.contactEdit = null));
}
public toggleShowGiveTotals() {
if (this.showGiveTotals) {
this.showGiveTotals = false;
@@ -900,6 +1030,26 @@ export default class ContactsView extends Vue {
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
/* Tooltip from https://www.w3schools.com/css/css_tooltip.asp */
/* Tooltip container */
.tooltip {
@@ -907,7 +1057,6 @@ export default class ContactsView extends Vue {
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;

View File

@@ -41,14 +41,15 @@
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
you can select any contact on the home page (or "anonymous") and record
your appreciation for... whatever. The main goal is to record what
people have given you, to grow gifting economies. Each claim is recorded
on a custom ledger. The day after being registered, you'll be able to
register others; later, you can create projects, too.
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.
Note that there are limits to how many others 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>
@@ -130,7 +131,9 @@
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p>
Go
Before doing this, note that it is an advanced feature that affects
functionality (eg. the words "Alt ID" next to results, backup features)
so beware if you think that may cause confusion. You can
<router-link to="start" class="text-blue-500">
create another identity here.
</router-link>

View File

@@ -6,198 +6,98 @@
Time Safari
</h1>
<!-- show the actions for recognizing a give -->
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'toast',
text: 'I\'m a toast. Don\'t mind me.',
},
5000,
)
"
class="font-bold uppercase bg-slate-400 text-white px-3 py-2 rounded-md mr-2"
>
Toast (self-dismiss)
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Information Alert',
text: 'Just wanted you to know.',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Info
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Success Alert',
text: 'Congratulations!',
},
-1,
)
"
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
>
Success
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'warning',
title: 'Warning Alert',
text: 'You might wanna look at this.',
},
-1,
)
"
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
>
Warning
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Danger Alert',
text: 'Something terrible has happened!',
},
-1,
)
"
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
>
Danger
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-permission',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif ON
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-mute',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif MUTE
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-off',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif OFF
</button>
</div>
<div class="mb-8">
<h2 class="text-xl font-bold">Quick Action</h2>
<p class="mb-4">Record a gift from a contact:</p>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openDialog()">
<EntityIcon
:entityId="null"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
></EntityIcon>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous
</h3>
</li>
<li
v-for="contact in allContacts"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:entityId="contact.did"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
></EntityIcon>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || contact.did }}
</h3>
</li>
</ul>
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
<router-link
v-if="allContacts.length > 7"
:to="{ name: 'contact-gives' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
Show More Contacts&hellip;
</router-link>
<!-- If there are no contacts, show this instead: -->
<div
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
v-if="allContacts.length === 0"
v-if="!activeDid"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
(No contacts to show.)
<p class="text-lg mb-3">
You need an <b>identifier</b> before you can record others' giving.
</p>
<router-link
:to="{ name: 'start' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
Create Your Identifier</router-link
>
</div>
<div
v-else-if="!isRegistered"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
Someone must register your account before you can record others' giving.
To do this:
<router-link
:to="{ name: 'contact-qr' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
1. Show Them Your Identity Info</router-link
>
<router-link
:to="{ name: 'account' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
2. Check Your Limits</router-link
>
</div>
<div v-else>
<!-- activeDid && isRegistered -->
<h2 class="text-xl font-bold">Record a Gift</h2>
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
<li @click="openDialog()">
<EntityIcon
:entityId="null"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
></EntityIcon>
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous/Unnamed
</h3>
</li>
<li
v-for="contact in allContacts"
:key="contact.did"
@click="openDialog(contact)"
>
<EntityIcon
:entityId="contact.did"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
></EntityIcon>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ contact.name || contact.did }}
</h3>
</li>
</ul>
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
<router-link
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gives' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
Show More Contacts&hellip;
</router-link>
<!-- If there are no contacts, show this instead: -->
<div
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
v-if="allContacts.length === 0"
>
(No contacts to show.)
</div>
</div>
</div>
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
message="Received from"
>
</GiftedDialog>
<GiftedDialog ref="customDialog" message="Received from"> </GiftedDialog>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
@@ -233,19 +133,18 @@
import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
createAndSubmitGive,
didInfo,
GiverInputInfo,
GiverOutputInfo,
GiveServerRecord,
} from "@/libs/endorserServer";
import { Contact } from "@/db/tables/contacts";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { IIdentifier } from "@veramo/core";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
@@ -269,6 +168,7 @@ export default class HomeView extends Vue {
feedPreviousOldestId?: string;
feedLastViewedId?: string;
isHiddenSpinner = true;
isRegistered = false;
numAccounts = 0;
async beforeCreate() {
@@ -278,10 +178,10 @@ export default class HomeView extends Vue {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -308,11 +208,12 @@ export default class HomeView extends Vue {
this.allMyDids = allAccounts.map((acc) => acc.did);
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedId = settings?.lastViewedClaimId;
this.isRegistered = !!settings?.isRegistered;
this.updateAllFeed();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
@@ -338,7 +239,9 @@ export default class HomeView extends Vue {
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const account = allAccounts.find(
(acc) => acc.did === this.activeDid,
) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -415,6 +318,7 @@ export default class HomeView extends Vue {
}
giveDescription(giveRecord: GiveServerRecord) {
// similar code is in endorser-mobile utility.ts
// claim.claim happen for some claims wrapped in a Verifiable Credential
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const claim = (giveRecord.fullClaim as any).claim || giveRecord.fullClaim;
@@ -427,9 +331,18 @@ export default class HomeView extends Vue {
this.allMyDids,
this.allContacts,
);
const gaveAmount = claim.object?.amountOfThisGood
let gaveAmount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: claim.description || "something unknown";
: "";
if (claim.description) {
if (gaveAmount) {
gaveAmount = gaveAmount + ", and also: ";
}
gaveAmount = gaveAmount + claim.description;
}
if (!gaveAmount) {
gaveAmount = "something not described";
}
// recipient.did is for legacy data, before March 2023
const gaveRecipientId =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -443,7 +356,7 @@ export default class HomeView extends Vue {
this.allContacts,
)
: "";
return giverInfo + " gave " + gaveAmount + gaveRecipientInfo;
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
}
displayAmount(code: string, amt: number) {
@@ -457,124 +370,5 @@ export default class HomeView extends Vue {
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
}
handleDialogResult(result: GiverOutputInfo) {
if (result.action === "confirm") {
return new Promise((resolve) => {
this.recordGive(
result.giver?.did,
result.description,
result.hours,
).then(() => {
resolve(null);
});
});
} else {
// action was "cancel" so do nothing
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param hours may be 0
*/
public async recordGive(
giverDid?: string,
description?: string,
hours?: number,
) {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record a give.",
},
-1,
);
return;
}
if (!description && !hours) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must enter a description or some number of hours.",
},
-1,
);
return;
}
try {
const identity = await this.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours,
);
if (this.isGiveCreationError(result)) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.log("Error with give result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error recording the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "That gift was recorded.",
},
-1,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log("Error with give caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
// Helper functions for readability
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return result.data?.error?.message;
}
}
</script>

View File

@@ -22,7 +22,7 @@
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa>
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0">
{{ firstName }} {{ lastName }}
{{ givenName }}
</h2>
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ activeDid }}</code>
@@ -49,7 +49,9 @@
</ul>
<!-- Actions -->
<!-- id used by puppeteer test script -->
<router-link
id="start-link"
:to="{ name: 'start' }"
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
>
@@ -69,7 +71,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { AppString } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
interface Notification {
@@ -88,8 +90,7 @@ export default class IdentitySwitcherView extends Vue {
public activeDid = "";
public apiServer = "";
public apiServerInput = "";
public firstName = "";
public lastName = "";
public givenName = "";
public otherIdentities: Array<{ did: string }> = [];
public showContactGives = false;
@@ -99,19 +100,20 @@ export default class IdentitySwitcherView extends Vue {
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse(account?.identity || "null");
const identity = JSON.parse((account?.identity as string) || "null");
return identity;
}
async created() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.firstName = settings?.firstName || "No";
this.lastName = settings?.lastName || "Name";
this.givenName =
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
this.showContactGives = !!settings?.showContactGivesInline;
const identity = await this.getIdentity(this.activeDid);
@@ -149,7 +151,7 @@ export default class IdentitySwitcherView extends Vue {
did = undefined;
}
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
this.activeDid = did || "";

View File

@@ -17,7 +17,9 @@
<p class="text-center text-xl mb-4 font-light">
Enter your seed phrase below to import your identity on this device.
</p>
<!-- id used by puppeteer test script -->
<input
id="seed-input"
type="text"
placeholder="Seed Phrase"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"

View File

@@ -10,21 +10,15 @@
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
[New/Edit] Identity
Edit Identity
</h1>
</div>
<input
type="text"
placeholder="First Name"
placeholder="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"
v-model="givenName"
/>
<div class="mt-8">
@@ -50,36 +44,30 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@Component({
components: {},
})
export default class NewEditAccountView extends Vue {
firstName =
localStorage.getItem("firstName") === null
? "--"
: localStorage.getItem("firstName");
lastName =
localStorage.getItem("lastName") === null
? "--"
: localStorage.getItem("lastName");
givenName = "";
// '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 || "";
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.givenName =
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
}
onClickSaveChanges() {
db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.firstName,
lastName: this.lastName,
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
localStorage.setItem("firstName", this.firstName as string);
localStorage.setItem("lastName", this.lastName as string);
localStorage.setItem("firstName", this.givenName as string);
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
this.$router.push({ name: "account" });
}

View File

@@ -1,4 +1,5 @@
<template>
<QuickNav selected="Projects"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Breadcrumb -->
@@ -107,6 +108,7 @@ import * as didJwt from "did-jwt";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import QuickNav from "@/components/QuickNav.vue";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
@@ -122,7 +124,7 @@ interface Notification {
}
@Component({
components: { LMap, LMarker, LTileLayer },
components: { LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: Notification, timeout?: number) => void;

View File

@@ -46,7 +46,7 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class AccountViewView extends Vue {
export default class NewIdentifierView extends Vue {
loading = true;
async mounted() {

View File

@@ -102,7 +102,7 @@
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous
Anonymous/Unnamed
</h3>
</li>
<li
@@ -125,7 +125,7 @@
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
<router-link
v-if="allContacts.length > 7"
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gives' }"
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
>
@@ -196,8 +196,8 @@
<GiftedDialog
ref="customDialog"
@dialog-result="handleDialogResult"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
</section>
@@ -212,18 +212,16 @@ import { Component, Vue } from "vue-facing-decorator";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
createAndSubmitGive,
didInfo,
GiverInputInfo,
GiverOutputInfo,
GiveServerRecord,
ResultWithType,
} from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { Account } from "@/db/tables/accounts";
interface Notification {
group: string;
@@ -257,7 +255,7 @@ export default class ProjectViewView extends Vue {
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
@@ -266,17 +264,17 @@ export default class ProjectViewView extends Vue {
const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr?.find((acc) => acc.did === this.activeDid);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
this.LoadProject(identity);
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
@@ -461,11 +459,6 @@ export default class ProjectViewView extends Vue {
}
}
openDialog(contact: GiverInputInfo) {
const dialog: GiftedDialog = this.$refs.customDialog as GiftedDialog;
dialog.open(contact);
}
getOpenStreetMapUrl() {
// Google URL is https://maps.google.com/?q=LAT,LONG
return (
@@ -480,96 +473,8 @@ export default class ProjectViewView extends Vue {
);
}
handleDialogResult(result: GiverOutputInfo) {
if (result.action === "confirm") {
return new Promise((resolve) => {
this.recordGive(
result.giver?.did,
result.description,
result.hours,
).then(() => {
resolve(null);
});
});
} 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?: string, description?: string, hours?: number) {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record a give.",
},
-1,
);
return;
}
if (!description && !hours) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must enter a description or some number of hours.",
},
-1,
);
} else {
const identity = await this.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
giverDid,
this.activeDid,
description,
hours,
this.projectId,
);
if (result.type == "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "That gift was recorded.",
},
-1,
);
} else {
console.log("Error with give creation:", result);
if (result.type != "error") {
console.log(
"... and it has an unexpected result type of",
(result as ResultWithType).type,
);
}
const message =
result?.error?.userMessage ||
"There was an error recording the Give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
openDialog(contact: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(contact);
}
}
</script>

View File

@@ -98,8 +98,6 @@ export default class ProjectsView extends Vue {
projects: ProjectData[] = [];
current: IIdentifier;
isLoading = false;
alertTitle = "";
alertMessage = "";
numAccounts = 0;
async beforeCreate() {

View File

@@ -8,7 +8,8 @@
Start Here
</h1>
<div class="mt-8">
<!-- id used by puppeteer test script -->
<div id="start-question" class="mt-8">
<p class="text-center text-xl mb-4 font-light">
Do you have an identity to import?
</p>

151
src/views/TestView.vue Normal file
View File

@@ -0,0 +1,151 @@
<template>
<QuickNav selected="Profile"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Test
</h1>
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'toast',
text: 'I\'m a toast. Don\'t mind me.',
},
5000,
)
"
class="font-bold uppercase bg-slate-400 text-white px-3 py-2 rounded-md mr-2"
>
Toast (self-dismiss)
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'info',
title: 'Information Alert',
text: 'Just wanted you to know.',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Info
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'success',
title: 'Success Alert',
text: 'Congratulations!',
},
-1,
)
"
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
>
Success
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'warning',
title: 'Warning Alert',
text: 'You might wanna look at this.',
},
-1,
)
"
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
>
Warning
</button>
<button
@click="
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Danger Alert',
text: 'Something terrible has happened!',
},
-1,
)
"
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
>
Danger
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-permission',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif ON
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-mute',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif MUTE
</button>
<button
@click="
this.$notify(
{
group: 'modal',
type: 'notification-off',
},
-1,
)
"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif OFF
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class Help extends Vue {}
</script>

View File

@@ -0,0 +1,63 @@
/* eslint-env serviceworker */
/* global workbox */
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js",
);
self.addEventListener("install", (event) => {
importScripts(
"safari-notifications.js",
"nacl.js",
"noble-curves.js",
"noble-hashes.js",
);
});
self.addEventListener("push", function (event) {
event.waitUntil(
(async () => {
try {
let payload;
if (event.data) {
payload = JSON.parse(event.data.text());
}
const message = await self.getNotificationCount();
const title = payload ? payload.title : "Custom Title";
const options = {
body: message,
icon: payload ? payload.icon : "icon.png",
badge: payload ? payload.badge : "badge.png",
};
await self.registration.showNotification(title, options);
} catch (error) {
console.error("Error in processing the push event:", error);
}
})(),
);
});
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SEND_LOCAL_DATA") {
self.secret = event.data.data;
event.ports[0].postMessage({ success: true });
}
});
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim());
console.log("Service worker activated", event);
});
self.addEventListener("fetch", (event) => {
console.log(event.request);
});
self.addEventListener("error", (event) => {
console.error("Error in Service Worker:", event.message);
console.error("File:", event.filename);
console.error("Line:", event.lineno);
console.error("Column:", event.colno);
console.error("Error Object:", event.error);
});
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);

1051
sw_scripts/nacl.js Normal file

File diff suppressed because it is too large Load Diff

5248
sw_scripts/noble-curves.js Normal file

File diff suppressed because it is too large Load Diff

3068
sw_scripts/noble-hashes.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,545 @@
function bufferFromBase64(base64) {
const binaryString = atob(base64);
const length = binaryString.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
function fromString(str, encoding = "utf8") {
if (encoding === "utf8") {
return new TextEncoder().encode(str);
} else if (encoding === "base16") {
if (str.length % 2 !== 0) {
throw new Error("Invalid hex string length.");
}
let bytes = new Uint8Array(str.length / 2);
for (let i = 0; i < str.length; i += 2) {
bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
}
return bytes;
} else if (encoding === "base64url") {
str = str.replace(/-/g, "+").replace(/_/g, "/");
while (str.length % 4) {
str += "=";
}
return new Uint8Array(bufferFromBase64(str));
} else {
throw new Error(`Unsupported encoding "${encoding}"`);
}
}
/**
* Convert a Uint8Array to a string with the given encoding.
*
* @param {Uint8Array} byteArray - The Uint8Array to convert.
* @param {string} [encoding='utf8'] - The desired encoding ('utf8', 'base16', 'base64url').
* @returns {string} - The encoded string.
* @throws {Error} - Throws an error if the encoding is unsupported.
*/
function toString(byteArray, encoding = "utf8") {
switch (encoding) {
case "utf8":
return decodeUTF8(byteArray);
case "base16":
return toBase16(byteArray);
case "base64url":
return toBase64Url(byteArray);
default:
throw new Error(`Unsupported encoding "${encoding}"`);
}
}
/**
* Decode a Uint8Array as a UTF-8 string.
*
* @param {Uint8Array} byteArray
* @returns {string}
*/
function decodeUTF8(byteArray) {
return new TextDecoder().decode(byteArray);
}
/**
* Convert a Uint8Array to a base16 (hex) encoded string.
*
* @param {Uint8Array} byteArray
* @returns {string}
*/
function toBase16(byteArray) {
return Array.from(byteArray)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
/**
* Convert a Uint8Array to a base64url encoded string.
*
* @param {Uint8Array} byteArray
* @returns {string}
*/
function toBase64Url(byteArray) {
let uint8Array = new Uint8Array(byteArray);
let binaryString = "";
for (let i = 0; i < uint8Array.length; i++) {
binaryString += String.fromCharCode(uint8Array[i]);
}
// Encode to base64
let base64 = btoa(binaryString);
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
const u8a = { toString, fromString };
function sha256(payload) {
const data = typeof payload === "string" ? u8a.fromString(payload) : payload;
return nobleHashes.sha256(data);
}
async function accessToken(identifier) {
const did = identifier["did"];
const privateKeyHex = identifier["keys"][0]["privateKeyHex"];
const signer = await 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 = await createJWT(tokenPayload, {
alg,
issuer: did,
signer,
});
return jwt;
}
async function createJWT(payload, options, header = {}) {
const { issuer, signer, alg, expiresIn, canonicalize } = options;
if (!signer)
throw new Error(
"missing_signer: No Signer functionality has been configured",
);
if (!issuer)
throw new Error("missing_issuer: No issuing DID has been configured");
if (!header.typ) header.typ = "JWT";
if (!header.alg) header.alg = alg;
const timestamps = {
iat: Math.floor(Date.now() / 1000),
exp: undefined,
};
if (expiresIn) {
if (typeof expiresIn === "number") {
timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn);
} else {
throw new Error("invalid_argument: JWT expiresIn is not a number");
}
}
const fullPayload = { ...timestamps, ...payload, iss: issuer };
return createJWS(fullPayload, signer, header, { canonicalize });
}
const defaultAlg = "ES256K";
async function createJWS(payload, signer, header = {}, options = {}) {
if (!header.alg) header.alg = defaultAlg;
const encodedPayload =
typeof payload === "string"
? payload
: encodeSection(payload, options.canonicalize);
const signingInput = [
encodeSection(header, options.canonicalize),
encodedPayload,
].join(".");
const jwtSigner = ES256KSignerAlg(false);
const signature = await jwtSigner(signingInput, signer);
// JWS Compact Serialization
// https://www.rfc-editor.org/rfc/rfc7515#section-7.1
return [signingInput, signature].join(".");
}
function canonicalizeData(object) {
if (typeof object === "number" && isNaN(object)) {
throw new Error("NaN is not allowed");
}
if (typeof object === "number" && !isFinite(object)) {
throw new Error("Infinity is not allowed");
}
if (object === null || typeof object !== "object") {
return JSON.stringify(object);
}
if (object.toJSON instanceof Function) {
return serialize(object.toJSON());
}
if (Array.isArray(object)) {
const values = object.reduce((t, cv, ci) => {
const comma = ci === 0 ? "" : ",";
const value = cv === undefined || typeof cv === "symbol" ? null : cv;
return `${t}${comma}${serialize(value)}`;
}, "");
return `[${values}]`;
}
const values = Object.keys(object)
.sort()
.reduce((t, cv) => {
if (object[cv] === undefined || typeof object[cv] === "symbol") {
return t;
}
const comma = t.length === 0 ? "" : ",";
return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`;
}, "");
return `{${values}}`;
}
function encodeSection(data, shouldCanonicalize = false) {
if (shouldCanonicalize) {
return encodeBase64url(canonicalizeData(data));
} else {
return encodeBase64url(JSON.stringify(data));
}
}
function encodeBase64url(s) {
return bytesToBase64url(u8a.fromString(s));
}
function instanceOfEcdsaSignature(object) {
return typeof object === "object" && "r" in object && "s" in object;
}
function ES256KSignerAlg(recoverable) {
return async function sign(payload, signer) {
const signature = await signer(payload);
if (instanceOfEcdsaSignature(signature)) {
return toJose(signature, recoverable);
} else {
if (
recoverable &&
typeof fromJose(signature).recoveryParam === "undefined"
) {
throw new Error(
`not_supported: ES256K-R not supported when signer doesn't provide a recovery param`,
);
}
return signature;
}
};
}
function leftpad(data, size = 64) {
if (data.length === size) return data;
return "0".repeat(size - data.length) + data;
}
async function SimpleSigner(hexPrivateKey) {
const signer = await ES256KSigner(hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = await signer(data);
return fromJose(signature);
};
}
function hexToBytes(s, minLength) {
let input = s.startsWith("0x") ? s.substring(2) : s;
if (input.length % 2 !== 0) {
input = `0${input}`;
}
if (minLength) {
const paddedLength = Math.max(input.length, minLength * 2);
input = input.padStart(paddedLength, "00");
}
return u8a.fromString(input.toLowerCase(), "base16");
}
function ES256KSigner(privateKey, recoverable = false) {
const privateKeyBytes = privateKey;
if (privateKeyBytes.length !== 32) {
throw new Error(
`bad_key: Invalid private key format. Expecting 32 bytes, but got ${privateKeyBytes.length}`,
);
}
return async function (data) {
const signature = nobleCurves.secp256k1.sign(sha256(data), privateKeyBytes);
return toJose(
{
r: leftpad(signature.r.toString(16)),
s: leftpad(signature.s.toString(16)),
recoveryParam: signature.recovery,
},
recoverable,
);
};
}
function toJose(signature, recoverable) {
const { r, s, recoveryParam } = signature;
const jose = new Uint8Array(recoverable ? 65 : 64);
jose.set(u8a.fromString(r, "base16"), 0);
jose.set(u8a.fromString(s, "base16"), 32);
if (recoverable) {
if (typeof recoveryParam === "undefined") {
throw new Error("Signer did not return a recoveryParam");
}
jose[64] = recoveryParam;
}
return bytesToBase64url(jose);
}
function bytesToBase64url(b) {
return u8a.toString(b, "base64url");
}
function base64ToBytes(s) {
const inputBase64Url = s
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return u8a.fromString(inputBase64Url, "base64url");
}
function bytesToHex(b) {
return u8a.toString(b, "base16");
}
function fromJose(signature) {
const signatureBytes = 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 };
}
function validateBase64(s) {
if (
!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(
s,
)
) {
throw new TypeError("invalid encoding");
}
}
function decodeBase64(s) {
validateBase64(s);
var i,
d = atob(s),
b = new Uint8Array(d.length);
for (i = 0; i < d.length; i++) b[i] = d.charCodeAt(i);
return b;
}
async function getSettingById(id) {
return new Promise((resolve, reject) => {
let openRequest = indexedDB.open("TimeSafari");
openRequest.onupgradeneeded = (event) => {
// Handle database setup if necessary
let db = event.target.result;
if (!db.objectStoreNames.contains("settings")) {
db.createObjectStore("settings", { keyPath: "id" });
}
};
openRequest.onsuccess = (event) => {
let db = event.target.result;
let transaction = db.transaction("settings", "readonly");
let objectStore = transaction.objectStore("settings");
let getRequest = objectStore.get(id);
getRequest.onsuccess = () => resolve(getRequest.result);
getRequest.onerror = () => reject(getRequest.error);
};
openRequest.onerror = () => reject(openRequest.error);
});
}
async function setMostRecentNotified(id) {
try {
const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("settings", "readwrite");
const store = transaction.objectStore("settings");
const data = await getRecord(store, 1);
if (data) {
data["lastNotifiedClaimId"] = id;
await updateRecord(store, data);
} else {
console.error("Record not found");
}
transaction.oncomplete = () => db.close();
} catch (error) {
console.error("Database error: " + error.message);
}
}
function openIndexedDB(dbName) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains("settings")) {
db.createObjectStore("settings");
}
};
});
}
function getRecord(store, key) {
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function updateRecord(store, data) {
return new Promise((resolve, reject) => {
const request = store.put(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function fetchAllAccounts() {
return new Promise((resolve, reject) => {
let openRequest = indexedDB.open("TimeSafariAccounts");
openRequest.onupgradeneeded = function (event) {
let db = event.target.result;
if (!db.objectStoreNames.contains("accounts")) {
db.createObjectStore("accounts", { keyPath: "id" });
}
};
openRequest.onsuccess = function (event) {
let db = event.target.result;
let transaction = db.transaction("accounts", "readonly");
let objectStore = transaction.objectStore("accounts");
let getAllRequest = objectStore.getAll();
getAllRequest.onsuccess = function () {
resolve(getAllRequest.result);
};
getAllRequest.onerror = function () {
reject(getAllRequest.error);
};
};
openRequest.onerror = function () {
reject(openRequest.error);
};
});
}
async function getNotificationCount() {
let secret = null;
let accounts = [];
let result = null;
if ("secret" in self) {
secret = self.secret;
const secretUint8Array = self.decodeBase64(secret);
const settings = await getSettingById(1);
let lastNotifiedClaimId = null;
if ("lastNotifiedClaimId" in settings) {
lastNotifiedClaimId = settings["lastNotifiedClaimId"];
}
const activeDid = settings["activeDid"];
accounts = await fetchAllAccounts();
let did = null;
for (var i = 0; i < accounts.length; i++) {
let account = accounts[i];
let did = account["did"];
if (did == activeDid) {
let publicKeyHex = account["publicKeyHex"];
let identity = account["identity"];
const messageWithNonceAsUint8Array = self.decodeBase64(identity);
const nonce = messageWithNonceAsUint8Array.slice(0, 24);
const message = messageWithNonceAsUint8Array.slice(24, identity.length);
const decoder = new TextDecoder("utf-8");
const decrypted = self.secretbox.open(message, nonce, secretUint8Array);
const msg = decoder.decode(decrypted);
const identifier = JSON.parse(JSON.parse(msg));
const headers = {
"Content-Type": "application/json",
};
headers["Authorization"] = "Bearer " + (await accessToken(identifier));
let response = await fetch(
"https://test-api.endorser.ch/api/v2/report/claims",
{
method: "GET",
headers: headers,
},
);
if (response.status == 200) {
let json = await response.json();
let claims = json["data"];
let newClaims = 0;
for (var i = 0; i < claims.length; i++) {
let claim = claims[i];
if (claim["id"] === lastNotifiedClaimId) {
break;
}
newClaims++;
}
if (newClaims === 0) {
result = "You have no new claims today.";
} else {
result = `${newClaims} have been shared with you`;
}
const most_recent_notified = claims[0]["id"];
await setMostRecentNotified(most_recent_notified);
return "TEST";
} else {
console.error(response.status);
}
}
}
}
return result;
}
self.getNotificationCount = getNotificationCount;
self.decodeBase64 = decodeBase64;

View File

@@ -11,5 +11,9 @@ module.exports = defineConfig({
iconPaths: {
faviconSVG: "img/icons/safari-pinned-tab.svg",
},
workboxPluginMode: "InjectManifest",
workboxOptions: {
swSrc: "./sw_scripts/additional-scripts.js",
},
},
});

View File

@@ -1,3 +1,6 @@
# Overivew of Web Push
Web Push notifications is a web browser messaging protocol defined by the W3C.
Discussions of this interesting technology are clouded because of a
@@ -360,29 +363,29 @@ unsubscribeFromPush().catch((err) => {
NOTE: We could offer an option within the app to "mute" these notifications. This wouldn't turn off the notifications at the browser level, but you could make it so that your Service Worker doesn't display them even if it receives them.
## NOTIFICATION DIALOG WORKFLOW
# NOTIFICATION DIALOG WORKFLOW
# ON APP FIRST-LAUNCH:
## ON APP FIRST-LAUNCH:
The user is periodically presented with the notification permission dialog that asks them if they want to turn on notifications. User is given 3 choices:
- "Turn on Notifications": triggers the browser's own notification permission prompt.
- "Maybe Later": dismisses the dialog, to reappear at a later instance. (The next time the user launches the app? After X amount of days? A combination of both?)
- "Never": dismisses the dialog; app remembers to not automatically present the dialog again.
# IF THE USER CHOOSES "NEVER":
## IF THE USER CHOOSES "NEVER":
The dialog can still be accessed via the Notifications toggle switch in `AccountViewView` (which also tells the user if notifications are turned on or off).
# TO TEMPORARILY MUTE NOTIFICATIONS:
## TO TEMPORARILY MUTE NOTIFICATIONS:
While notifications are turned on, the user can tap on the Mute Notifications toggle switch in `AccountViewView` (visible only when notifications are turned on) to trigger the Mute Notifications Dialog. User is given the following choices:
- Several "Mute for X Hour/s" buttons to temporarily mute notifications.
- "Mute until I turn it back on" button to indefinitely mute notifications.
- "Cancel" to make no changes and dismiss the dialog.
# TO UNMUTE NOTIFICATIONS:
## TO UNMUTE NOTIFICATIONS:
Simply tap on the Mute Notifications toggle switch in `AccountViewView` to immediately unmute notifications. No dialog needed.
# TO TURN OFF NOTIFICATIONS:
## TO TURN OFF NOTIFICATIONS:
While notifications are turned on, the user can tap on the App Notifications toggle switch in `AccountViewView` to trigger the Turn Off Notifications Dialog. User is given the following choices:
- "Turn off Notifications" to fully turn them off (which means the user will need to go through the dialogs agains to turn them back on).