Compare commits

...

79 Commits

Author SHA1 Message Date
d4bf045049 Merge branch 'master' into passkey 2024-06-24 20:20:13 -06:00
1639e7cf25 move & resize the contact edit & info buttons 2024-06-22 12:34:30 -06:00
8f2bebe8ae bump version and add "-beta" 2024-06-22 12:23:57 -06:00
810f307442 bump to v 0.3.14 2024-06-22 12:23:10 -06:00
a4bdd2e922 fix checkbox verbiage when no project is chosen for a give 2024-06-22 12:06:55 -06:00
08e1ce6486 fix prompt for already-registered contacts (plus some verbiage) 2024-06-22 11:47:10 -06:00
e88eea7f36 add BX currency, add link for user's activity, tweak verbiage 2024-06-21 20:33:44 -06:00
ea156fac13 improve messaging when user has no offers or projects 2024-06-21 19:52:35 -06:00
a95d5db24a fix justification of checkboxes and text so they don't move 2024-06-21 19:25:46 -06:00
453256f874 give-detail page: add more-correct parameters from confirm-give page, and allow toggling of project & user-recipient 2024-06-21 19:13:19 -06:00
7bf488d4fe tweak UI for give-confirmation screen 2024-06-21 16:02:08 -06:00
230773a917 add Confirm Gift screen for simpler confirmation 2024-06-20 20:52:26 -06:00
8a64da2b5f modify test payload, add test to help with server verification 2024-06-16 19:42:55 -06:00
cf1137737a allow switching to did:peer ID, remove another unnecessary data point 2024-06-16 10:12:56 -06:00
82f51b6f93 save passkey DID in accounts, consolidate more data 2024-06-16 09:47:51 -06:00
83722e0057 remove duplicate data elements, including public key which is in peer DID 2024-06-15 22:31:21 -06:00
3bb2498e28 refactor, and make separate testing buttons for different approaches 2024-06-15 15:45:29 -06:00
80f05ba9e9 add signing by non-simplewebauthn code 2024-06-14 21:19:03 -06:00
bb555cd6ee finally get something other that simplewebauthn working 2024-06-12 20:26:26 -06:00
1dd7c6e3b1 fully split the signature creation from the verification 2024-06-09 19:04:43 -06:00
e8423b1a00 register with simple-lib browser (works until jwt signature validation) 2024-06-09 06:09:30 -06:00
b4a521c6d4 remove lingering pieces of tests from /start 2024-06-08 15:31:01 -06:00
a64c7c2848 move all passkey testing to /test 2024-06-08 15:29:16 -06:00
37907ee3ad attempt to simply verify something signed with the same library -- doesn't work 2024-06-08 14:19:42 -06:00
43da8586e5 use code from @simplewebauthn/server in hopes of more accurate parsing 2024-05-26 22:17:29 -06:00
94443c93bc gets past code errors and now gotta verify signature (note that had to add Buffer in asn1 lib) 2024-05-26 16:59:58 -06:00
2a675eca6a derive DID for issuer 2024-05-25 18:51:18 -06:00
94fb76cfdc retrieve the correct passkey just created (doesn't validate JWT) 2024-05-24 20:16:34 -06:00
7dde4d4d30 add prompt for a passkey and a start at creating a JWT out of it 2024-05-24 16:30:33 -06:00
79d93994c2 fix dependency vulnerabilities 2024-05-24 11:42:36 -06:00
bab4a62540 bump version and add -beta; enhance help 2024-05-24 10:21:08 -06:00
f84a2c2750 bump to verson 0.3.13 2024-05-24 10:19:17 -06:00
2321e1d6e8 allow link to the large version of a project image 2024-05-24 09:11:20 -06:00
af976ba838 add an image to projects (which shows on all ProjectIcons except for offers) 2024-05-23 20:51:40 -06:00
d08541fdae bump version and add -beta 2024-05-20 08:27:12 -06:00
fa92beed27 update CHANGELOG 2024-05-19 20:03:30 -06:00
9e1ae2abe5 bump to version 0.3.12 2024-05-19 19:57:02 -06:00
ad39ea05c2 fix the photo share_target, and tweak other verbiage 2024-05-19 19:56:25 -06:00
151c8154c4 bump version and add -beta 2024-05-19 19:32:38 -06:00
21a6348afc add a global error handler 2024-05-19 16:25:44 -06:00
210605c8e4 bump to version 0.3.11 (and enhance warning on profile deletion) 2024-05-19 08:39:18 -06:00
33a340326f set the correct active camera number when it starts 2024-05-17 20:24:33 -06:00
3f8596aacc bump version and add -beta 2024-05-17 12:16:23 -06:00
fd112bd447 allow any image URL for gifts & profiles 2024-05-12 21:43:18 -06:00
7d6b210ee1 allow file choice for gift, plus other UI fixes 2024-05-12 17:55:54 -06:00
6c28828c0a fix cropping problem where long images go off the screen 2024-05-12 12:39:16 -06:00
6af239378c bump to v 0.3.10, fix image upload on Chrome 2024-05-12 12:12:59 -06:00
4ff7d908d4 Merge pull request 'add a share_target for people to add a photo' (#115) from share-photo into master
Reviewed-on: #115
2024-05-11 20:03:33 -04:00
17c901b1de add file-chooser to the profile image selection 2024-05-11 12:30:10 -06:00
f7b5dbf4ce style the sharing screen (plus other fixes) 2024-05-11 07:09:48 -06:00
7f02ba29a3 add a share_target for people to add a photo 2024-05-10 13:17:20 -06:00
20c4613533 increment version and add "-beta" 2024-04-28 20:10:39 -06:00
a44fc1d6d0 bump to version 0.3.9 2024-04-28 20:09:56 -06:00
b86543b404 disallow new-project page if not registered 2024-04-28 19:16:29 -06:00
7d0007e4d9 remove verbiage on front page that's now extra 2024-04-28 19:04:44 -06:00
ddd32e7f44 show something to indicate claims were sent (mostly in BVC screens) 2024-04-28 18:36:06 -06:00
8a9bb100ea constantly recheck on home screen if not registered 2024-04-28 17:02:31 -06:00
c48b8246f9 add registration inside contact import, with flag to hide it 2024-04-28 16:18:30 -06:00
b32a3d85e9 add 'registered' flag in contact info 2024-04-28 13:12:26 -06:00
8571c78a53 for scan on QR code screen, import and keep on that screen 2024-04-27 20:33:10 -06:00
eba68e2aaa add tweaks to testing instructions 2024-04-27 14:59:23 -06:00
e2df848e96 add page to view all claims about a DID (which we'll have to restrict to visible people soon) 2024-04-26 20:13:44 -06:00
9acba28b85 fix problem with duplicates in feed, plus some other UI tweaks 2024-04-26 17:05:11 -06:00
bef56fce10 allow loading more gives & offers & plans when limits are hit on project view 2024-04-26 15:44:09 -06:00
fccc4edb63 remove some 'uppercase' CSS markers 2024-04-25 20:17:49 -06:00
0a42edf595 put button directly on contacts page to show the given totals 2024-04-24 20:38:34 -06:00
f4f5fc7730 change remainder of "confirm" calls to better UX 2024-04-24 20:11:38 -06:00
eeaacaf202 replace many of the javascript "confirm" calls with the nicer UX version 2024-04-24 19:52:33 -06:00
d9aebfebd3 remove 'moment' library that's no longer used 2024-04-24 18:56:09 -06:00
7078f7b9e6 add choice of a start date for a project 2024-04-23 20:48:38 -06:00
d316f4924b add note about confirming your own, plus other helpful verbiage, plus notify messages that don't linger 2024-04-23 09:13:57 -06:00
1df2d3ed05 remove message confusion, add project name during give-details 2024-04-21 20:31:57 -06:00
4e877c15f6 change the "give" action on contact page to use dialog box 2024-04-21 16:42:22 -06:00
ef95708d02 add 'offer' on contact screen 2024-04-21 07:38:59 -06:00
7cbdc7a099 add code to display profiles in feed, but deactivate it for now 2024-04-20 19:53:11 -06:00
c748869c44 increment version and add "-beta" 2024-04-20 08:14:53 -06:00
60e11e23d4 bump to v 0.3.8 2024-04-20 08:06:34 -06:00
883687f1c3 make so cropping isn't behind header; delete profile image from storage when deleted 2024-04-19 20:13:44 -06:00
4466ceed99 Merge pull request 'profile-pic' (#114) from profile-pic into master
Reviewed-on: #114
2024-04-19 17:36:53 -04:00
50 changed files with 6349 additions and 2326 deletions

View File

@@ -5,12 +5,73 @@ 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.3.14]
### Added
- Clearer give-confirmation screen
- BX currency https://thebx.medium.com/
- Deselection of project on gifted details page
### Fixed
- Don't show registration pop-up for a new contact that is registered
### Changed in DB or environment
- Nothing
## [0.3.7] - 2024.04-10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
### Added
- Photos on projects
### Changed in DB or environment
- Nothing
## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
### Fixed
- Photo share (share_target) failed because requests were sent to server
### Changed in DB or environment
- Nothing
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
### Added
- Choose a file for gifts, and a URL for gifts & profiles
### Fixed
- Multiple button pushes were required to switch camera
### Changed in DB or environment
- Nothing
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
### Added
- Share an image
- Choose a file on the device for a profile image
### Changed in DB or environment
- Nothing
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
### Added
- Offers on contacts page
- Checks on front page until they show as registered
### Changed
- Scanned contacts now add immediately and prompt for registration.
- Better UI for gives on contact page
- Better UI for all confirmation messages
### Fixed
- Repeated elements at top of main feed
### Changed in DB or environment
- Nothing
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
### Added
- Profile image for user
### Fixed
- Slow loading of home page feed
### Changed in DB or environment
- Nothing
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
### Added
- Filter on home page feed
- Ability to set time of daily notification

View File

@@ -10,38 +10,38 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
```
npm install
```
### Compiles and hot-reloads for development
### Compile and hot-reloads for development
```
npm run dev
```
### Builds the production app
### Build the test & production app
```
npm run serve
```
### Lints and fixes files
### Lint and fix files
```
npm run lint
```
### Compiles and minifies for production
### Compile and minify for test & production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
* `npx prettier --write ./sw_scripts/`
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`.
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Record what version is currently on production.
* Run the correct build
* Run the correct build:
* Test
```
@@ -95,9 +95,9 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
### Manual walk-through test
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act.
- Use a mobile user as well as a desktop user.
- Backup seed & data & get a CSV dump from Endorser Mobile.
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
- Use a mobile user as well as a desktop user.
- Check that the version is updated.
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
- Make sure that it's using the test API (under Identity in 'Advanced').
@@ -109,15 +109,19 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
- Copy the contact URL.
- On each page, verify the messaging, and that they cannot take action.
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
- On the contacts page, check that they can add User #0 even without their own ID.
- As User #0 in another browser on the test API, add a give & a project.
- `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
- With the new user on the home page, see the feed that shows User #0 in network but without the name.
- As the new user on the contacts page, add User #0 as a contact.
- On the home page, see the feed that shows User #0 with a name.
- On the contacts page, check that they can add a contact even without their own ID.
- Install the PWA.
- As User 0 in another browser on the test API, add a give & a project.
- Note that some combinations of desktop with mobile emulation stretch the image.
- Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
- Add new user as a contact (which allows them to see User 0).
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
- As the new user, import contacts & identifiers.
- As the new user on the contacts page, add User 0 as a contact.
- On the home page, see the feed that shows User 0 with a name.
- Switch back to the generated identifier.
- On the account page, check that they see messages on limits.
- As User #0, register the ID.
- As User 0, register the ID.
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
- On the contacts page, check that they cannot register someone else yet.
- Walk through the functions on each page.
@@ -125,6 +129,7 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
- Export & import, both seed and contacts & settings.
- Choose location on the search map.
- Offer, deliver a give, and confirm. Create a third user and test connections.
- On mobile, share an image with the app.
- Switch to "no identifier" to see that things look OK without any ID.
### Clear/Reset data & restart

2468
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{
"name": "TimeSafari",
"version": "0.3.8-beta",
"private": true,
"version": "0.3.15-beta",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@@ -17,20 +16,25 @@
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@types/js-yaml": "^4.0.9",
"@types/luxon": "^3.4.2",
"@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0",
"@veramo/did-manager": "^5.6.0",
"@veramo/did-provider-ethr": "^5.6.0",
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0",
"@vueuse/core": "^10.9.0",
"@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.1",
@@ -45,7 +49,6 @@
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"moment": "^2.30.1",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
@@ -68,7 +71,9 @@
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/ramda": "^0.29.11",
"@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39",

View File

@@ -129,7 +129,10 @@
</div>
</NotificationGroup>
<!-- These are general-purpose messages - except there are some for turning app notifications on and off. -->
<!--
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
@@ -143,13 +146,19 @@
move="transition duration-500"
move-delay="delay-300"
>
<!-- see NotificationIface in constants/app.ts -->
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<!-- type "confirm" will post a message and, with onYes function, show a "Yes" button to call that function -->
<!--
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
-->
<div
v-if="notification.type === 'confirm'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -158,9 +167,10 @@
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p class="text-lg mb-4">
<span class="font-semibold text-lg">
{{ notification.title }}
</p>
</span>
<p class="text-sm mb-2">{{ notification.text }}</p>
<button
v-if="notification.onYes"
@@ -174,7 +184,51 @@
</button>
<button
@click="close(notification.id)"
v-if="notification.onNo"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No
</button>
<label
v-if="notification.promptToStopAsking && notification.onNo"
for="toggleStopAsking"
class="flex items-center justify-between cursor-pointer my-4"
@click="stopAsking = !stopAsking"
>
<!-- label -->
<span class="ml-2">... and do not ask again.</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
name="stopAsking"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<button
@click="
notification.onCancel
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
{{ notification.onYes ? "Cancel" : "Close" }}
@@ -358,6 +412,7 @@ import { sendTestThroughPushServer } from "@/libs/util";
export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
b64 = "";
hourAm = true;
hourInput = "8";

View File

@@ -17,9 +17,16 @@ export default class EntityIcon extends Vue {
generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) {
return `<img src="${imageUrl}" alt="avatar" width="${this.iconSize}" height="${this.iconSize}" />`;
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} else {
const identifier = this.contact?.did || this.entityId;
if (!identifier) {
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = {
seed: (identifier as string) || "",
size: this.iconSize,

View File

@@ -2,12 +2,12 @@
<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 named" }}
{{ customTitle }}
</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
placeholder="What was given"
v-model="description"
/>
<div class="flex flex-row justify-center">
@@ -15,7 +15,7 @@
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] }}
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@@ -45,15 +45,16 @@
description,
giverDid: giver?.did,
giverName: giver?.name,
message,
offerId,
projectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode,
},
}"
class="text-blue-500"
>
Photo, ...
Photo & Details ...
</router-link>
</span>
</div>
@@ -90,7 +91,7 @@ import { NotificationIface } from "@/constants/app";
import {
createAndSubmitGive,
didInfo,
GiverInputInfo,
GiverReceiverInputInfo,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accountsDB, db } from "@/db/index";
@@ -101,9 +102,7 @@ import { Contact } from "@/db/tables/contacts";
export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop message = "";
@Prop projectId = "";
@Prop showGivenToUser = false;
activeDid = "";
allContacts: Array<Contact> = [];
@@ -111,22 +110,32 @@ export default class GiftedDialog extends Vue {
apiServer = "";
amountInput = "0";
callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string;
description = "";
givenToUser = false;
giver?: GiverInputInfo; // undefined means no identified giver agent
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = "";
receiver?: GiverReceiverInputInfo;
unitCode = "HUR";
visible = false;
libsUtil = libsUtil;
async open(giver?: GiverInputInfo, offerId?: string) {
async open(
giver?: GiverReceiverInputInfo,
receiver?: GiverReceiverInputInfo,
offerId?: string,
customTitle?: string,
callbackOnSuccess?: (amount: number) => void,
) {
this.customTitle = customTitle;
this.description = "";
this.giver = giver || {};
this.giver = giver;
this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true
this.givenToUser = this.showGivenToUser;
this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || "";
try {
@@ -141,7 +150,7 @@ export default class GiftedDialog extends Vue {
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
if (!this.giver.name) {
if (this.giver && !this.giver.name) {
this.giver.name = didInfo(
this.giver.did,
this.activeDid,
@@ -196,7 +205,6 @@ export default class GiftedDialog extends Vue {
eraseValues() {
this.description = "";
this.giver = undefined;
this.givenToUser = this.showGivenToUser;
this.amountInput = "0";
this.unitCode = "HUR";
}
@@ -254,6 +262,7 @@ export default class GiftedDialog extends Vue {
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive(
(this.giver?.did as string) || null,
(this.receiver?.did as string) || null,
this.description,
parseFloat(this.amountInput),
this.unitCode,
@@ -265,14 +274,16 @@ export default class GiftedDialog extends Vue {
/**
*
* @param giverDid may be null
* @param recipientDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param amount may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive(
async recordGive(
giverDid: string | null,
recipientDid: string | null,
description: string,
amountInput: number,
amount: number,
unitCode: string = "HUR",
) {
try {
@@ -282,9 +293,9 @@ export default class GiftedDialog extends Vue {
this.apiServer,
identity,
giverDid,
this.givenToUser ? this.activeDid : undefined,
this.receiver?.did as string,
description,
amountInput,
amount,
unitCode,
this.projectId,
this.offerId,
@@ -316,11 +327,14 @@ export default class GiftedDialog extends Vue {
},
7000,
);
if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const message =
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
@@ -329,7 +343,7 @@ export default class GiftedDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: message,
text: errorMessage,
},
-1,
);

View File

@@ -0,0 +1,177 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
Camera or Other?
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</div>
<div>
<div class="text-center mt-8">
<div class>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog()"
/>
</div>
<div class="mt-4">
<input type="file" @change="uploadImageFile" />
</div>
<div class="mt-4">
<span class="mt-2">
... or paste a URL:
<input type="text" v-model="imageUrl" class="border-2" />
</span>
<span class="ml-2">
<fa
v-if="imageUrl"
icon="check"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl"
/>
<!-- so that there's no shifting when it becomes visible -->
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
</span>
</div>
</div>
</div>
</div>
</div>
<PhotoDialog ref="photoDialog" />
</template>
<script lang="ts">
import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "@/components/PhotoDialog.vue";
import { NotificationIface } from "@/constants/app";
const inputImageFileNameRef = ref<Blob>();
@Component({
components: { PhotoDialog },
})
export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string;
crop: boolean = false;
imageCallback: (imageUrl?: string) => void = () => {};
imageUrl?: string;
visible = false;
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName,
);
}
async uploadImageFile(event: Event) {
this.visible = false;
inputImageFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.openPhotoDialog(blob, file.name as string);
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
async acceptUrl() {
this.visible = false;
if (this.crop) {
try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob
});
const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error retrieving that image.",
},
5000,
);
}
} else {
this.imageCallback(this.imageUrl);
}
}
close() {
this.visible = false;
}
}
</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: 700px;
}
</style>

View File

@@ -43,7 +43,7 @@
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
:placeholder="datePlaceholder()"
v-model="expirationDateInput"
/>
</div>
@@ -69,6 +69,7 @@
</template>
<script lang="ts">
import { DateTime } from "luxon";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@@ -81,8 +82,7 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop message = "";
@Prop projectId = "";
@Prop projectId? = "";
activeDid = "";
apiServer = "";
@@ -91,16 +91,20 @@ export default class OfferDialog extends Vue {
amountUnitCode = "HUR";
description = "";
expirationDateInput = "";
recipientDid? = "";
visible = false;
libsUtil = libsUtil;
async open() {
async open(recipientDid?: string) {
try {
this.recipientDid = recipientDid;
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.error("Error retrieving settings from database:", err);
@@ -140,6 +144,12 @@ export default class OfferDialog extends Vue {
)}`;
}
datePlaceholder() {
return (
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
);
}
cancel() {
this.close();
this.eraseValues();
@@ -222,6 +232,7 @@ export default class OfferDialog extends Vue {
amount,
unitCode,
expirationDateInput,
this.recipientDid,
this.projectId,
);

View File

@@ -4,7 +4,7 @@
<div class="text-lg text-center font-light relative z-50">
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-2 bg-black/50 text-white leading-none"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
<span v-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span>
@@ -12,7 +12,7 @@
</div>
<div
class="text-lg text-center p-2 leading-none absolute right-0 top-0 text-white"
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
@@ -29,17 +29,16 @@
<div v-if="crop">
<VuePictureCropper
:boxStyle="{
width: '100%',
height: '100%',
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="URL.createObjectURL(blob)"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 9 / 9,
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
<!-- This gives a round cropper.
:presetMode="{
@@ -49,7 +48,10 @@
</div>
<div v-else>
<div class="flex justify-center">
<img :src="URL.createObjectURL(blob)" class="mt-2 rounded" />
<img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
/>
</div>
</div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
@@ -60,7 +62,10 @@
<span>Upload</span>
</button>
</div>
<div class="absolute bottom-[1rem] right-[1rem] px-2 py-1">
<div
v-if="showRetry"
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button
@click="retryImage"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
@@ -127,17 +132,19 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera, VuePictureCropper } })
export default class GiftedPhotoDialog extends Vue {
export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0;
activeDid = "";
blob: Blob | null = null;
claimType = "GiveAction";
blob?: Blob;
claimType = "";
crop = false;
fileName?: string;
mirror = false;
numDevices = 0;
setImage: (arg: string) => void = () => {};
setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false;
visible = false;
@@ -163,15 +170,31 @@ export default class GiftedPhotoDialog extends Vue {
}
}
open(setImageFn: (arg: string) => void, crop?: boolean, claimType?: string) {
open(
setImageFn: (arg: string) => void,
claimType: string,
crop?: boolean,
blob?: Blob, // for image upload, just to use the cropping function
inputFileName?: string,
) {
this.visible = true;
this.claimType = claimType;
this.crop = !!crop;
this.claimType = claimType || "GiveAction";
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "none";
}
this.setImage = setImageFn;
this.setImageCallback = setImageFn;
if (blob) {
this.blob = blob;
this.fileName = inputFileName;
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false;
} else {
this.blob = undefined;
this.fileName = undefined;
this.showRetry = true;
}
}
close() {
@@ -180,7 +203,7 @@ export default class GiftedPhotoDialog extends Vue {
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = null;
this.blob = undefined;
}
async cameraStarted() {
@@ -188,6 +211,12 @@ export default class GiftedPhotoDialog extends Vue {
if (cameraComponent) {
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
this.mirror = cameraComponent.facingMode === "user";
// figure out which device is active
const currentDeviceId = cameraComponent.currentDeviceID();
const devices = await cameraComponent.devices(["videoinput"]);
this.activeDeviceNumber = devices.findIndex(
(device) => device.deviceId === currentDeviceId,
);
}
}
@@ -195,7 +224,9 @@ export default class GiftedPhotoDialog extends Vue {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
const devices = await cameraComponent?.devices(["videoinput"]);
cameraComponent?.changeCamera(devices[this.activeDeviceNumber].deviceId);
await cameraComponent?.changeCamera(
devices[this.activeDeviceNumber].deviceId,
);
}
async takeImage(/* payload: MouseEvent */) {
@@ -236,10 +267,13 @@ export default class GiftedPhotoDialog extends Vue {
// The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob = await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
}); // png is default; if that changes, change extension in formData.append
this.blob =
(await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) {
this.$notify(
{
@@ -254,8 +288,12 @@ export default class GiftedPhotoDialog extends Vue {
}
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = null;
this.blob = undefined;
}
/****
@@ -307,7 +345,7 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = true;
if (this.crop) {
this.blob = await cropper?.getBlob();
this.blob = (await cropper?.getBlob()) || undefined;
}
const identifier = await getIdentity(this.activeDid);
@@ -330,7 +368,7 @@ export default class GiftedPhotoDialog extends Vue {
this.uploading = false;
return;
}
formData.append("image", this.blob, "snapshot.png"); // png is set in snapshot()
formData.append("image", this.blob, this.fileName || "snapshot.png");
formData.append("claimType", this.claimType);
try {
const response = await axios.post(
@@ -340,9 +378,8 @@ export default class GiftedPhotoDialog extends Vue {
);
this.uploading = false;
this.visible = false;
this.blob = null;
this.setImage(response.data.url as string);
this.close();
this.setImageCallback(response.data.url as string);
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
@@ -350,12 +387,12 @@ export default class GiftedPhotoDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture. Please try again.",
text: "There was an error saving the picture.",
},
5000,
);
this.uploading = false;
this.blob = null;
this.blob = undefined;
}
}

View File

@@ -1,5 +1,17 @@
<template>
<div v-html="generateIdenticon()" class="w-fit"></div>
<a
v-if="linkToFull && imageUrl"
:href="imageUrl"
target="_blank"
class="h-full w-full object-contain"
>
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
</a>
<div
v-else
v-html="generateIdenticon()"
class="h-full w-full object-contain"
/>
</template>
<script lang="ts">
import { toSvg } from "jdenticon";
@@ -21,11 +33,17 @@ const BLANK_CONFIG = {
export default class ProjectIcon extends Vue {
@Prop entityId = "";
@Prop iconSize = 0;
@Prop imageUrl = "";
@Prop linkToFull = false;
generateIdenticon() {
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
if (this.imageUrl) {
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
} else {
const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString;
}
}
}
</script>

View File

@@ -12,7 +12,7 @@
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw"></fa>
<fa icon="house-chimney" class="fa-fw" />
</router-link>
</li>
<!-- Search -->
@@ -28,7 +28,7 @@
:to="{ name: 'discover' }"
class="block text-center py-3 px-1"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
<fa icon="magnifying-glass" class="fa-fw" />
</router-link>
</li>
<!-- Projects -->
@@ -44,7 +44,7 @@
:to="{ name: 'projects' }"
class="block text-center py-3 px-1"
>
<fa icon="folder-open" class="fa-fw"></fa>
<fa icon="hand" class="fa-fw" />
</router-link>
</li>
<!-- Contacts -->
@@ -60,7 +60,7 @@
:to="{ name: 'contacts' }"
class="block text-center py-3 px-1"
>
<fa icon="users" class="fa-fw"></fa>
<fa icon="users" class="fa-fw" />
</router-link>
</li>
<!-- Profile -->
@@ -76,7 +76,7 @@
:to="{ name: 'account' }"
class="block text-center py-3 px-1"
>
<fa icon="circle-user" class="fa-fw"></fa>
<fa icon="circle-user" class="fa-fw" />
</router-link>
</li>
</ul>

View File

@@ -30,6 +30,8 @@ export const DEFAULT_IMAGE_API_SERVER =
export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = "profile";
/**
* The possible values for "group" and "type" are in App.vue.
* From the notiwind package
@@ -38,6 +40,9 @@ export interface NotificationIface {
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text: string;
text?: string;
onCancel?: (stopAsking: boolean) => Promise<void>;
onNo?: (stopAsking: boolean) => Promise<void>;
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
}

View File

@@ -8,6 +8,7 @@ import {
Settings,
SettingsSchema,
} from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data
@@ -16,6 +17,7 @@ type NonsensitiveTables = {
contacts: Table<Contact>;
logs: Table<Log>;
settings: Table<Settings>;
temp: Table<Temp>;
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
@@ -25,14 +27,7 @@ export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
// 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 = {
...ContactSchema,
...LogSchema,
...SettingsSchema,
};
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
@@ -42,11 +37,18 @@ if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
// Define the schema for our databases
accountsDB.version(1).stores(SensitiveSchemas);
// v1 was contacts & settings
// v2 added logs
db.version(2).stores(NonsensitiveSchemas);
// Define the schemas for our databases
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
accountsDB.version(1).stores(AccountsSchema);
// v1 also had contacts & settings
// v2 added Log
db.version(2).stores({
...ContactSchema,
...LogSchema,
...SettingsSchema,
});
// v3 added Temp
db.version(3).stores(TempSchema);
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", () => {

View File

@@ -3,41 +3,46 @@
*/
export type Account = {
/**
* Auto-generated ID by Dexie.
* Auto-generated ID by Dexie
*/
id?: number;
/**
* The date the account was created.
* The date the account was created
*/
dateCreated: string;
/**
* The derivation path for the account.
* The derivation path for the account, if this is from a mnemonic
*/
derivationPath: string;
derivationPath?: string;
/**
* Decentralized Identifier (DID) for the account.
* Decentralized Identifier (DID) for the account
*/
did: string;
/**
* Stringified JSON containing underlying key material.
* Based on the IIdentifier type from Veramo.
* Stringified JSON containing underlying key material, if generated from a mnemonic
* Based on the IIdentifier type from Veramo
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
*/
identity: string;
identity?: string;
/**
* The public key in hexadecimal format.
* The mnemonic phrase for the account, if this is from a mnemonic
*/
mnemonic?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
/**
* The public key in hexadecimal format
*/
publicKeyHex: string;
/**
* The mnemonic passphrase for the account.
*/
mnemonic: string;
};
/**

View File

@@ -21,6 +21,7 @@ export type Settings = {
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
firstName?: string; // user's full name
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean;
lastName?: string; // deprecated - put all names in firstName
lastNotifiedClaimId?: string;
@@ -36,7 +37,7 @@ export type Settings = {
}>;
showContactGivesInline?: boolean; // Display contact inline or not
showShortcutBvc?: boolean; // Show shortcut for BVC actions
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server

13
src/db/tables/temp.ts Normal file
View File

@@ -0,0 +1,13 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = {
id: string;
blob?: Blob;
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = {
temp: "id",
};

View File

@@ -11,6 +11,8 @@ import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
export const LOCAL_KMS_NAME = "local";
/**
*
*
@@ -31,7 +33,7 @@ export const newIdentifier = (
keys: [
{
kid: publicHex,
kms: "local",
kms: LOCAL_KMS_NAME,
meta: { derivationPath: derivationPath },
privateKeyHex: privateHex,
publicKeyHex: publicHex,
@@ -64,6 +66,10 @@ export const deriveAddress = (
return [address, privateHex, publicHex, derivationPath];
};
export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes);
};
/**
*
*

View File

@@ -0,0 +1,102 @@
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
/**
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
*
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
*/
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]);
return finalSignature;
}
/**
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence
* should be removed based on the following logic:
*
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0,
* then remove the leading 0x0 byte"
*/
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
/**
* Combine multiple Uint8Arrays into a single Uint8Array
*/
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: unknown = undefined;
export function getWebCrypto() {
/**
* Hello there! If you came here wondering why this method is asynchronous when use of
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()`
* become synchronous if we make this synchronous (since nothing else in that method is async)
* which represents a breaking API change in this library's core API.
*
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense
* to keep this method asynchronous.
*/
const toResolve = new Promise((resolve, reject) => {
if (webCrypto) {
return resolve(webCrypto);
}
/**
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
* support (and Node v20+)
*/
const _globalThisCrypto = _getWebCryptoInternals.stubThisGlobalThisCrypto();
if (_globalThisCrypto) {
webCrypto = _globalThisCrypto;
return resolve(webCrypto);
}
// We tried to access it both in Node and globally, so bail out
return reject(new MissingWebCrypto());
});
return toResolve;
}
export class MissingWebCrypto extends Error {
constructor() {
const message = "An instance of the Crypto API could not be located";
super(message);
this.name = "MissingWebCrypto";
}
}
// Make it possible to stub return values during testing
export const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: unknown) => {
webCrypto = newCrypto;
},
};

570
src/libs/didPeer.ts Normal file
View File

@@ -0,0 +1,570 @@
import asn1 from "asn1-ber";
import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x";
import { bytesToMultibase, JWTPayload, multibaseToBytes } from "did-jwt";
import { DIDResolutionResult } from "did-resolver";
import { sha256 } from "ethereum-cryptography/sha256.js";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
import {
Base64URLString,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/types";
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers";
const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export async function registerCredential(passkeyName?: string) {
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
rpName: "Time Safari",
rpID: window.location.hostname,
userName: passkeyName || "Time Safari User",
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: "none",
authenticatorSelection: {
// Defaults
residentKey: "preferred",
userVerification: "preferred",
// Optional
authenticatorAttachment: "platform",
},
});
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
// with pubKeyCredParams: { type: "public-key", alg: -7 }
const attResp = await startRegistration(options);
const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: options.challenge,
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
});
// references for parsing auth data and getting the public key
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) {
console.log("Warning! The raw ID does not match the credential ID.")
}
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),
).toString("hex");
const { publicKeyJwk } = cborToKeys(
verification.registrationInfo?.credentialPublicKey as Uint8Array,
);
return {
authData: verification.registrationInfo?.attestationObject,
credIdHex: credIdHex,
publicKeyJwk: publicKeyJwk,
publicKeyBytes: verification.registrationInfo
?.credentialPublicKey as Uint8Array,
};
}
export function createPeerDid(publicKeyBytes: Uint8Array) {
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
const methodSpecificId = bytesToMultibase(
publicKeyBytes,
"base58btc",
"p256-pub",
);
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
}
function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
export class PeerSetup {
public authenticatorData?: ArrayBuffer;
public challenge?: Uint8Array;
public clientDataJsonBase64Url?: Base64URLString;
public signature?: Base64URLString;
public async createJwtSimplewebauthn(
issuerDid: string,
payload: object,
credIdHex: string,
) {
const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const fullPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
// const payloadHash: Uint8Array = sha256(this.challenge);
const options: PublicKeyCredentialRequestOptionsJSON =
await generateAuthenticationOptions({
challenge: this.challenge,
rpID: window.location.hostname,
allowCredentials: [{ id: credentialId }],
});
// console.log("simple authentication options", options);
const clientAuth = await startAuthentication(options);
// console.log("simple credential get", clientAuth);
const authenticatorDataBase64Url = clientAuth.response.authenticatorData;
this.authenticatorData = Buffer.from(
clientAuth.response.authenticatorData,
"base64",
).buffer;
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON;
// console.log("simple authenticatorData for signing", this.authenticatorData);
this.signature = clientAuth.response.signature;
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const signature = clientAuth.response.signature;
return headerBase64 + "." + payloadBase64 + "." + signature;
}
public async createJwtNavigator(
issuerDid: string,
payload: object,
credIdHex: string,
) {
const fullPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataToSignString = JSON.stringify(fullPayload);
const dataToSignBuffer = Buffer.from(dataToSignString);
const credentialId = Buffer.from(credIdHex, "hex");
// console.log("lower credentialId", credentialId);
this.challenge = new Uint8Array(dataToSignBuffer);
const options = {
publicKey: {
allowCredentials: [
{
id: credentialId,
type: "public-key",
},
],
challenge: this.challenge.buffer,
rpID: window.location.hostname,
userVerification: "preferred",
},
};
const credential = await navigator.credentials.get(options);
// console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData,
);
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.response.clientDataJSON,
);
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature)
.toString("base64")
this.signature = origSignature
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
return jwt;
}
// return a low-level signing function, similar to createJWS approach
// async webAuthnES256KSigner(credentialID: string) {
// return async (data: string | Uint8Array) => {
// // get signature from WebAuthn
// const signature = await this.generateWebAuthnSignature(data);
//
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
// const signatureBuffer = Buffer.from(signature);
// console.log("lower signature inside signer", signature);
// console.log("lower buffer signature inside signer", signatureBuffer);
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
// // Decode the DER-encoded signature to extract R and S values
// const reader = new asn1.BerReader(signatureBuffer);
// console.log("lower after reader");
// reader.readSequence();
// console.log("lower after read sequence");
// const r = reader.readString(asn1.Ber.Integer, true);
// console.log("lower after r");
// const s = reader.readString(asn1.Ber.Integer, true);
// console.log("lower after r & s");
//
// // Ensure R and S are 32 bytes each
// const rBuffer = Buffer.from(r);
// const sBuffer = Buffer.from(s);
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
// const rPadded =
// rWithoutPrefix.length < 32
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
// : rWithoutPrefix;
// const sPadded =
// rWithoutPrefix.length < 32
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
// : sWithoutPrefix;
//
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
// console.log(
// "lower combinedSignature",
// combinedSignature.length,
// combinedSignature,
// );
//
// const combSig64 = combinedSignature.toString("base64");
// console.log("lower combSig64", combSig64);
// const combSig64Url = combSig64
// .replace(/\+/g, "-")
// .replace(/\//g, "_")
// .replace(/=+$/, "");
// console.log("lower combSig64Url", combSig64Url);
// return combSig64Url;
// };
// }
}
// I'd love to use this but it doesn't verify.
// Requires:
// npm install @noble/curves
// ... and this import:
// import { p256 } from "@noble/curves/p256";
export async function verifyJwtP256(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
const isValid = p256.verify(
finalSigBuffer,
new Uint8Array(preimage),
publicKeyBytes,
);
return isValid;
}
export async function verifyJwtSimplewebauthn(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const credId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const authOpts: VerifyAuthenticationResponseOpts = {
authenticator: {
credentialID: credId,
credentialPublicKey: publicKeyBytes,
counter: 0,
},
expectedChallenge: arrayToBase64Url(challenge),
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
response: {
authenticatorAttachment: "platform",
clientExtensionResults: {},
id: credId,
rawId: credId,
response: {
authenticatorData: authData,
clientDataJSON: clientDataJsonBase64Url,
signature: signature,
},
type: "public-key",
},
};
const verification = await verifyAuthenticationResponse(authOpts);
return verification.verified;
}
export async function verifyJwtWebCrypto(
credId: Base64URLString,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const WebCrypto = await getWebCrypto();
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"],
);
const verified = await WebCrypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSigBuffer,
preimage,
);
return verified;
}
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) {
throw new Error(
"This only verifies a peer DID, method 0, encoded base58btc.",
);
}
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is the @aviarytech/did-peer resolver)
const id = did.split(":")[2];
const multibase = id.slice(1);
const encnumbasis = multibase.slice(1);
const didDocument = {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1",
],
assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis],
capabilityDelegation: [did + "#" + encnumbasis],
capabilityInvocation: [did + "#" + encnumbasis],
id: did,
keyAgreement: undefined,
service: undefined,
verificationMethod: [
{
controller: did,
id: did + "#" + encnumbasis,
publicKeyMultibase: multibase,
type: "EcdsaSecp256k1VerificationKey2019",
},
],
};
return {
didDocument,
didDocumentMetadata: {},
didResolutionMetadata: { contentType: "application/did+ld+json" },
};
}
// convert COSE public key to PEM format
function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format
const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")}
-----END PUBLIC KEY-----`;
return pem;
}
function base64urlDecode(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
function base64urlEncode(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer) {
const bytes = new Uint8Array(buffer);
let str = "";
for (const charCode of bytes) {
str += String.fromCharCode(charCode);
}
const base64String = btoa(str);
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
// from @simplewebauthn/browser
function base64URLStringToArrayBuffer(base64URLString: string) {
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (base64.length % 4)) % 4;
const padded = base64.padEnd(base64.length + padLength, "=");
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buffer;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes);
if (
jwkObj[1] != 2 || // kty "EC"
jwkObj[3] != -7 || // alg "ES256"
jwkObj[-1] != 1 || // crv "P-256"
jwkObj[-2].length != 32 || // x
jwkObj[-3].length != 32 // y
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2]),
y: arrayToBase64Url(jwkObj[-3]),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2]),
Buffer.from(jwkObj[-3]),
]);
return { publicKeyJwk, publicKeyBuffer };
}
async function pemToCryptoKey(pem: string) {
const binaryDerString = atob(
pem
.split("\n")
.filter((x) => !x.includes("-----"))
.join(""),
);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
// console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey(
"spki",
binaryDer.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["verify"],
);
}

View File

@@ -1,11 +1,19 @@
import { Axios, AxiosResponse, RawAxiosRequestHeaders } from "axios";
import {
Axios,
AxiosRequestConfig,
AxiosResponse,
RawAxiosRequestHeaders,
} from "axios";
import * as didJwt from "did-jwt";
import { LRUCache } from "lru-cache";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app";
import { Contact } from "@/db/tables/contacts";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import { NonsensitiveDexie } from "@/db/index";
import { getIdentity } from "@/libs/util";
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
@@ -27,14 +35,14 @@ export interface AgreeVerifiableCredential {
object: Record<string, any>;
}
export interface GiverInputInfo {
export interface GiverReceiverInputInfo {
did?: string;
name?: string;
}
export interface GiverOutputInfo {
action: string;
giver?: GiverInputInfo;
giver?: GiverReceiverInputInfo;
description?: string;
amount?: number;
unitCode?: string;
@@ -52,7 +60,7 @@ export interface GenericVerifiableCredential {
}
export interface GenericCredWrapper extends GenericVerifiableCredential {
handleId?: string;
handleId: string;
id: string;
issuedAt: string;
issuer: string;
@@ -64,6 +72,7 @@ export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
claim: {},
handleId: "",
id: "",
issuedAt: "",
issuer: "",
@@ -109,6 +118,7 @@ export interface PlanSummaryRecord {
endTime?: string;
fulfillsPlanHandleId: string;
handleId: string;
image?: string;
issuerDid: string;
locLat?: number;
locLon?: number;
@@ -143,6 +153,7 @@ export interface OfferVerifiableCredential {
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
};
offeredBy?: { identifier: string };
recipient?: { identifier: string };
validThrough?: string;
}
@@ -165,7 +176,7 @@ export interface PlanVerifiableCredential {
* Represents data about a project
*
* @deprecated
* We should use PlanServerRecord instead.
* We should use PlanSummaryRecord instead.
**/
export interface PlanData {
/**
@@ -180,6 +191,7 @@ export interface PlanData {
* URL referencing information about the project
**/
handleId: string;
image?: string;
/**
* The DID of the issuer
*/
@@ -394,7 +406,8 @@ export function contactForDid(
* @param activeDid
* @param contact
* @param allMyDids
* @return { known: boolean, displayName: string } where known is true if the display name is some easily-recogizable name, false if it's a generic name like "Someone Anonymous"
* @return { known: boolean, displayName: string, profileImageUrl?: string }
* where 'known' is true if the display name is some easily-recogizable name, false if it's a generic name like "Someone Unnamed"
*/
export function didInfoForContact(
did: string | undefined,
@@ -402,15 +415,16 @@ export function didInfoForContact(
contact?: Contact,
allMyDids: string[] = [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): { known: boolean; displayName: string } {
if (!did) return { displayName: "Someone Anonymous", known: false };
if (contact) {
): { known: boolean; displayName: string; profileImageUrl?: string } {
if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
if (did === activeDid) {
return { displayName: "You", known: true };
} else if (contact) {
return {
displayName: contact.name || "Contact With No Name",
known: !!contact.name,
profileImageUrl: contact.profileImageUrl,
};
} else if (did === activeDid) {
return { displayName: "You", known: true };
} else {
const myId = R.find(R.equals(did), allMyDids);
return myId
@@ -451,8 +465,8 @@ async function getHeaders(identity: IIdentifier | null) {
}
/**
* @param handleId nullable -- which means that "undefined" will be returned
* @param identity nullable -- which means no private info will be returned
* @param handleId nullable, in which case "undefined" will be returned
* @param identity nullable, in which case no private info will be returned
* @param axios
* @param apiServer
*/
@@ -461,7 +475,7 @@ export async function getPlanFromCache(
identity: IIdentifier | null,
axios: Axios,
apiServer: string,
) {
): Promise<PlanSummaryRecord | undefined> {
if (!handleId) {
return undefined;
}
@@ -478,7 +492,7 @@ export async function getPlanFromCache(
cred = resp.data.data[0];
planCache.set(handleId, cred);
} else {
console.log(
console.error(
"Failed to load plan with handle",
handleId,
" Got data:",
@@ -486,7 +500,7 @@ export async function getPlanFromCache(
);
}
} catch (error) {
console.log(
console.error(
"Failed to load plan with handle",
handleId,
" Got error:",
@@ -580,6 +594,7 @@ export async function createAndSubmitOffer(
amount?: number,
unitCode?: string,
expirationDate?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = {
@@ -597,6 +612,9 @@ export async function createAndSubmitOffer(
if (description) {
vcClaim.itemOffered = { description };
}
if (recipientDid) {
vcClaim.recipient = { identifier: recipientDid };
}
if (fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = {
@@ -698,6 +716,12 @@ export async function createAndSubmitClaim(
}
}
/**
* An AcceptAction is when someone accepts some contract or pledge.
*
* @param claim has properties '@context' & '@type'
* @return true if the claim is a schema.org AcceptAction
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isAccept = (claim: Record<string, any>) => {
return (
@@ -894,3 +918,136 @@ export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
},
};
};
export async function register(
activeDid: string,
apiServer: string,
axios: Axios,
contact: Contact,
) {
const identity = await getIdentity(activeDid);
const vcClaim: RegisterVerifiableCredential = {
"@context": "https://schema.org",
"@type": "RegisterAction",
agent: { identifier: identity.did },
object: SERVICE_ID,
participant: { identifier: contact.did },
};
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Create a signature using private key of identity
if (identity.keys[0].privateKeyHex == null) {
return { error: "Private key not found." };
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = apiServer + "/api/v2/claim";
const headers = await getHeaders(identity);
const resp = await axios.post(url, payload, { headers });
if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message =
"There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError == "string") {
message += " " + resp.data.success.embeddedRecordError;
}
return { error: message };
} else {
console.error(resp);
return { error: "Got a server error when registering." };
}
}
export async function setVisibilityUtil(
activeDid: string,
apiServer: string,
axios: Axios,
db: NonsensitiveDexie,
contact: Contact,
visibility: boolean,
) {
if (!activeDid) {
return { error: "Cannot set visibility without an identifier." };
}
const url =
apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe");
const identity = await getIdentity(activeDid);
const headers = await getHeaders(identity);
const payload = JSON.stringify({ did: contact.did });
try {
const resp = await axios.post(url, payload, { headers });
if (resp.status === 200) {
db.contacts.update(contact.did, { seesMe: visibility });
return { success: true };
} else {
console.error(
"Got some bad server response when setting visibility: ",
resp.status,
resp,
);
const message =
resp.data.error?.message || "Got some error setting visibility.";
return { error: message };
}
} catch (err) {
console.error("Got some error when setting visibility:", err);
return { error: "Check connectivity and try again." };
}
}
/**
* Fetches rate limits from the Endorser server.
*
* @param apiServer endorser server URL string
* @param axios Axios instance
* @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
export async function fetchEndorserRateLimits(
apiServer: string,
axios: Axios,
identity: IIdentifier,
) {
const url = `${apiServer}/api/report/rateLimits`;
const headers = await getHeaders(identity);
return await axios.get(url, { headers } as AxiosRequestConfig);
}
/**
* Fetches rate limits from the image server.
*
* @param apiServer image server URL string
* @param axios Axios instance
* @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
export async function fetchImageRateLimits(
apiServer: string,
axios: Axios,
identity: IIdentifier,
) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
const headers = await getHeaders(identity);
return await axios.get(url, { headers } as AxiosRequestConfig);
}

View File

@@ -17,6 +17,7 @@ export const PRIVACY_MESSAGE =
/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
"BX": "BX",
"BTC": "BTC",
"ETH": "ETH",
"HUR": "Hours",
@@ -26,6 +27,7 @@ export const UNIT_SHORT: Record<string, string> = {
/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
"BX": "Buxbe",
"BTC": "Bitcoin",
"ETH": "Ethereum",
"HUR": "hours",
@@ -70,7 +72,7 @@ export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
};
export const giveIsConfirmable = (veriClaim: GenericCredWrapper) => {
export const isGiveAction = (veriClaim: GenericCredWrapper) => {
return veriClaim.claimType === "GiveAction";
};
@@ -91,7 +93,7 @@ export const isGiveRecordTheUserCanConfirm = (
confirmerIdList: string[] = [],
) => {
return (
giveIsConfirmable(veriClaim) &&
isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim)
@@ -201,7 +203,7 @@ export const getIdentity = async (activeDid: string): Promise<IIdentifier> => {
if (!identity) {
throw new Error(
`Attempted to load Offer records for DID ${activeDid} but no identifier was found`,
`Attempted to load identity ${activeDid} but no identifier was found`,
);
}
return identity;

View File

@@ -1,5 +1,5 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
@@ -11,9 +11,12 @@ import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
@@ -34,6 +37,7 @@ import {
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEye,
faEyeSlash,
@@ -45,8 +49,10 @@ import {
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLocationDot,
faLongArrowAltLeft,
@@ -63,6 +69,7 @@ import {
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
@@ -74,9 +81,12 @@ import {
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
@@ -97,6 +107,7 @@ library.add(
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEye,
faEyeSlash,
@@ -108,8 +119,10 @@ library.add(
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLocationDot,
faLongArrowAltLeft,
@@ -126,6 +139,7 @@ library.add(
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
@@ -139,11 +153,38 @@ library.add(
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
createApp(App)
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
function setupGlobalErrorHandler(app: VueApp) {
// @ts-expect-error 'cause we cannot see why config is not defined
app.config.errorHandler = (
err: Error,
instance: ComponentPublicInstance | null,
info: string,
) => {
console.error(
"Ouch! Global Error Handler. Info:",
info,
"Error:",
err,
"Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.
alert(
(err.message || "Something bad happened") +
" - Try reloading or restarting the app.",
);
};
}
const app = createApp(App)
.component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.use(Notifications)
.mount("#app");
.use(Notifications);
setupGlobalErrorHandler(app);
app.mount("#app");

View File

@@ -43,14 +43,19 @@ const routes: Array<RouteRecordRaw> = [
name: "confirm-contact",
component: () => import("../views/ConfirmContactView.vue"),
},
{
path: "/confirm-gift/:id?",
name: "confirm-gift",
component: () => import("@/views/ConfirmGiftView.vue"),
},
{
path: "/contact-amounts",
name: "contact-amounts",
component: () => import("../views/ContactAmountsView.vue"),
},
{
path: "/contact-gives",
name: "contact-gives",
path: "/contact-gift",
name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"),
},
{
@@ -63,6 +68,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contacts",
component: () => import("../views/ContactsView.vue"),
},
{
path: "/did/:did?",
name: "did",
component: () => import("../views/DIDView.vue"),
},
{
path: "/discover",
name: "discover",
@@ -164,6 +174,11 @@ const routes: Array<RouteRecordRaw> = [
name: "seed-backup",
component: () => import("../views/SeedBackupView.vue"),
},
{
path: "/shared-photo",
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
{
path: "/start",
name: "start",

View File

@@ -4,16 +4,6 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-4">
Your Identity
@@ -24,7 +14,7 @@
<span class="whitespace-nowrap">
<router-link
:to="{ name: 'contact-qr' }"
class="text-xs uppercase bg-slate-500 text-white px-1.5 py-1 rounded-md"
class="text-xs bg-slate-500 text-white px-1.5 py-1 rounded-md"
>
<fa icon="qrcode" class="fa-fw"></fa>
</router-link>
@@ -55,7 +45,7 @@
</p>
<router-link
:to="{ name: 'start' }"
class="inline-block text-md uppercase bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Create An Identifier
</router-link>
@@ -67,14 +57,17 @@
<h2 class="text-xl font-semibold mb-2">
{{ givenName }}
<router-link :to="{ name: 'new-edit-account' }">
<fa icon="pen" class="text-xs text-blue-500 mb-1"></fa>
<fa icon="pen" class="text-xs text-blue-500 ml-2 mb-1"></fa>
</router-link>
</h2>
</div>
<span v-else>
<span
v-else
class="block w-full text-center text-md bg-amber-200 text-blue-500 uppercase border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2"
>
<router-link
:to="{ name: 'new-edit-account' }"
class="block w-full text-center text-md bg-amber-200 text-blue-500 uppercase border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2"
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Set Your Name
</router-link>
@@ -93,18 +86,26 @@
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog"
/>
</span>
<GiftedPhotoDialog ref="photoDialog" />
<div v-else class="text-center">
<div class @click="openImageDialog()">
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-l"
/>
<fa
icon="image-portrait"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-r"
@click="openImageDialog()"
/>
</div>
</div>
<ImageMethodDialog ref="imageMethodDialog" />
</div>
<div class="mt-4">
<div class="flex justify-center">
... and those without your image see this:
<div class="mt-6">
<div class="flex justify-center text-center">
People {{ profileImageUrl ? "without your image" : "" }} see this
<br />
(if you've let them see your activity):
</div>
<div class="flex justify-center">
<EntityIcon
@@ -148,6 +149,12 @@
</button>
<span v-show="showDidCopy">Copied</span>
</div>
<div class="text-blue-500 text-sm font-bold">
<router-link :to="{ path: '/did/' + encodeURIComponent(activeDid) }">
Activity
</router-link>
</div>
</div>
<!-- Registration notice -->
@@ -162,7 +169,7 @@
</p>
<router-link
:to="{ name: 'contact-qr' }"
class="inline-block text-md uppercase bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Share Your Info
</router-link>
@@ -210,7 +217,7 @@
<router-link
:to="{ name: 'search-area' }"
v-if="activeDid"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6"
class="block w-full text-center text-m bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6"
>
Set Search Area
<!-- If already set, change button label to "Change Search Area" -->
@@ -264,7 +271,7 @@
</p>
</div>
<button
class="block float-right w-fit text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
class="block float-right w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
@click="checkLimits()"
>
Recheck Limits
@@ -276,14 +283,14 @@
<router-link
:to="{ name: 'seed-backup' }"
v-if="activeDid"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Backup Identifier Seed
</router-link>
<button
v-bind:class="computedStartDownloadLinkClassNames()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="exportDatabase()"
>
Download Settings & Contacts
@@ -293,7 +300,7 @@
<a
ref="downloadLink"
v-bind:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
@@ -386,7 +393,7 @@
>
<!-- label -->
<span class="text-slate-500 text-sm font-bold">Contacts Display</span>
<span class="ml-2">Show amounts given</span>
<span class="ml-2">Show hours given & received</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
@@ -528,25 +535,51 @@
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
</div>
<label
for="toggleHideRegisterPromptOnNewContact"
class="flex items-center justify-between cursor-pointer mt-4"
@click="toggleHideRegisterPromptOnNewContact()"
>
<!-- label -->
<span class="text-slate-500 text-sm font-bold">
Hide Register Prompt on New Contact
</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="hideRegisterPromptOnNewContact"
class="sr-only"
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/>
</div>
</label>
<label
for="toggleShowShortcutBvc"
class="flex items-center justify-between cursor-pointer mt-4"
@click="toggleShowShortcutBvc"
>
<!-- label -->
<span class="text-slate-500 text-sm font-bold"
>Show BVC Shortcut on Home Page</span
>
<span class="text-slate-500 text-sm font-bold">
Show BVC Shortcut on Home Page
</span>
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input type="checkbox" v-model="showShortcutBvc" class="sr-only" />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
/>
</div>
</label>
@@ -557,11 +590,11 @@
<div class="ml-4 mt-2">
Import
<input type="file" @change="uploadFile" class="ml-2" />
<input type="file" @change="uploadImportFile" class="ml-2" />
<div v-if="showContactImport()">
<button
class="block text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="submitFile()"
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Import Settings & Contacts
<br />
@@ -575,7 +608,7 @@
<button>
<router-link
:to="{ name: 'statistics' }"
class="block w-fit text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-2"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mb-2"
>
See Global Animated History of Giving
</router-link>
@@ -586,7 +619,7 @@
</template>
<script lang="ts">
import { AxiosError, AxiosRequestConfig } from "axios";
import { AxiosError } from "axios";
import Dexie from "dexie";
import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import";
@@ -594,13 +627,14 @@ import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db, accountsDB } from "@/db/index";
@@ -611,10 +645,11 @@ import {
ErrorResponse,
EndorserRateLimits,
ImageRateLimits,
fetchEndorserRateLimits,
fetchImageRateLimits,
} from "@/libs/endorserServer";
import { Buffer } from "buffer/";
import EntityIcon from "@/components/EntityIcon.vue";
import {Contact} from "@/db/tables/contacts";
interface IAccount {
did: string;
@@ -623,10 +658,10 @@ interface IAccount {
derivationPath: string;
}
const inputFileNameRef = ref<Blob>();
const inputImportFileNameRef = ref<Blob>();
@Component({
components: {EntityIcon, GiftedPhotoDialog, QuickNav, TopMessage },
components: { EntityIcon, ImageMethodDialog, QuickNav, TopMessage },
})
export default class AccountViewView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -662,19 +697,20 @@ export default class AccountViewView extends Vue {
showB64Copy = false;
showPubCopy = false;
showAdvanced = false;
hideRegisterPromptOnNewContact = false;
showShortcutBvc = false;
subscription: PushSubscription | null = null;
warnIfProdServer = false;
warnIfTestServer = false;
/**
* Async function executed when the component is created.
* Async function executed when the component is mounted.
* Initializes the component's state with values from the database,
* handles identity-related tasks, and checks limitations.
*
* @throws Will display specific messages to the user based on different errors.
*/
async created() {
async mounted() {
try {
await db.open();
@@ -688,18 +724,13 @@ export default class AccountViewView extends Vue {
if (identity) {
this.processIdentity(identity);
}
} catch (err: unknown) {
this.handleError(err);
}
}
async mounted() {
try {
const registration = await navigator.serviceWorker.ready;
this.subscription = await registration.pushManager.getSubscription();
this.isSubscribed = !!this.subscription;
} catch (error) {
console.error("Mount error:", error);
this.handleError(error);
}
}
@@ -723,6 +754,8 @@ export default class AccountViewView extends Vue {
this.isRegistered = !!settings?.isRegistered;
this.profileImageUrl = settings?.profileImageUrl as string;
this.showContactGives = !!settings?.showContactGivesInline;
this.hideRegisterPromptOnNewContact =
!!settings?.hideRegisterPromptOnNewContact;
this.showShortcutBvc = !!settings?.showShortcutBvc;
this.warnIfProdServer = !!settings?.warnIfProdServer;
this.warnIfTestServer = !!settings?.warnIfTestServer;
@@ -948,6 +981,28 @@ export default class AccountViewView extends Vue {
}
}
public async toggleHideRegisterPromptOnNewContact() {
const newSetting = !this.hideRegisterPromptOnNewContact;
try {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: newSetting,
});
this.hideRegisterPromptOnNewContact = newSetting;
} catch (err) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Updating Setting",
text: "The setting may not have saved. Try again, maybe after restarting the app.",
},
-1,
);
console.error("Telling user to try again because:", err);
}
}
public async updateShowShortcutBvc(newSetting: boolean) {
try {
await db.open();
@@ -1073,13 +1128,29 @@ export default class AccountViewView extends Vue {
console.error("Export Error:", error);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async uploadFile(event: any) {
inputFileNameRef.value = event.target.files[0];
async uploadImportFile(event: Event) {
inputImportFileNameRef.value = event.target.files[0];
}
showContactImport() {
return !!inputFileNameRef.value;
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile() {
if (inputImportFileNameRef.value != null) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Replace All",
text:
"This will replace all settings and contacts, so we recommend you first do the backup step above." +
" Are you sure you want to import and replace all contacts and settings?",
onYes: this.submitImportFile,
},
-1,
);
}
}
/**
@@ -1087,19 +1158,12 @@ export default class AccountViewView extends Vue {
*
* @throws Will notify the user if there is an export error.
*/
async submitFile() {
if (inputFileNameRef.value != null) {
if (
confirm(
"This will replace all settings and contacts, so we recommend you first do the backup step above." +
" Are you sure you want to import and replace all contacts and settings?",
)
) {
await db.delete();
await Dexie.import(inputFileNameRef.value, {
progressCallback: this.progressCallback,
});
}
async submitImportFile() {
if (inputImportFileNameRef.value != null) {
await db.delete();
await Dexie.import(inputImportFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
});
}
}
@@ -1142,7 +1206,11 @@ export default class AccountViewView extends Vue {
this.limitsMessage = "";
try {
const resp = await this.fetchEndorserRateLimits(identity);
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
identity,
);
if (resp.status === 200) {
this.endorserLimits = resp.data;
if (!this.isRegistered) {
@@ -1158,7 +1226,7 @@ export default class AccountViewView extends Vue {
this.$notify(
{
group: "alert",
type: "warning",
type: "danger",
title: "Update Error",
text: "Unable to update your settings. Check claim limits again.",
},
@@ -1166,7 +1234,11 @@ export default class AccountViewView extends Vue {
);
}
}
const imageResp = await this.fetchImageRateLimits(identity);
const imageResp = await fetchImageRateLimits(
this.apiServer,
this.axios,
identity,
);
if (imageResp.status === 200) {
this.imageLimits = imageResp.data;
}
@@ -1189,30 +1261,6 @@ export default class AccountViewView extends Vue {
this.loadingLimits = false;
}
/**
* Fetches rate limits from the Endorser server.
*
* @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
private async fetchEndorserRateLimits(identity: IIdentifier) {
const url = `${this.apiServer}/api/report/rateLimits`;
const headers = await this.getHeaders(identity);
return await this.axios.get(url, { headers } as AxiosRequestConfig);
}
/**
* Fetches rate limits from the image server.
*
* @param {IIdentifier} identity - The identity object to check rate limits for.
* @returns {Promise<AxiosResponse>} The Axios response object.
*/
private async fetchImageRateLimits(identity: IIdentifier) {
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
const headers = await this.getHeaders(identity);
return await this.axios.get(url, { headers } as AxiosRequestConfig);
}
/**
* Handles errors that occur while fetching rate limits.
*
@@ -1326,8 +1374,8 @@ export default class AccountViewView extends Vue {
);
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open(
openImageDialog() {
(this.$refs.imageMethodDialog as ImageMethodDialog).open(
async (imgUrl) => {
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
@@ -1336,8 +1384,8 @@ export default class AccountViewView extends Vue {
this.profileImageUrl = imgUrl;
//console.log("Got image URL:", imgUrl);
},
IMAGE_TYPE_PROFILE,
true,
"profile",
);
}
@@ -1346,7 +1394,8 @@ export default class AccountViewView extends Vue {
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete your profile picture?",
title:
"Note that anyone with you already as a contact will no longer see a picture, and you will have to reshare your data with them if you save a new picture. Are you sure you want to delete your profile picture?",
text: "",
onYes: this.deleteImage,
},
@@ -1384,20 +1433,29 @@ export default class AccountViewView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
text: "There was a problem deleting the image. Contact support if you want it removed from the servers.",
},
5000,
-1,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: undefined,
});
this.profileImageUrl = undefined;
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
console.error("The image was already deleted:", error);
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: undefined,
});
this.profileImageUrl = undefined;

View File

@@ -10,7 +10,7 @@
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<fa icon="chevron-left" class="fa-fw" />
</button>
Verifiable Claim Details
</h1>
@@ -35,16 +35,16 @@
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showIdCopy">Copied ID</span>
</div>
<div>
<fa icon="message" class="fa-fw text-slate-400"></fa>
<fa icon="message" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }}
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400"></fa>
<fa icon="user" class="fa-fw text-slate-400" />
{{ veriClaim.issuer }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(veriClaim.issuer)">
<button
@@ -56,13 +56,13 @@
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
<span v-show="showDidCopy">Copied DID</span>
</span>
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div>
@@ -121,7 +121,7 @@
</div>
</div>
<div class="columns-3">
<div class="flex columns-3">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
@@ -131,12 +131,22 @@
confirmerIdList,
)
"
@click="confirmClaim()"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<span class="px-4 py-2">
<router-link
v-if="libsUtil.isGiveAction(veriClaim)"
:to="'/confirm-gift/' + encodeURIComponent(veriClaim.id)"
class="col-span-1 text-blue-500"
>
Confirmation Details...
</router-link>
</span>
<button
v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()"
@@ -146,9 +156,9 @@
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
</button>
</div>
<GiftedDialog ref="customGiveDialog" message="Offer fulfilled by" />
<GiftedDialog ref="customGiveDialog" />
<div v-if="libsUtil.giveIsConfirmable(veriClaim)">
<div v-if="libsUtil.isGiveAction(veriClaim)">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
@@ -319,7 +329,7 @@
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw"></fa>
<fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
@@ -341,8 +351,7 @@
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400"></fa
>&nbsp;<a
<fa icon="globe" class="fa-fw text-slate-400" />&nbsp;<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
@@ -406,7 +415,6 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
@@ -416,10 +424,10 @@ import * as serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
import { GiverInputInfo } from "@/libs/endorserServer";
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({
components: { GiftedDialog, OfferDialog, QuickNav },
components: { GiftedDialog, QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -475,10 +483,10 @@ export default class ClaimView extends Vue {
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr = await accounts?.toArray();
const accountsArr: Array<Account> = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
this.accountIdentityStr = account?.identity || "null";
this.accountIdentityStr = (account?.identity as string) || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring("/claim/".length);
@@ -667,7 +675,7 @@ export default class ClaimView extends Vue {
const accounts = accountsDB.accounts;
const accountsArr: Account[] = await accounts?.toArray();
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const identity = JSON.parse((account?.identity as string) || "null");
const url =
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
@@ -716,52 +724,65 @@ export default class ClaimView extends Vue {
}
}
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView
async confirmClaim() {
if (confirm("Do you personally confirm that this is true?")) {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
-1,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
-1,
);
}
}
}
@@ -776,12 +797,14 @@ export default class ClaimView extends Vue {
}
openFulfillGiftDialog() {
const giver: GiverInputInfo = {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.veriClaim.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}

View File

@@ -0,0 +1,878 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw" />
</button>
<span
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
>
Do you agree?
</span>
<span v-else> Details </span>
</h1>
</div>
<div v-if="giveDetails">
<div class="flex justify-center">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
veriClaim,
activeDid,
confirmerIdList,
)
"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<button
v-else
@click="notifyWhyCannotConfirm()"
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<a
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md"
:href="urlForNewGive"
>
Record a Similar One
</a>
</div>
<!-- Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<div class="text-sm">
<div>
<fa icon="arrow-down" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
<div v-if="giveDetails.amount">
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" />
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
</div>
<div v-if="giveDetails.description">
<fa icon="message" class="fa-fw text-slate-400" />
{{ giveDetails.amount ? "and:" : "" }}
{{ giveDetails.description }}
</div>
<div class="ml-6">to</div>
<div>
<fa icon="arrow-up" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
on
{{ giveDetails.issuedAt.substring(0, 10) }}
</div>
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills a bigger plan
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
>
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails.fulfillsType,
)
}}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="mt-2">
<fa icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4">
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person has confirmed this.
</span>
<span v-else>
{{ totalConfirmers() }} people have confirmed this.
</span>
<div v-if="totalConfirmers() > 0">
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
"
>
Nobody that you know confirmed this claim, nor do they have any
confirmers in their network.
</div>
<div
v-if="
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0
"
>
<!-- Only show if this person has links to confirmers (below). -->
Nobody that you know has issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people have issued or confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
:key="confirmerId"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!--
Never need to show this message:
"Nobody that you know can see someone who has confirmed this claim."
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
If there is somebody in the confirmerIdList then that's all they need to show.
-->
<!-- Now show anyone linked to confirmers. -->
<div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who have issued or
confirmed this claim.
<ul class="ml-4">
<li
v-for="confsVisibleTo in confsVisibleToIdList"
:key="confsVisibleTo"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)"
>
<button
@click="
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim.
</div>
<div v-else-if="giveDetails.agentDid == activeDid">
You cannot confirm this because you issued this claim, so you already
count as confirming it.
</div>
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)">
You cannot confirm this because it contains hidden identifiers.
</div>
</div>
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
@click="showDetails = !showDetails"
>
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details
<span v-if="!showDetails"><fa icon="chevron-down" /></span>
<span v-else><fa icon="chevron-up" /></span>
</h2>
<div v-if="showDetails">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
R.isEmpty(veriClaimDidsVisible)
"
class="mb-2"
>
Some of the details are not visible to you; they show as "HIDDEN".
They are not visible to any of your direct contacts, either.
<span v-if="canShare">
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction.
</span>
<span v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>share this page with them</a
>
and see if they are willing to make an introduction.
</span>
</div>
<div v-if="!R.isEmpty(veriClaimDidsVisible)">
Some of the details are not visible to you but they are visible to
some of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
</span>
<div
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)"
:key="index"
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
<ul>
<li
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]"
:key="idx2"
class="list-disc"
>
<div class="text-sm mt-2">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />
<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
>{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
)
}}
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
</div>
</div>
<div v-else>This does not have details to confirm.</div>
<div class="mt-4">
<a
@click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" class="pl-2" />
All Generic Info
</a>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import * as serverUtil from "@/libs/endorserServer";
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { isGiveAction } from "@/libs/util";
@Component({
methods: { displayAmount },
components: { GiftedDialog, QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
allMyDids: Array<string> = [];
allContacts: Array<Contact> = [];
apiServer = "";
canShare = false;
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails = null;
giverName = "";
issuerName = "";
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showDetails = false;
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location;
R = R;
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = null;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
async mounted() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
await accountsDB.open();
const accounts = accountsDB.accounts;
const accountsArr: Array<Account> = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
this.accountIdentityStr = (account?.identity as string) || "null";
const identity = JSON.parse(this.accountIdentityStr);
const pathParam = window.location.pathname.substring(
"/confirm-gift/".length,
);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, identity);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
3000,
);
}
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
}
totalConfirmers() {
return (
this.numConfsNotVisible +
this.confirmerIdList.length +
this.confsVisibleToIdList.length
);
}
public async getIdentity(activeDid: string): Promise<IIdentifier> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load project records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
// Isn't there a better way to make this available to the template?
didInfo(did: string | undefined) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
async loadClaim(claimId: string, identity: IIdentifier) {
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimId);
try {
const headers = await this.getHeaders(identity);
const resp = await this.axios.get(url, { headers });
// resp.data is:
// - a Jwt from https://api.endorser.ch/api-docs/
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3
if (resp.status === 200) {
this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim);
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids(
this.veriClaim,
true,
);
} else {
// actually, axios typically throws an error so we never get here
console.error("Error getting claim:", resp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem retrieving that claim.",
},
3000,
);
return;
}
// retrieve more details on Give, Offer, or Plan
if (this.veriClaim.claimType !== "GiveAction") {
// no need to go further... this page is for gifts
return;
}
this.issuerName = this.didInfo(this.veriClaim.issuer);
// use give record when possible since it may include edits
const giveUrl =
this.apiServer +
"/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await this.getHeaders(identity);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
// giveResp.data is a Give from https://api.endorser.ch/api-docs/
if (giveResp.status === 200) {
this.giveDetails = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gift data.",
},
3000,
);
}
this.urlForNewGive = "/gifted-details?";
if (this.giveDetails.amount) {
this.urlForNewGive +=
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount));
}
if (this.giveDetails.unit) {
this.urlForNewGive +=
"&unitCode=" + encodeURIComponent(this.giveDetails.unit);
}
if (this.giveDetails.description) {
this.urlForNewGive +=
"&description=" + encodeURIComponent(this.giveDetails.description);
}
this.giverName = this.didInfo(this.giveDetails.agentDid);
if (this.giveDetails.agentDid) {
this.urlForNewGive +=
"&giverDid=" +
encodeURIComponent(this.giveDetails.agentDid) +
"&giverName=" +
encodeURIComponent(this.giverName);
}
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
if (this.giveDetails.recipientDid) {
this.urlForNewGive +=
"&recipientDid=" +
encodeURIComponent(this.giveDetails.recipientDid) +
"&recipientName=" +
encodeURIComponent(this.recipientName);
}
if (this.giveDetails.fullClaim.image) {
this.urlForNewGive +=
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image);
}
if (
this.giveDetails.type == "Offer" &&
this.giveDetails.fulfillsHandleId
) {
this.urlForNewGive +=
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId);
}
if (this.giveDetails.fulfillsPlanHandleId) {
this.urlForNewGive +=
"&projectId=" +
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
}
// retrieve the list of confirmers
const confirmUrl =
this.apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await this.getHeaders(identity);
const response = await this.axios.get(confirmUrl, {
headers: confirmHeaders,
});
if (response.status === 200) {
const resultList1 = response.data.result || [];
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
const resultList3 = R.reject(
(did: string) => did === this.giveDetails.agentDid,
resultList2,
);
this.confirmerIdList = resultList3;
this.numConfsNotVisible = resultList1.length - resultList2.length;
if (resultList3.length === resultList2.length) {
// the issuer was not in the "visible" list so they must be hidden
// so subtract them from the non-visible confirmers count
this.numConfsNotVisible = this.numConfsNotVisible - 1;
}
this.confsVisibleToIdList =
response.data.result.resultVisibleToDids || [];
} else {
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations.";
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving claim data.",
},
3000,
);
}
}
confirmConfirmClaim() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim();
},
},
-1,
);
}
// similar code is found in ProjectViewView
async confirmClaim() {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
this.veriClaim.claim,
this.veriClaim.id,
this.veriClaim.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
3000,
);
} else {
console.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
5000,
);
}
}
showClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
this.$router.push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr));
});
}
openFulfillGiftDialog() {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(this.veriClaim),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.giveDetails.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
notifyWhyCannotConfirm() {
if (!isGiveAction(this.veriClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not A Give",
text: "This is not a giving action to confirm.",
},
3000,
);
} else if (this.confirmerIdList.includes(this.activeDid)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You have already confirmed this claim.",
},
3000,
);
} else if (this.giveDetails.agentDid == this.activeDid) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because you issued this claim.",
},
3000,
);
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because it contains hidden identifiers.",
},
3000,
);
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim.",
},
3000,
);
}
}
onClickShareClaim() {
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
url: this.windowLocation.href,
});
}
}
</script>

View File

@@ -57,7 +57,7 @@
<td class="p-1">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
@@ -81,7 +81,7 @@
<td class="p-1">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ record.amount }} {{ record.unit }}
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
</span>
@@ -119,6 +119,7 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import {
AgreeVerifiableCredential,
displayAmount,
GiveSummaryRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
@@ -134,6 +135,8 @@ export default class ContactAmountssView extends Vue {
giveRecords: Array<GiveSummaryRecord> = [];
numAccounts = 0;
displayAmount = displayAmount;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();

View File

@@ -26,7 +26,7 @@
width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
Anonymous/Unnamed
Unnamed/Unknown
</span>
<span class="text-right">
<button
@@ -66,11 +66,7 @@
</li>
</ul>
<GiftedDialog
ref="customDialog"
message="Received from"
:projectId="projectId"
/>
<GiftedDialog ref="customDialog" :projectId="projectId" />
</section>
</template>
@@ -87,7 +83,7 @@ import { Account, AccountsSchema } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverInputInfo } from "@/libs/endorserServer";
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
@@ -163,8 +159,16 @@ export default class ContactGiftingView extends Vue {
return headers;
}
openDialog(giver: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
openDialog(giver: GiverReceiverInputInfo) {
const recipient = this.projectId
? undefined
: { did: this.activeDid, name: "you" };
(this.$refs.customDialog as GiftedDialog).open(
giver,
recipient,
undefined,
"Given by " + (giver?.name || "someone not named"),
);
}
}
</script>

View File

@@ -71,6 +71,7 @@
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import { sha256 } from "ethereum-cryptography/sha256.js";
import QRCodeVue3 from "qr-code-generator-vue3";
@@ -79,16 +80,26 @@ import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {deriveAddress, getContactPayloadFromJwtUrl, nextDerivationPath, SimpleSigner} from "@/libs/crypto";
import QuickNav from "@/components/QuickNav.vue";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
deriveAddress,
getContactPayloadFromJwtUrl,
nextDerivationPath,
SimpleSigner,
} from "@/libs/crypto";
import {
CONTACT_URL_PREFIX,
ENDORSER_JWT_URL_LOCATION,
isDid,
register,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { Buffer } from "buffer/";
@Component({
@@ -104,31 +115,19 @@ export default class ContactQRScanShow extends Vue {
activeDid = "";
apiServer = "";
givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = "";
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identifier available.",
);
}
return identity;
}
async created() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.givenName = settings?.firstName || "";
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.givenName = (settings?.firstName as string) || "";
this.hideRegisterPromptOnNewContact =
!!settings?.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
@@ -155,6 +154,7 @@ export default class ContactQRScanShow extends Vue {
publicEncKey,
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
profileImageUrl: settings?.profileImageUrl,
registered: settings?.isRegistered,
},
};
@@ -172,27 +172,147 @@ export default class ContactQRScanShow extends Vue {
}
}
danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
timeout,
);
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account: Account | undefined = R.find(
(acc) => acc.did === activeDid,
accounts,
);
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"Attempted to show contact info with no identifier available.",
);
}
return identity;
}
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
*/
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanDetect(content: any) {
async onScanDetect(content: any) {
const url = content[0]?.rawValue;
if (url) {
let newContact: Contact;
try {
const fullData = getContactPayloadFromJwtUrl(url);
console.log("fullData", fullData);
localStorage.setItem("contactEndorserUrl", url);
this.$router.push({ name: "contacts" });
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
}
newContact = {
did: payload.iss as string,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
};
if (!newContact.did) {
this.danger("There is no DID.", "Incomplete Contact");
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
return;
}
} catch (e) {
console.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
return;
}
try {
await db.open();
await db.contacts.add(newContact);
let addedMessage;
if (this.activeDid) {
await this.setVisibility(newContact, true);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage =
"They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "warning",
title: "Invalid Contact QR Code",
text: "The QR code isn't in the right format.",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
if (stopAsking) {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
}
} catch (e) {
console.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact info. Check if it already exists.",
},
5000,
);
@@ -201,7 +321,7 @@ export default class ContactQRScanShow extends Vue {
this.$notify(
{
group: "alert",
type: "warning",
type: "danger",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
@@ -210,13 +330,102 @@ export default class ContactQRScanShow extends Vue {
}
}
async setVisibility(contact: Contact, visibility: boolean) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
console.error("Got strange result from setting visibility:", result);
}
}
async register(contact: Contact) {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
console.error("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "warning",
type: "danger",
title: "Invalid Scan",
text: "The scan was invalid.",
},
@@ -225,6 +434,7 @@ export default class ContactQRScanShow extends Vue {
}
onCopyToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.qrValue)
.then(() => {

File diff suppressed because it is too large Load Diff

337
src/views/DIDView.vue Normal file
View File

@@ -0,0 +1,337 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Identifier Details
</h1>
</div>
<!-- Identity Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<h2 class="text-xl font-semibold">
{{
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
.displayName
}}
</h2>
<span class="mt-2 text-xl font-semibold break-words">
{{ viewingDid }}
</span>
</div>
<div class="flex justify-center mt-4">
<span v-if="contact?.profileImageUrl" class="flex justify-between">
<EntityIcon
:icon-size="96"
:profileImageUrl="contact?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = contact?.profileImageUrl"
/>
</span>
</div>
<div class="mt-4">
<div class="flex justify-center">Auto-Generated Icon:</div>
<div class="flex justify-center">
<EntityIcon
:entityId="viewingDid"
:iconSize="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = viewingDid"
/>
</div>
</div>
<div
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
class="fixed z-[100] top-0 inset-x-0 w-full"
>
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:entityId="showLargeIdenticonId"
:iconSize="512"
:profileImageUrl="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="
showLargeIdenticonId = undefined;
showLargeIdenticonUrl = undefined;
"
/>
</div>
</div>
</div>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
<div class="text-l font-bold text-center">Claims That Involve Them</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
v-for="claim in claims"
:key="claim.handleId"
>
<div class="grid grid-cols-12 gap-4">
<span class="col-span-2">
{{ claim.issuedAt.substring(0, 10) }}
</span>
<span class="col-span-2">
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }}
</span>
<span class="col-span-2">
{{ claimAmount(claim) }}
</span>
<span class="col-span-5">
{{ claimDescription(claim) }}
</span>
<span class="col-span-1">
<a
@click="onClickLoadClaim(claim.handleId)"
class="cursor-pointer"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</span>
</div>
</li>
</ul>
</InfiniteScroll>
<div
v-if="!isLoading && claims.length === 0"
class="flex justify-center mt-4"
>
<span>They Are in No Claims Visible to You</span>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
TopMessage,
},
})
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claims: Array<GenericCredWrapper> = [];
contact?: Contact;
hitEnd = false;
isLoading = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
viewingDid?: string;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
const pathParam = window.location.pathname.substring("/did/".length);
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid);
await this.loadClaimsAbout();
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
-1,
);
}
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
}
public async buildHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (this.activeDid) {
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
const account = allAccounts.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse((account?.identity as string) || "null");
if (!identity) {
throw new Error(
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.",
);
}
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
async loadMoreData(payload: boolean) {
if (this.claims.length > 0 && !this.hitEnd && payload) {
this.loadClaimsAbout();
}
}
public async loadClaimsAbout() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
return;
}
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid);
let postfix = "";
if (this.claims.length > 0) {
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id;
}
try {
this.isLoading = true;
const response = await fetch(
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
{
method: "GET",
headers: await this.buildHeaders(),
},
);
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `There was a problem accessing the server. Try again later.`,
},
5000,
);
return;
}
const results = await response.json();
this.claims = this.claims.concat(results.data);
this.hitEnd = !results.hitLimit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
-1,
);
} finally {
this.isLoading = false;
}
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveVerifiableCredential;
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
return displayAmount(
giveClaim.object.unitCode,
giveClaim.object.amountOfThisGood,
);
} else {
return "";
}
} else if (claim.claimType === "Offer") {
const offerClaim = claim.claim as OfferVerifiableCredential;
if (
offerClaim.includesObject?.unitCode &&
offerClaim.includesObject?.amountOfThisGood
) {
return displayAmount(
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
} else {
return "";
}
}
return "";
}
claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || "";
}
}
</script>

View File

@@ -6,7 +6,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Discover
Discover Projects
</h1>
<!-- Quick Search -->
@@ -74,7 +74,7 @@
<div v-if="isLocalActive">
<div>
<button
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })"
>
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
@@ -100,14 +100,15 @@
>
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
class="block py-4 flex gap-4 cursor-pointer"
>
<div class="w-12">
<div>
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
class="block border border-slate-300 rounded-md"
></ProjectIcon>
:imageUrl="project.image"
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div class="grow">
@@ -271,8 +272,15 @@ export default class DiscoverView extends Vue {
const plans: PlanData[] = results.data;
if (plans) {
for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid });
const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
handleId,
image,
issuerDid,
rowid,
});
}
this.remoteCount = this.projects.length;
} else {

View File

@@ -5,10 +5,13 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancel()"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
@@ -18,7 +21,8 @@
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Given</h1>
<h1 class="text-xl font-bold text-center mb-4">
{{ message }} {{ giverName || "somebody not named" }}
<span>From {{ giverName || "somebody not named" }}</span>
<span> to {{ recipientName || "somebody not named" }}</span>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
@@ -30,7 +34,7 @@
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] }}
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@@ -66,28 +70,55 @@
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog"
@click="openImageDialog"
/>
</span>
</div>
<GiftedPhotoDialog ref="photoDialog" />
<ImageMethodDialog ref="imageDialog" />
<div v-if="projectId" class="mt-4">
<fa
icon="check"
class="bg-slate-500 text-white h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !givenToUser"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<label class="text-sm">This is given to a project</label>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
</div>
<div v-if="!projectId" class="mt-4">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="givenToUser" />
<label class="text-sm">Given to you</label>
<div class="h-7 mt-4 flex">
<input
v-if="!givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToUser"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="
notifyUser('You cannot assign this both a project and also to you.')
"
/>
<label class="text-sm mt-1">This was given to you</label>
</div>
<div class="mt-4">
<div class="mt-4 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm">Trade (not a gift)</label>
<label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
<p class="text-center mb-2 mt-6 italic">
@@ -118,21 +149,19 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { createAndSubmitGive } from "@/libs/endorserServer";
import { createAndSubmitGive, getPlanFromCache } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
import GiftedDialog from "@/components/GiftedDialog.vue";
import GiftedPhotoDialog from "@/components/GiftedPhotoDialog.vue";
@Component({
components: {
GiftedDialog,
GiftedPhotoDialog,
ImageMethodDialog,
QuickNav,
TopMessage,
},
@@ -145,31 +174,59 @@ export default class GiftedDetails extends Vue {
amountInput = "0";
description = "";
destinationNameAfter = "";
givenToProject = false;
givenToUser = false;
giverDid: string | undefined;
giverName = "";
hideBackButton = false;
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
projectId = "";
projectName = "a project";
recipientDid = "";
recipientName = "";
showGivenToUser = false;
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
this.amountInput = this.$route.query.amountInput as string;
this.description = this.$route.query.description as string;
this.amountInput =
(this.$route.query.amountInput as string) || this.amountInput;
this.description = (this.$route.query.description as string) || "";
this.destinationNameAfter = this.$route.query
.destinationNameAfter as string;
this.giverDid = this.$route.query.giverDid as string;
this.giverName = this.$route.query.giverName as string;
this.message = this.$route.query.message as string;
this.giverName = (this.$route.query.giverName as string) || "";
this.hideBackButton = this.$route.query.hideBackButton === "true";
this.message = (this.$route.query.message as string) || "";
this.offerId = this.$route.query.offerId as string;
this.projectId = this.$route.query.projectId as string;
this.unitCode = this.$route.query.unitCode as string;
this.recipientDid = this.$route.query.recipientDid as string;
this.recipientName = (this.$route.query.recipientName as string) || "";
this.unitCode = (this.$route.query.unitCode as string) || this.unitCode;
this.imageUrl = localStorage.getItem("imageUrl") || "";
this.imageUrl =
(this.$route.query.imageUrl as string) ||
localStorage.getItem("imageUrl") ||
"";
this.givenToUser = !this.projectId;
// this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if (this.$route.query.shareTitle) {
this.description = this.$route.query.shareTitle as string;
}
if (this.$route.query.shareText) {
this.description =
(this.description ? this.description + " " : "") +
(this.$route.query.shareText as string);
}
if (this.$route.query.shareUrl) {
this.imageUrl = this.$route.query.shareUrl as string;
}
try {
await db.open();
@@ -177,6 +234,19 @@ export default class GiftedDetails extends Vue {
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
if (this.giverDid && !this.giverName) {
this.giverName =
this.giverDid === this.activeDid ? "you" : "someone not named";
}
this.givenToUser = this.recipientDid === this.activeDid;
if (this.recipientDid && !this.recipientName) {
this.recipientName =
this.recipientDid === this.activeDid ? "you" : "someone not named";
}
this.givenToProject = !!this.projectId;
this.givenToUser =
!this.projectId && this.recipientDid === this.activeDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
@@ -190,6 +260,21 @@ export default class GiftedDetails extends Vue {
-1,
);
}
if (this.projectId) {
// console.log("Getting project name from cache", this.projectId);
const identity = await libsUtil.getIdentity(this.activeDid);
const project = await getPlanFromCache(
this.projectId,
identity,
this.axios,
this.apiServer,
);
console.log("Got project name from cache", project);
this.projectName = project?.name
? "the project: " + project.name
: "a project";
}
}
changeUnitCode() {
@@ -210,14 +295,23 @@ export default class GiftedDetails extends Vue {
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
this.$router.back();
}
openPhotoDialog() {
(this.$refs.photoDialog as GiftedPhotoDialog).open((imgUrl) => {
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
});
}, "GiveAction");
}
confirmDeleteImage() {
@@ -254,7 +348,7 @@ export default class GiftedDetails extends Vue {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Non-success deleting image:", response);
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
@@ -348,6 +442,43 @@ export default class GiftedDetails extends Vue {
await this.recordGive();
}
notifyUser(message: string) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: message,
},
3000,
);
}
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this dialog through a project.",
},
3000,
);
} else {
// must be because givenToUser is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to yourself.",
},
3000,
);
}
}
/**
*
* @param giverDid may be null
@@ -358,16 +489,23 @@ export default class GiftedDetails extends Vue {
public async recordGive() {
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const recipientDid =
this.recipientDid === this.activeDid
? this.givenToUser
? this.activeDid
: undefined
: this.recipientDid;
const projectId = this.givenToProject ? this.projectId : undefined;
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
identity,
this.giverDid,
this.givenToUser ? this.activeDid : undefined,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
this.projectId,
projectId,
this.offerId,
this.isTrade,
this.imageUrl,
@@ -399,12 +537,16 @@ export default class GiftedDetails extends Vue {
5000,
);
localStorage.removeItem("imageUrl");
this.$router.back();
if (this.destinationNameAfter) {
this.$router.push({ name: this.destinationNameAfter });
} else {
this.$router.back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const message =
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
@@ -413,7 +555,7 @@ export default class GiftedDetails extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: message,
text: errorMessage,
},
-1,
);

View File

@@ -84,24 +84,21 @@
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<a href="/help-onboarding" target="_blank" class="text-blue-500">
Click here to show an alert with the steps.
Use these instructions.
</a>
To start scanning, go
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
</p>
<p>
If they are not nearby to scan QR codes, tell them to copy their ID from
their Identity <fa icon="circle-user" class="fa-fw" /> page, which
typically starts with "did:ethr:...", and send it to you. Go to the
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
top form. To add a name, put a comma and then their name; to add their
public key, put another comma followed by the key.
If they are not nearby to scan QR codes, you each can tap on the QR code
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
</p>
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
<p>
There are two sets of data to backup: the identifier secrets and the
other data that isn't quite a secret such as settings, contacts, etc.
There are three sets of data to backup: the identifier secrets;
the non-public textual data that isn't quite a secret such as settings and contacts;
the non-public image for yourself; and the data that you have sent to the public.
</p>
<div class="px-4">
@@ -122,7 +119,7 @@
</ul>
<h2 class="text-xl font-semibold">
How do I backup my other (non-identifier-secret) data?
How do I backup my non-secret, non-public text data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
@@ -134,6 +131,27 @@
won't lose it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my non-secret, non-public image?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
tap on your image, and save it.
</li>
</ul>
<h2 class="text-xl font-semibold">
How do I backup my public data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
This requires use of the API, so investigate the endpoints
<a href="https://api.endorser.ch/" target="_blank" class="text-blue-500">here</a>
(particularly the "claim" endpoints).
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
@@ -178,8 +196,7 @@
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
<p>
Before doing this, note the two kinds of data to backup: identity data,
and other data for contacts and settings (see instructions above).
Before doing this, you may want to back up your data with the instructions above.
</p>
<ul>
<li class="list-disc list-outside ml-4">
@@ -198,11 +215,11 @@
<ul>
<li class="list-disc list-outside ml-4">
Chrome:
Clear at chrome://settings/content/all and
Clear at "chrome://settings/content/all" and
also clear under dev tools Application
</li>
<li class="list-disc list-outside ml-4">
Firefox: <a href="about:preferences">go here</a>, Manage Data,
Firefox: Navigate to "about:preferences", Manage Data,
find timesafari.app and select, hit Remove Selected, then Save
Changes
</li>
@@ -232,7 +249,7 @@
<p>
There is a even more functionality in a mobile app (and more
documentation) at
<a href="https://endorser.ch" class="text-blue-500">
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
EndorserSearch.com
</a>
</p>
@@ -303,7 +320,7 @@
find "timesafari.app", and click "Unregister".
</li>
<li>
<a href="https://duckduckgo.com/?q=unregister+service+worker" class="text-blue-500">Search</a>
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
for instructions for other browsers.</li>
</ul>
Then reload Time Safari.
@@ -344,7 +361,7 @@
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
<br />
For all other claim data,
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</p>
@@ -352,7 +369,7 @@
<h2 class="text-xl font-semibold">Where can I read more?</h2>
<p>
This is part of the
<a href="https://livesofgiving.org" class="text-blue-500">
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
Lives of Giving
</a>
initiative.
@@ -362,7 +379,7 @@
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold">
For any other questions, including removing your data:
For any other questions, including removing all your data from the public ledger:
</h2>
<p>
Contact us at

View File

@@ -62,7 +62,7 @@
<div v-if="showShortcutBvc" class="mb-4">
<router-link
:to="{ name: 'quick-action-bvc' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Bountiful Voluntaryist Community Actions</router-link
>
@@ -72,7 +72,7 @@
<div class="mb-8">
<div v-if="isCreatingIdentifier">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading&hellip;
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
</p>
</div>
<div
@@ -85,7 +85,7 @@
</p>
<router-link
:to="{ name: 'start' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Create An Identifier</router-link
>
@@ -95,21 +95,13 @@
v-else-if="!isRegistered"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
Someone must register your identifier before you can record anyone's
giving.
Someone must register you before you can give or offer.
<router-link
:to="{ name: 'contact-qr' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Show Them Your Identifier Info</router-link
>
<br />
To double-check that you're registered,
<br />
<router-link :to="{ name: 'account' }" class="text-blue-500">
see your Usage Limits on the Account
<fa icon="circle-user" /> page.</router-link
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
Show Them Your Identifier Info
</router-link>
</div>
<div v-else>
@@ -129,7 +121,7 @@
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous/Unnamed
Unnamed/Unknown
</h3>
</li>
<li
@@ -140,7 +132,7 @@
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
@@ -153,14 +145,14 @@
<div class="flex justify-between">
<router-link
v-if="allContacts.length >= 7"
:to="{ name: 'contact-gives' }"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
:to="{ name: 'contact-gift' }"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Choose From All Contacts
</router-link>
<button
@click="openGiftedPrompts()"
class="block text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
class="block text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Ideas...
</button>
@@ -168,11 +160,7 @@
</div>
</div>
<GiftedDialog
ref="customDialog"
message="Received from"
showGivenToUser="true"
/>
<GiftedDialog ref="customDialog" />
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
@@ -181,7 +169,7 @@
<div class="flex items-center mb-4">
<h2 class="text-xl font-bold">Latest Activity</h2>
<button @click="openFeedFilters()" class="block text-center ml-auto">
<span class="text-sm uppercase text-white">
<span class="text-sm text-white">
<span
v-if="resultsAreFiltered()"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md"
@@ -205,7 +193,7 @@
:key="record.jwtId"
>
<div
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="record.jwtId == feedLastViewedClaimId"
>
You've already seen all the following
@@ -223,6 +211,39 @@
</span>
</span>
<span class="col-span-10 justify-self-stretch">
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
<span
v-if="
record.giver.profileImageUrl ||
record.receiver.profileImageUrl
"
>
<EntityIcon
v-if="record.agentDid !== activeDid"
:icon-size="32"
:profile-image-url="record.giver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
<fa
v-if="
record.agentDid !== activeDid &&
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
icon="ellipsis"
class="text-slate"
/>
<EntityIcon
v-if="
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
:iconSize="32"
:profile-image-url="record.receiver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
/>
</span>
-->
<span class="pl-2">
{{ giveDescription(record) }}
</span>
@@ -230,7 +251,7 @@
<fa
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
></fa>
/>
</a>
</span>
<span class="col-span-1 justify-self-end">
@@ -241,7 +262,7 @@
encodeURIComponent(record.fulfillsPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500"></fa>
<fa icon="hammer" class="text-blue-500" />
</router-link>
</span>
</div>
@@ -255,7 +276,7 @@
</InfiniteScroll>
<div v-if="isFeedLoading">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading&hellip;
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
</p>
</div>
<div v-if="!isFeedLoading && feedData.length === 0">
@@ -271,6 +292,7 @@
import { UAParser } from "ua-parser-js";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
@@ -294,8 +316,9 @@ import {
contactForDid,
containsNonHiddenDid,
didInfoForContact,
fetchEndorserRateLimits,
getPlanFromCache,
GiverInputInfo,
GiverReceiverInputInfo,
GiveSummaryRecord,
} from "@/libs/endorserServer";
import { generateSaveAndActivateIdentity } from "@/libs/util";
@@ -304,12 +327,14 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
giver: {
displayName: string;
known: boolean;
profileImageUrl?: string;
};
image?: string;
recipientProjectName?: string;
receiver: {
displayName: string;
known: boolean;
profileImageUrl?: string;
};
}
@@ -347,7 +372,7 @@ export default class HomeView extends Vue {
showShortcutBvc = false;
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
public async getIdentity(activeDid: string) {
public async getIdentity(activeDid: string): Promise<IIdentifier | null> {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
@@ -393,8 +418,29 @@ export default class HomeView extends Vue {
this.isCreatingIdentifier = false;
}
// this returns a Promise but we don't need to wait for it
// someone may have have registered after sharing contact info
if (!this.isRegistered && this.activeDid) {
const identity = await this.getIdentity(this.activeDid);
try {
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
identity as IIdentifier,
);
if (resp.status === 200) {
// we just needed to know that they're registered
await db.open();
db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true,
});
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
}
// this returns a Promise but we don't need to wait for it
await this.updateAllFeed();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -460,7 +506,10 @@ export default class HomeView extends Vue {
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
public async loadMoreGives(payload: boolean) {
if (payload) {
// Since feed now loads projects along the way, it takes longer
// and the InfiniteScroll component triggers a load before finished.
// One alternative is to totally separate the project link loading.
if (payload && !this.isFeedLoading) {
this.updateAllFeed();
}
}
@@ -498,6 +547,9 @@ export default class HomeView extends Vue {
// recipient.did is for legacy data, before March 2023
const recipientDid =
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
// This has indeed proven problematic. See loadMoreGives
// We should display it immediately and then get the plan later.
const plan = await getPlanFromCache(
record.fulfillsPlanHandleId,
identity,
@@ -587,7 +639,7 @@ export default class HomeView extends Vue {
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const response = await fetch(
endorserApiServer +
"/api/v2/report/gives?giftNotTrade=true&" +
"/api/v2/report/gives?giftNotTrade=true" +
beforeQuery,
{
method: "GET",
@@ -676,7 +728,7 @@ export default class HomeView extends Vue {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
(this.$router as Router).push(route);
}
displayAmount(code: string, amt: number) {
@@ -687,8 +739,16 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
openDialog(giver?: GiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(giver);
openDialog(giver?: GiverReceiverInputInfo) {
(this.$refs.customDialog as GiftedDialog).open(
giver,
{
did: this.activeDid,
name: "you",
},
undefined,
"Given by " + (giver?.name || "someone not named"),
);
}
openGiftedPrompts() {

View File

@@ -112,10 +112,15 @@ export default class IdentitySwitcherView extends Vue {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const did = JSON.parse(accounts[n].identity)["did"];
this.otherIdentities.push({ did: did });
if (did && this.activeDid === did) {
this.activeDidInIdentities = true;
try {
const did = accounts[n]["did"];
this.otherIdentities.push({ did: did });
if (did && this.activeDid === did) {
this.activeDidInIdentities = true;
}
} catch (err) {
console.error("Error parsing identity:", err);
continue;
}
}
} catch (err) {

View File

@@ -18,7 +18,7 @@
Enter your seed phrase below to import your identifier on this device.
</p>
<!-- id used by puppeteer test script -->
<input
<textarea
id="seed-input"
type="text"
placeholder="Seed Phrase"

View File

@@ -29,10 +29,31 @@
v-model="fullClaim.name"
/>
<div class="flex justify-center mt-4">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
/>
</span>
<span v-else>
<fa
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openImageDialog"
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<input
type="text"
placeholder="Other Authorized Representative"
class="block w-full rounded border border-slate-400 px-3 py-2"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid"
/>
<div class="mb-4">
@@ -64,6 +85,23 @@
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="flex mb-4 columns-3 w-full">
<input
v-model="startDateInput"
placeholder="Start Date"
type="date"
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time"
type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/>
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
</div>
<div class="flex items-center mb-4">
<input
type="checkbox"
@@ -99,7 +137,7 @@
<l-marker
v-if="latitude && longitude"
:lat-lng="[latitude, longitude]"
@click="maybeEraseLatLong()"
@click="confirmEraseLatLong()"
/>
</l-map>
</div>
@@ -137,23 +175,32 @@
import "leaflet/dist/leaflet.css";
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import { DateTime } from "luxon";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import * as libsUtil from "@/libs/util";
import { useAppStore } from "@/store/app";
import { IIdentifier } from "@veramo/core";
import { PlanVerifiableCredential } from "@/libs/endorserServer";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
@Component({
components: { LMap, LMarker, LTileLayer, QuickNav },
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
activeDid = "";
agentDid = "";
@@ -165,6 +212,7 @@ export default class NewEditProjectView extends Vue {
name: "",
description: "",
}; // this default is only to avoid errors before plan is loaded
imageUrl = "";
includeLocation = false;
isHiddenSave = false;
isHiddenSpinner = true;
@@ -174,12 +222,12 @@ export default class NewEditProjectView extends Vue {
numAccounts = 0;
projectId = localStorage.getItem("projectId") || "";
projectIssuerDid = "";
startDateInput?: string;
startTimeInput?: string;
zoneName = DateTime.local().zoneName;
zoom = 2;
async beforeCreate() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
libsUtil = libsUtil;
public async getIdentity(activeDid: string) {
await accountsDB.open();
@@ -206,7 +254,10 @@ export default class NewEditProjectView extends Vue {
return headers;
}
async created() {
async mounted() {
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
@@ -214,7 +265,7 @@ export default class NewEditProjectView extends Vue {
if (this.projectId) {
if (this.numAccounts === 0) {
console.error("Error: no account was found.");
this.errNote("There was a problem loading your account info.");
} else {
const identity = await this.getIdentity(this.activeDid);
if (!identity) {
@@ -243,6 +294,7 @@ export default class NewEditProjectView extends Vue {
if (resp.status === 200) {
this.projectIssuerDid = resp.data.issuer;
this.fullClaim = resp.data.claim;
this.imageUrl = resp.data.claim.image || "";
this.lastClaimJwtId = resp.data.id;
if (this.fullClaim?.location) {
this.includeLocation = true;
@@ -252,9 +304,94 @@ export default class NewEditProjectView extends Vue {
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
).toLocal();
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm");
}
}
} catch (error) {
console.error("Got error retrieving that project", error);
this.errNote("There was an error retrieving that project.");
}
}
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "PlanAction");
}
confirmDeleteImage() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Are you sure you want to delete the image?",
text: "",
onYes: this.deleteImage,
},
-1,
);
}
async deleteImage() {
if (!this.imageUrl) {
return;
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const token = await accessToken(identity);
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem deleting the image.",
},
5000,
);
return;
}
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error deleting the image.",
},
5000,
);
}
}
}
@@ -271,6 +408,11 @@ export default class NewEditProjectView extends Vue {
} else {
delete vcClaim.agent;
}
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
delete vcClaim.image;
}
if (this.includeLocation) {
vcClaim.location = {
geo: {
@@ -282,6 +424,28 @@ export default class NewEditProjectView extends Vue {
} else {
delete vcClaim.location;
}
if (this.startDateInput) {
try {
const startTimeFull = this.startTimeInput || "00:00:00";
const fullTimeString = this.startDateInput + " " + startTimeFull;
// throw an error on an invalid date or time string
vcClaim.startTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.startTime;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "The date was invalid so it was not set.",
},
5000,
);
}
} else {
delete vcClaim.startTime;
}
// Make a payload for the claim
const vcPayload = {
vc: {
@@ -401,12 +565,25 @@ export default class NewEditProjectView extends Vue {
}
}
public maybeEraseLatLong() {
if (window.confirm("Are you sure you don't want to mark a location?")) {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
}
confirmEraseLatLong() {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: async () => {
this.eraseLatLong();
},
},
-1,
);
}
public eraseLatLong() {
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
}
public onCancelClick() {

View File

@@ -5,7 +5,7 @@
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<div id="ViewBreadcrumb">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@@ -15,23 +15,25 @@
<fa icon="chevron-left" class="fa-fw"></fa>
</button>
Idea
<h2 class="text-xl font-semibold">{{ name }}</h2>
</h1>
</div>
<!-- Project Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4">
<div>
<div class="block pb-4 flex gap-4">
<div class="flex-none w-16 pt-1">
<div class="pb-4 flex gap-4">
<div class="pt-1">
<ProjectIcon
:entityId="projectId"
:iconSize="64"
class="block border border-slate-300 rounded-md"
></ProjectIcon>
:imageUrl="imageUrl"
:linkToFull="true"
class="block border border-slate-300 rounded-md max-h-16 max-w-16"
/>
</div>
<div class="overflow-hidden">
<h2 class="text-xl font-semibold">{{ name }}</h2>
<div class="text-sm mb-3">
<div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa>
@@ -53,9 +55,9 @@
<span v-show="showDidCopy">Copied DID</span>
</span>
</div>
<div v-if="timeSince">
<div v-if="startTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ timeSince }}
{{ startTime }}
</div>
<div v-if="latitude || longitude">
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
@@ -69,8 +71,9 @@
</div>
<div v-if="url">
<fa icon="globe" class="fa-fw text-slate-400"></fa>
<a :href="addScheme(url)" target="_blank" class="underline"
>{{ domainForWebsite(this.url) }}
<a :href="addScheme(url)" target="_blank" class="underline">
{{ domainForWebsite(this.url) }}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</div>
</div>
@@ -112,11 +115,11 @@
</button>
</div>
<div v-if="activeDid" class="mb-4">
<div v-if="activeDid" class="mt-4">
<div class="text-center">
<button
@click="openOfferDialog()"
class="block w-full text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
class="block w-full text-lg font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Offer (maybe with conditions)...
</button>
@@ -125,14 +128,8 @@
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<div v-if="activeDid">
<GiftedDialog
ref="customGiveDialog"
message="Received from"
:projectId="this.projectId"
>
</GiftedDialog>
<div class="text-center">
<p class="mt-2 mb-4 text-center">Record a contribution from:</p>
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
</div>
<ul
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5"
@@ -153,7 +150,7 @@
<h3
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
Anonymous/Unnamed
Unnamed/Unknown
</h3>
</li>
<li
@@ -164,7 +161,7 @@
<EntityIcon
:contact="contact"
:iconSize="64"
class="mx-auto border border-slate-300 rounded-md mb-1"
class="mx-auto border border-slate-300 rounded-md mb-1 cursor-pointer"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
@@ -178,18 +175,18 @@
<a
v-if="allContacts.length >= 7"
@click="onClickAllContactsGifting()"
class="block text-center text-md font-bold uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
class="block text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
Show More Contacts&hellip;
</a>
<GiftedDialog ref="customGiveDialog" :projectId="this.projectId" />
</div>
<!-- Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Offers & Gifts to & from this -->
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Offered To This Idea
</h3>
<h3 class="text-sm font-semibold mb-3">Offered To This Idea</h3>
<div v-if="offersToThis.length === 0">
(None yet. Wanna
@@ -246,10 +243,13 @@
</div>
</li>
</ul>
<div v-if="offersHitLimit" class="text-center text-blue-500">
<button @click="loadOffers()">Load More</button>
</div>
</div>
<div class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
<h3 class="text-sm font-semibold mb-3">Given To This Idea</h3>
<div v-if="givesToThis.length === 0">
(None yet. If you've seen something, say something by clicking a
@@ -293,12 +293,18 @@
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
<a v-if="checkIsConfirmable(give)" @click="confirmClaim(give)">
<a
v-if="checkIsConfirmable(give)"
@click="confirmConfirmClaim(give)"
>
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a>
</div>
</li>
</ul>
<div v-if="givesHitLimit" class="text-center text-blue-500">
<button @click="loadGives()">Load More</button>
</div>
</div>
<div class="grid items-start grid-cols-1 gap-4">
@@ -319,6 +325,7 @@
{{ plan.name }}
</button>
</div>
<div v-if="fulfillersToHitLimit" class="text-center">Load More</div>
</div>
</div>
@@ -343,7 +350,6 @@
<script lang="ts">
import { AxiosError, RawAxiosRequestHeaders } from "axios";
import * as moment from "moment";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
@@ -363,7 +369,7 @@ import * as libsUtil from "@/libs/util";
import {
BLANK_GENERIC_SERVER_RECORD,
GenericCredWrapper,
GiverInputInfo,
GiverReceiverInputInfo,
GiveSummaryRecord,
OfferSummaryRecord,
PlanSummaryRecord,
@@ -392,15 +398,19 @@ export default class ProjectViewView extends Vue {
expanded = false;
fulfilledByThis: PlanSummaryRecord | null = null;
fulfillersToThis: Array<PlanSummaryRecord> = [];
fulfillersToHitLimit = false;
givesToThis: Array<GiveSummaryRecord> = [];
givesHitLimit = false;
imageUrl = "";
issuer = "";
latitude = 0;
longitude = 0;
name = "";
offersToThis: Array<OfferSummaryRecord> = [];
offersHitLimit = false;
projectId = localStorage.getItem("projectId") || ""; // handle ID
showDidCopy = false;
timeSince = "";
startTime = "";
truncatedDesc = "";
truncateLength = 40;
url = "";
@@ -420,7 +430,7 @@ export default class ProjectViewView extends Vue {
const accountsArr: Account[] = await accounts?.toArray();
this.allMyDids = accountsArr.map((acc) => acc.did);
const account = accountsArr.find((acc) => acc.did === this.activeDid);
const identity = JSON.parse(account?.identity || "null");
const identity = JSON.parse((account?.identity as string) || "null");
const pathParam = window.location.pathname.substring("/project/".length);
if (pathParam) {
@@ -472,13 +482,16 @@ export default class ProjectViewView extends Vue {
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const startTime = resp.data.startTime;
const startTime = resp.data.claim?.startTime;
if (startTime != null) {
const eventDate = new Date(startTime);
const now = moment.now();
this.timeSince = moment.utc(now).to(eventDate);
const startDateTime = new Date(startTime);
this.startTime =
startDateTime.toLocaleDateString() +
" " +
startDateTime.toLocaleTimeString();
}
this.agentDid = resp.data.claim?.agent?.identifier;
this.imageUrl = resp.data.claim?.image;
this.issuer = resp.data.issuer;
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
@@ -496,7 +509,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "There was a problem getting that project. See logs for more info.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@@ -510,7 +523,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "That project does not exist.",
},
-1,
5000,
);
} else {
this.$notify(
@@ -520,83 +533,22 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving that project. See logs for more info.",
},
-1,
5000,
);
}
}
const givesInUrl =
this.apiServer +
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve gives to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gives to this project.",
},
-1,
);
console.error(
"Error retrieving gives to this project:",
serverError.message,
);
}
this.loadGives();
const offersToUrl =
this.apiServer +
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([projectId]));
try {
const resp = await this.axios.get(offersToUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.offersToThis = resp.data.data;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve offers to this project.",
},
-1,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving offers to this project.",
},
-1,
);
console.error(
"Error retrieving offers to this project:",
serverError.message,
);
}
this.loadOffers();
this.loadFulfillersTo();
// now load fulfilled-by, a single project
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
const fulfilledByUrl =
this.apiServer +
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
@@ -613,7 +565,7 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Failed to retrieve plans fulfilled by this project.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@@ -625,31 +577,50 @@ export default class ProjectViewView extends Vue {
title: "Error",
text: "Something went wrong retrieving plans fulfilled by this project.",
},
-1,
5000,
);
console.error(
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
}
}
const fulfillersToUrl =
async loadGives() {
const givesUrl =
this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
encodeURIComponent(projectId);
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
let postfix = "";
if (this.givesToThis.length > 0) {
postfix =
"&beforeId=" + this.givesToThis[this.givesToThis.length - 1].jwtId;
}
const givesInUrl = givesUrl + postfix;
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try {
const resp = await this.axios.get(fulfillersToUrl, { headers });
if (resp.status === 200) {
this.fulfillersToThis = resp.data.data;
const resp = await this.axios.get(givesInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.givesToThis = this.givesToThis.concat(resp.data.data);
this.givesHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve plan fulfillers to this project.",
text: "Failed to retrieve more gives to this project.",
},
-1,
5000,
);
}
} catch (error: unknown) {
@@ -659,12 +630,123 @@ export default class ProjectViewView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving plan fulfillers to this project.",
text: "Something went wrong retrieving more gives to this project.",
},
-1,
5000,
);
console.error(
"Error retrieving plan fulfillers to this project:",
"Something went wrong retrieving more gives to this project:",
serverError.message,
);
}
}
async loadOffers() {
const offersUrl =
this.apiServer +
"/api/v2/report/offersToPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
let postfix = "";
if (this.offersToThis.length > 0) {
postfix =
"&beforeId=" + this.offersToThis[this.offersToThis.length - 1].jwtId;
}
const offersInUrl = offersUrl + postfix;
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try {
const resp = await this.axios.get(offersInUrl, { headers });
if (resp.status === 200 && resp.data.data) {
this.offersToThis = this.offersToThis.concat(resp.data.data);
this.offersHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve more offers to this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving more offers to this project.",
},
5000,
);
console.error(
"Something went wrong retrieving more offers to this project:",
serverError.message,
);
}
}
async loadFulfillersTo() {
const fulfillsUrl =
this.apiServer +
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
encodeURIComponent(this.projectId);
let postfix = "";
if (this.fulfillersToThis.length > 0) {
postfix =
"&beforeId=" +
this.fulfillersToThis[this.fulfillersToThis.length - 1].jwtId;
}
const fulfillsInUrl = fulfillsUrl + postfix;
const headers: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
};
const identity = await this.getIdentity(this.activeDid);
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
try {
const resp = await this.axios.get(fulfillsInUrl, { headers });
if (resp.status === 200) {
this.fulfillersToThis = this.fulfillersToThis.concat(resp.data.data);
this.fulfillersToHitLimit = resp.data.hitLimit;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to retrieve more plans that fullfill this project.",
},
5000,
);
}
} catch (error: unknown) {
const serverError = error as AxiosError;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving more plans that fulfull this project.",
},
5000,
);
console.error(
"Something went wrong retrieving more plans that fulfill this project:",
serverError.message,
);
}
@@ -697,8 +779,13 @@ export default class ProjectViewView extends Vue {
);
}
openGiftDialog(contact?: GiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
openGiftDialog(contact?: GiverReceiverInputInfo) {
(this.$refs.customGiveDialog as GiftedDialog).open(
contact,
undefined,
undefined,
"Given by " + (contact?.name || "someone not named"),
);
}
openOfferDialog() {
@@ -708,7 +795,7 @@ export default class ProjectViewView extends Vue {
onClickAllContactsGifting() {
localStorage.setItem("projectId", this.projectId);
const route = {
name: "contact-gives",
name: "contact-gift",
};
this.$router.push(route);
}
@@ -736,10 +823,15 @@ export default class ProjectViewView extends Vue {
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giver: GiverInputInfo = {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord),
};
(this.$refs.customGiveDialog as GiftedDialog).open(giver, offer.handleId);
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
offer.handleId,
"Given by " + (giver?.name || "someone not named"),
);
}
// return an HTTPS URL if it's not a global URL
@@ -780,55 +872,68 @@ export default class ProjectViewView extends Vue {
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
}
confirmConfirmClaim(give: GiveSummaryRecord) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
await this.confirmClaim(give);
},
},
-1,
);
}
// similar code is found in ClaimView
async confirmClaim(give: GiveSummaryRecord) {
if (confirm("Do you personally confirm that this is true?")) {
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
give.fullClaim,
give.jwtId,
give.handleId,
),
// similar logic is found in endorser-mobile
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
give.fullClaim,
give.jwtId,
give.handleId,
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
} else {
console.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation. See logs for more info.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
5000,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation. See logs for more info.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
-1,
);
}
}
}
}

View File

@@ -62,8 +62,8 @@
<!-- New Project -->
<button
v-if="showProjects"
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
v-if="isRegistered && showProjects"
class="fixed right-6 top-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()"
>
<fa icon="plus" class="fa-fw"></fa>
@@ -79,6 +79,13 @@
<!-- Offer Results List -->
<InfiniteScroll v-if="showOffers" @reached-bottom="loadMoreOfferData">
<div v-if="offers.length === 0" class="text-center py-4">
You have not offered anything.
<br />
<router-link to="/discover" class="text-blue-600">
Look for projects worth some of your time.
</router-link>
</div>
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300"
@@ -86,12 +93,12 @@
:key="offer.handleId"
>
<div class="block py-4 flex gap-4">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none w-12">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
<ProjectIcon
:entityId="offer.fulfillsPlanHandleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
></ProjectIcon>
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div v-if="offer.recipientDid" class="flex-none w-12">
<EntityIcon
@@ -177,8 +184,16 @@
</li>
</ul>
</InfiniteScroll>
<!-- Project Results List -->
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
<div v-if="projects.length === 0" class="text-center py-4">
You have not announced any projects.
<br />
Hit the big
<fa icon="plus" class="bg-blue-600 text-white px-1 py-1 rounded-full" />
button. You'll never know until you try.
</div>
<ul class="border-t border-slate-300">
<li
class="border-b border-slate-300"
@@ -189,12 +204,13 @@
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
>
<div class="flex-none w-12">
<div class="flex-none">
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
class="inline-block align-middle border border-slate-300 rounded-md"
></ProjectIcon>
:imageUrl="project.image"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div class="grow overflow-hidden">
@@ -211,6 +227,7 @@
</template>
<script lang="ts">
import { AxiosRequestConfig } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@@ -231,11 +248,18 @@ import EntityIcon from "@/components/EntityIcon.vue";
})
export default class ProjectsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
);
}
apiServer = "";
projects: PlanData[] = [];
currentIid: IIdentifier;
isLoading = false;
isRegistered = false;
numAccounts = 0;
offers: OfferSummaryRecord[] = [];
showOffers = true;
@@ -243,44 +267,26 @@ export default class ProjectsView extends Vue {
libsUtil = libsUtil;
/**
* 'created' hook runs when the Vue instance is first created
**/
async created() {
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid: string = (settings?.activeDid as string) || "";
this.apiServer = (settings?.apiServer as string) || "";
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
if (this.numAccounts === 0) {
console.error("No accounts found.");
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You need an identifier to load your projects.",
},
-1,
);
this.errNote("You need an identifier to load your projects.");
} else {
this.currentIid = await this.getIdentity(activeDid);
await this.loadOffers();
}
} catch (err) {
console.error("Error initializing:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong loading your projects.",
},
-1,
);
this.errNote("Something went wrong loading your projects.");
}
}
@@ -297,12 +303,19 @@ export default class ProjectsView extends Vue {
try {
this.isLoading = true;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 || !resp.data.data) {
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
const plans: PlanData[] = resp.data.data;
for (const plan of plans) {
const { name, description, handleId, issuerDid, rowid } = plan;
this.projects.push({ name, description, handleId, issuerDid, rowid });
const { name, description, handleId, image, issuerDid, rowid } = plan;
this.projects.push({
name,
description,
image,
handleId,
issuerDid,
rowid,
});
}
} else {
console.error(
@@ -310,28 +323,12 @@ export default class ProjectsView extends Vue {
resp.status,
resp.data,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to get projects from the server. Try again later.",
},
-1,
);
this.errNote("Failed to get projects from the server.");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Got error loading plans:", error.message || error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading projects.",
},
-1,
);
this.errNote("Got an error loading projects.");
} finally {
this.isLoading = false;
}
@@ -422,8 +419,8 @@ export default class ProjectsView extends Vue {
try {
this.isLoading = true;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 || !resp.data.data) {
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) {
this.offers = this.offers.concat(resp.data.data);
} else {
console.error(

View File

@@ -97,6 +97,7 @@ export default class QuickActionBvcBeginView extends Vue {
todayOrPreviousStartDate = "";
async mounted() {
// use the time zone for Bountiful
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
if (currentOrPreviousSat.weekday < 6) {
// it's not Saturday or Sunday,
@@ -125,6 +126,8 @@ export default class QuickActionBvcBeginView extends Vue {
const hoursNum = libsUtil.numberOrZero(this.hoursStr);
const identity = await libsUtil.getIdentity(activeDid);
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
// first send the claim for time given
let timeSuccess = false;
if (this.gaveTime && hoursNum > 0) {
@@ -152,7 +155,7 @@ export default class QuickActionBvcBeginView extends Vue {
timeResult?.error?.userMessage ||
"There was an error sending the time.",
},
-1,
5000,
);
}
}
@@ -179,7 +182,7 @@ export default class QuickActionBvcBeginView extends Vue {
attendResult?.error?.userMessage ||
"There was an error sending the attendance.",
},
-1,
5000,
);
}
}
@@ -198,7 +201,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Success",
text: actions,
},
-1,
3000,
);
}
@@ -210,9 +213,9 @@ export default class QuickActionBvcBeginView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: error.userMessage || "There was an error sending claims.",
text: error.userMessage || "There was an error sending the claims.",
},
-1,
5000,
);
}
}

View File

@@ -82,6 +82,16 @@
page.
</span>
</div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
<span>
{{
claimCountByUser === 1
? "There is 1 other claim by you"
: `There are ${claimCountByUser} other claims by you`
}}
which you don't need to confirm.
</span>
</div>
<div>
<h2 class="text-2xl m-2">Anything else?</h2>
@@ -165,6 +175,7 @@ export default class QuickActionBvcBeginView extends Vue {
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
claimCountByUser = 0;
claimCountWithHidden = 0;
claimsToConfirm: GenericCredWrapper[] = [];
claimsToConfirmSelected: string[] = [];
@@ -223,7 +234,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
if (!response.ok) {
console.log("Bad response", response);
console.error("Bad response", response);
throw new Error("Bad response when retrieving claims.");
}
await response.json().then((data) => {
@@ -236,6 +247,7 @@ export default class QuickActionBvcBeginView extends Vue {
dataByOthers,
);
this.claimsToConfirm = dataByOthersWithoutHidden;
this.claimCountByUser = data.length - dataByOthers.length;
this.claimCountWithHidden =
dataByOthers.length - dataByOthersWithoutHidden.length;
});
@@ -248,7 +260,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Error",
text: "There was an error retrieving today's claims to confirm.",
},
-1,
5000,
);
}
this.loadingConfirms = false;
@@ -265,6 +277,8 @@ export default class QuickActionBvcBeginView extends Vue {
try {
const identity = await libsUtil.getIdentity(this.activeDid);
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
// in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults = await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => {
@@ -300,7 +314,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Error",
text: `There was an error sending ${howMany} of the confirmations.`,
},
-1,
5000,
);
}
@@ -330,7 +344,7 @@ export default class QuickActionBvcBeginView extends Vue {
(giveResult as ErrorResult)?.error?.userMessage ||
"There was an error sending that give.",
},
-1,
5000,
);
}
}
@@ -355,7 +369,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Success",
text: actions,
},
-1,
3000,
);
}
@@ -369,7 +383,7 @@ export default class QuickActionBvcBeginView extends Vue {
title: "Error",
text: error.userMessage || "There was an error sending claims.",
},
-1,
5000,
);
}
}

View File

@@ -70,12 +70,9 @@ import * as R from "ramda";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
interface Account {
mnemonic: string;
}
@Component({ components: { QuickNav } })
export default class SeedBackupView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;

View File

@@ -0,0 +1,209 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Image
</h1>
<div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4">
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
<div class="text-center mb-4">Choose how to use this image</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
@click="recordGift"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="gift" class="fa-fw" />
Record a Gift
</button>
<button
@click="recordProfile"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="circle-user" class="fa-fw" />
Save as Profile Image
</button>
<button
@click="cancel"
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
>
<fa icon="ban" class="fa-fw" />
Cancel
</button>
</div>
<PhotoDialog ref="photoDialog" />
</div>
<div class="flex justify-center">
<img
:src="URL.createObjectURL(imageBlob)"
alt="Shared Image"
class="rounded mt-4"
/>
</div>
</div>
<div v-else class="text-center mb-4">
<p>No image found.</p>
</div>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "@/components/PhotoDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import {
DEFAULT_IMAGE_API_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getIdentity } from "@/libs/util";
import { accessToken } from "@/libs/crypto";
@Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid: string | undefined = undefined;
imageBlob: Blob | undefined = undefined;
imageFileName: string | undefined = undefined;
uploading = false;
URL = window.URL || window.webkitURL;
// 'created' hook runs when the Vue instance is first created
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
this.imageBlob = temp.blob;
// clear the temp image
db.temp.delete("shared-photo");
this.imageFileName = this.$route.query.fileName as string;
}
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error loading this data.",
},
-1,
);
}
}
async recordGift() {
await this.sendToImageServer("GiveAction").then((url) => {
if (url) {
this.$router.push({
name: "gifted-details",
query: {
destinationNameAfter: "home",
hideBackButton: true,
imageUrl: url,
recipientDid: this.activeDid,
},
});
}
});
}
recordProfile() {
(this.$refs.photoDialog as PhotoDialog).open(
async (imgUrl) => {
db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl,
});
this.$router.push({ name: "account" });
},
IMAGE_TYPE_PROFILE,
true,
this.imageBlob,
this.imageFileName,
);
}
async cancel() {
this.imageBlob = undefined;
this.imageFileName = undefined;
this.$router.push({ name: "home" });
}
async sendToImageServer(imageType: string) {
this.uploading = true;
let result;
try {
// send the image to the server
const identifier = await getIdentity(this.activeDid as string);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
formData.append(
"image",
this.imageBlob as Blob,
this.imageFileName as string,
);
formData.append("claimType", imageType);
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
if (response?.data?.url) {
this.imageBlob = undefined;
this.imageFileName = undefined;
result = response.data.url as string;
} else {
console.error("Problem uploading the image", response.data);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
"There was a problem saving the picture. " +
(response?.data?.message || ""),
},
5000,
);
}
this.uploading = false;
} catch (error) {
console.error("Error uploading the image", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error saving the picture.",
},
5000,
);
this.uploading = false;
}
return result;
}
}
</script>

View File

@@ -63,6 +63,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { accountsDB } from "@/db/index";
@Component({

View File

@@ -21,8 +21,8 @@
</h1>
</div>
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2>
<div>
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
<button
@click="
@@ -153,13 +153,269 @@
Notif OFF
</button>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" />
<router-link
v-if="showFileNextStep()"
:to="{
name: 'shared-photo',
query: { fileName },
}"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Go to Shared Page
</router-link>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
See console for results.
<br/>
Active DID: {{ activeDid }}
{{ credIdHex ? "has passkey ID" : "has no passkey ID" }}
<div>
Register
<button
@click="register()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
</div>
<div>
Create
<button
@click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Navigator
</button>
</div>
<div v-if="jwt">
Verify
<button
@click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
p256 - broken
</button>
</div>
<button
@click="verifyMyJwt()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Verify Mine
</button>
</div>
</section>
</template>
<script lang="ts">
import { Buffer } from "buffer/";
import { Base64URLString } from "@simplewebauthn/types";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { accountsDB, db } from "@/db/index";
import {
createPeerDid,
PeerSetup,
registerCredential,
verifyJwtP256,
verifyJwtSimplewebauthn,
verifyJwtWebCrypto,
} from "@/libs/didPeer";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
const inputFileNameRef = ref<Blob>();
const TEST_PAYLOAD = {
vc: {
credentialSubject: {
"@context": "https://schema.org",
"@type": "GiveAction",
description: "pizza",
},
},
};
@Component({ components: { QuickNav } })
export default class Help extends Vue {}
export default class Help extends Vue {
// for file import
fileName?: string;
// for passkeys
credIdHex?: string;
activeDid?: string;
jwt?: string;
peerSetup?: PeerSetup;
userName?: string;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.userName = settings?.firstName as string;
await accountsDB.open();
const account: { identity?: string } | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
if (this.activeDid) {
if (account) {
this.credIdHex = account.passkeyCredIdHex as string;
} else {
alert("No account found for DID " + this.activeDid);
}
}
}
async uploadFile(event: Event) {
inputFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.fileName = file.name as string;
const temp = await db.temp.get("shared-photo");
if (temp) {
await db.temp.update("shared-photo", { blob });
} else {
await db.temp.add({ id: "shared-photo", blob });
}
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
showFileNextStep() {
return !!inputFileNameRef.value;
}
public async register() {
const cred = await registerCredential(this.userName);
const publicKeyBytes = cred.publicKeyBytes;
this.activeDid = createPeerDid(publicKeyBytes as Uint8Array);
this.credIdHex = cred.credIdHex as string;
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
did: this.activeDid,
passkeyCredIdHex: this.credIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
});``
}
public async createJwtSimplewebauthn() {
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtSimplewebauthn(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("simple jwt4url", this.jwt);
}
public async createJwtNavigator() {
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtNavigator(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("lower jwt4url", this.jwt);
}
public async verifyP256() {
const decoded = await verifyJwtP256(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifySimplewebauthn() {
const decoded = await verifyJwtSimplewebauthn(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyWebCrypto() {
const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyMyJwt() {
const jwt =
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
const pieces = jwt.split(".");
console.log("pieces", typeof pieces[1], pieces);
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
const clientJSON = Buffer.from(
payload["ClientDataJSONB64URL"],
"base64",
).toString();
const clientData = JSON.parse(clientJSON);
const challenge = clientData.challenge;
const signatureB64URL = pieces[2];
const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString,
this.activeDid as string,
authData,
challenge,
payload["ClientDataJSONB64URL"],
signatureB64URL,
);
console.log("decoded", decoded);
}
}
</script>

View File

@@ -81,7 +81,7 @@ self.addEventListener("push", function (event) {
} else {
title = payload.title || "Update";
}
// getNotificationCount is injected from safari-notifications.js at build time by the vue.config.js configureWebpack apply plugin
// getNotificationCount is injected from safari-notifications.js at build time by the sw_combine.js script
// eslint-disable-next-line no-undef
message = await getNotificationCount();
}
@@ -131,8 +131,32 @@ self.addEventListener("notificationclick", (event) => {
);
});
// This is invoked when the user chooses this as a share_target, mapped to share-target in the manifest.
self.addEventListener("fetch", (event) => {
logConsoleAndDb("Service worker got fetch event.", event);
// Bypass any regular requests not related to Web Share Target
// and also requests that are not exactly to the timesafari.app
// (note that Chrome will send subdomain requests like image-api.timesafari.app through this service worker).
if (
event.request.method !== "POST" ||
!event.request.url.endsWith("/share-target")
) {
event.respondWith(fetch(event.request));
return;
}
// Requests related to Web Share share-target Target.
event.respondWith(
(async () => {
const formData = await event.request.formData();
const photo = formData.get("photo");
// savePhoto is injected from safari-notifications.js at build time by the sw_combine.js script
// eslint-disable-next-line no-undef
await savePhoto(photo);
return Response.redirect("/shared-photo", 303);
})(),
);
});
self.addEventListener("error", (event) => {

View File

@@ -566,6 +566,20 @@ async function getNotificationCount() {
return result;
}
// Store the image blob and go immediate to a page to upload it.
// @param photo - image Blob to store for later retrieval after redirect
async function savePhoto(photo) {
try {
const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("temp", "readwrite");
const store = transaction.objectStore("temp");
await updateRecord(store, { id: "shared-photo", blob: photo });
transaction.oncomplete = () => db.close();
} catch (error) {
console.error("safari-notifications logMessage IndexedDB error", error);
}
}
self.appendDailyLog = appendDailyLog;
self.getNotificationCount = getNotificationCount;
self.decodeBase64 = decodeBase64;

View File

@@ -17,6 +17,27 @@ export default defineConfig({
filename: 'sw_scripts-combined.js',
manifest: {
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
// 192x192 and 512x512 are important for Chrome to show that it's installable
"icons":[
{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},
{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},
{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},
{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}
],
share_target: {
action: '/share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
files: [
{
name: 'photo',
accept: ['image/*'],
},
],
},
},
},
}),
],