Compare commits

..

18 Commits

Author SHA1 Message Date
d4bf045049 Merge branch 'master' into passkey 2024-06-24 20:20:13 -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
145 changed files with 7380 additions and 22975 deletions

View File

@@ -1,5 +1,3 @@
# I tried and failed to set things here with vue-cli-service but
# things may be more reliable with vite so let's try again.
VITE_APP_SERVER=http://localhost:8080

View File

@@ -1,6 +1,4 @@
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
VITE_APP_SERVER=https://timesafari.app
# Only the variables that start with VITE_ are seen in the application process.env in Vue.
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch

View File

@@ -14,21 +14,8 @@ module.exports = {
// ecmaVersion: 2020,
// },
rules: {
"max-len": [
"warn",
{
code: 120,
ignoreComments: true, // why does this not make it allow comment of any length?
ignorePattern: '^\\s*class="[^"]*"$',
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
},
],
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// "prettier/prettier": ["warn", { printWidth: 120 }], // removes errors but adds thousands of warnings
"@typescript-eslint/no-unnecessary-type-constraint": "off",
},
};

View File

@@ -1,27 +0,0 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

4
.gitignore vendored
View File

@@ -27,7 +27,3 @@ pnpm-debug.log*
*.njsproj
*.sln
*.sw?
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -6,273 +6,7 @@ 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).
## [0.4.1] - 2025.02.16
### Fixed
- nostr build issue
- Linting
## [0.4.0] - 2025.02.14
### Changed
- Images in the home feed now take up the full width of the card.
- Clicking the image previously, would open the image in a new tab. Now, clicking the image opens the image in a lightbox view.
### Added
- Clicking an image also now displays an in-app lightbox view of the image.
- The lightbox view includes a download button for the image in mobile view.
## [0.3.57] - 2025.02.11
### Added
- Automatic user creation in onboarding meetings
## [0.3.55] - 2025.02.07
### Added
- End time for projects
## [0.3.54] - 2025.02.06
### Added
- Group onboarding meetings
## [0.3.53] - 2025.01.30
### Added
- Hints for contacting the creator of a project
## [0.3.52] - 2025.01.22
### Fixed
- User profile endpoint server for map was broken.
## [0.3.51] - 2025.01.22
### Fixed
- User profile map jumped on first zoom.
## [0.3.50] - 2025.01.20 - b9fedcd3fd3e34c3fb0fc79150d1a81a76eaeb40
### Added
- User public profiles
## [0.3.49] - 2025.01.09 - 36301ed238ff84df25bb11a8d44a295ee7eaf0f8
### Changed
- Make all external contact links direct to the contact-import page.
- Handle all new-single-contact JWTs in the contacts page, and multiple-contact JWTs in the contacts-import page.
## [0.3.48] - 2025.01.08 - 398f3e64a376789f7eb1c400cd886f5a2cacd588 (but app shows 07c4e58)
### Added
- More sanity-checks on contact-import JWT
## [0.3.47] - 2025.01.06 - 5bf6dd1ee32ca7cc46d39bd7afca58365b422f93
### Added
- Notes on contacts page with new contact-edit page
- Contact methods (only on contact-edit page and under DID details)
- DID view with no DID shows user's info.
### Changed
- URL for user's contact info is now URL to this app (not endorser.ch).
- Extended details (eg. full claim) is beneath details link on claim page.
## [0.3.46] - 2025.01.03 - 9e7056616b5e5acc51e5a8cf7354d408029fefb3
### Added
- More action-oriented questions for the gift prompts
### Fixed
- Contact-list import set visibility for all, even if not chosen.
## [0.3.45] - 2025.01.01 - 65402dc68ce69ccc6cb9aa8d2e7a9249bf4298e0
### Fixed
- Previous project links stayed when following a link.
## [0.3.44] - 2024.12.31 - 694b22987b05482e4527c2478bbe15e6b6f3b532
### Added
- Project counts on a map
## [0.3.42] - 2024.12.27 - 9751934bc24a1040415a8cfeacbae59ed91f92a5
### Added
- Link from certificate page to the claim
### Changed
- Contact data sharing is now a verified JWT.
- Feed pictures are larger.
## [0.3.41] - 2024.12.21 - ff6d14138f26daea6216b051562f0a04681f69fc
### Added
- Link from certificate page to the claim
## [0.3.40] - 2024.12.20 - 77290d9fed3c364243793dc3e9bfe2e994a016b8
### Added
- Only show issuer on certificate if it's not the agent.
## [0.3.39] - 2024.12.20 - d8819155e2acd2b57fdab523168fa5d1d09e80cc
### Added
- Page for a framed claim certificate
## [0.3.38] - 2024.12.14 - f8cae5ad4fee1f114320dcce052299eab12108b2
### Fixed
- Error on BVC confirmation screen (from IndexedDB refactor)
## [0.3.37] - 2024.12.13 - 4d805b43cd25eed73cdd6651f36ad1ec8c109555
### Added
- Record a give from a project on the project page.
- New button on home page opens the gifted dialog.
- On confirmation buttons on the project page gives, mark when unavailable and explain why.
### Changed
- Moved the secret into IndexedDB (and out of localStorage) for more reliability.
- New "invite" destination page helps troubleshoot when JWT link doesn't come through.
### Fixed
- Problem showing claim issuer name
- Problem going "back" from a project page
## [0.3.36] - 2024.11.24 - c8d23647d165016f8a8f575e13d32583242e53ac
### Changed
- More friendly default reminder message
- Blue borders around people to indicate clickability
## [0.3.35] - 2024.11.24 - bff7d0a6320b70349185e26bfac72e3bb17f76df
### Added
- Daily reliable, hard-coded notification message
- Setting to change the partner API server
## [0.3.33] - 2024.11.07 - adb7b16ecf1343c39cba71a7d6bb0e7a973e1102
### Fixed
- Affirm Delivery button on offer claim page didn't work.
- Plans were not showing by default on project page.
## [0.3.32] - 2024.11.06 - 9a3fa38a3fd28f977e06f0265fc39e635c9c5ccd
### Added
- Highlight in green new offers to user & to user's projects on the front page.
## [0.3.31] - 2024.10.25 - 07c02ab98a09d293dd90d9289a7872e7d681d296
### Changed
- Onboarding messages about offers
## [0.3.30]
### Added
- Onboarding messages
## [0.3.29] - 2024.10.09 - babd3832bdfe0c40eaa3869de1b41399a51713c1
### Added
- Invite for a contact to join immediately
### Changed
- Send signed data to nostr endpoints to verify public key ownership.
- Enhanced help & help onboarding.
### Changed in DB or environment
- Uses Endorser.ch version 4.1.1
## [0.3.28] - 2024.09.30 - 84720b94049d29cc0ddd99c50cef2e7176130133
### Added
- Posting to nostr apps Trustroots & TripHopping
- Display of providers on claim view page
### Changed
- Switched BVC-meeting-ending gift to be a gift from the group.
### Changed in DB or environment
- Requires Endorser.ch version 4.1.0
## [0.3.27] - 2024.09.22 - ee23e6f005e47f5bd6f04d804599f6395371b0e4
### Fixed
- Error loading BVC claims to confirm
- Really allow visibility of bulk-imported contacts
## [0.3.26] - 2024.09.16 - 8263ed2b29947b3ccc6f3133bbc9454c222bce28
### Added
- Separate 'isRegistered' flag for each account
### Fixed
- Failure to assign offers to their project
- Alert when looking at one's own activity if not in contacts.
## [0.3.25] - 2024.08.30 - dcbe02d877aecb4cdef2643d90e6595d246a9f82
### Added
- "Ideas" now jumps directly to giving prompt or contact list.
### Fixed
- Empty giver name on gifted-details view
- Previously visited project would show up on the giving-details page.
### Removed
- All unnecessary localStorage for project IDs
## [0.3.23] - 2024.08.30
### Added
- Sections in Help for different kinds of users
- Discovery page parameters so that links with search text work
- Message when no projects are found
## [0.3.21] - 2024.08.24 - a7b89f4bb6da928d56daeffaae7741fa74cc80bf
### Added
- Send list of contacts to someone, and move individual contact actions to detail page.
- Prompt for name in pop-up, and send to different contact-sharing screens.
### Changed
- Moved contact actions from list onto detail page
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30
### Fixed
- Bad "give" verbiage on offer page
- Failing offer test
## [0.3.19] - 2024.08.18 - ee9c14942ceba993bf21a11249601f205158ec71
### Added
- Update of an offer
- Recipient description in offer list
### Fixed
- List of offers wasn't showing.
- Destination page after sharing photo was wrong.
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
### Added
- Photos on more screens
### Fixed
- Share of a photo, including sharing a photo from webkit/Safari which never worked
### Changed in DB or environment
- Nothing (though there's a new temp field in IndexedDB)
## [0.3.15] - 2024.08.04 - c8f0f2c2b16b9f0b4b47d40f7bf29058c7baa68e
### Added
- Edit gives
- Page to edit claim JSON before submitting
- Update of imported contacts
- Improve messaging on give dialog
- Section for gives provided by plan
- Deletion of an identity
- UI for choosing a passkey creation (not enabled on prod)
- Cache signatures for reports for passkey-signed requests
- Refactor: consolidate alternative signing, eg. for passkeys & did:peer
- Playwright tests
### Changed
- Linked projects display below description (instead of at bottom)
### Fixed
- Visibility toggle appearance
### Changed in DB or environment
- Nothing
## [0.3.14] - 2024.06.22 - 1611d22892f683f43856d2503eee7f391b6bbce8
## [0.3.14]
### Added
- Clearer give-confirmation screen
- BX currency https://thebx.medium.com/

View File

@@ -2,10 +2,5 @@
Welcome! We are happy to have your help with this project.
We expect contributions to include automated tests and pass linting. Run the `test-all` task.
Note that some previous features don't have tests and adding more will make you friends quick.
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
If you want to see a code of conduct, we're probably not the people you want to hang with.
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.
Note that all contributions will be under our
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).

View File

@@ -1,8 +0,0 @@
The author disclaims copyright to this source code. In place of a legal notice, here is a blessing:
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
________________________________________________________________
from https://www.sqlite.org/src/info/689401a6cfb4c234 and memorialized here https://spdx.org/licenses/blessing.html

View File

@@ -10,7 +10,7 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup
We like pkgx: `sh <(curl https://pkgx.sh) +vite sh`
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
```
npm install
@@ -21,8 +21,6 @@ npm install
npm run dev
```
See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use http://localhost:3000 for local endorser.ch
### Build the test & production app
```
npm run serve
@@ -33,11 +31,6 @@ npm run serve
npm run lint
```
### Run all UI tests
Look below for the "test-all" instructions.
### 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.
@@ -46,95 +39,36 @@ Look below for the "test-all" instructions.
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Record what version is currently on production.
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
* For test, build the app (because test server is not yet set up to build):
* Run the correct build:
* Test
```
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
# (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build
```
... and transfer to the test server: `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* Production
```
# This picks up values from .env.production
npm run build
```
(Let's replace that with a .env.development or .env.staging file.)
* Get on the server and back up 3 DBs and the time-safari folder.
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
* For prod, get on the server and run the correct build:
... and log onto the server:
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.55 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* Commit changes. Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
## Tests
### Automated
Use the locally running Endorser server:
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
```
npm install
test/test.sh
cp .env.local .env
NODE_ENV=test-local npm run dev
```
If that fails, go to the README.md in the endorser-ch directory and follow the instructions there.
* Install playwright browsers:
```
npx playwright install
```
* Now you can run the local tests:
```
npm run test-all
```
Note that a test will sometimes fail and rerunning may succeed (and repeat if a different test fails).
It's possible to use the global test Endorser (ledger) server (but currently the tests don't all succeed):
`npx playwright test`
It's possible to run with a minimal set of data; the following starts with the bare minimum of test data:
```
rm ../endorser-ch-test-local.sqlite3
NODE_ENV=test-local npm run flyway migrate
NODE_ENV=test-local npm run test test/controller0
NODE_ENV=test-local npm run dev
```
To run a single test like above with the screenshots, use the following:
```
npx playwright test -c playwright.config-local.ts --trace on test-playwright/40-add-contact.spec.ts
```
### Register new user on test server
On the test server, User #0 has rights to register others, so you can start

View File

@@ -1,76 +0,0 @@
# TimeSafari Docs
## Generating PDF from Markdown on OSx
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew.
### Set Up
```bash
brew install pandoc
brew install basictex
# Setting up LaTex packages
# First update tlmgr
sudo tlmgr update --self
# Then install LaTex packages
sudo tlmgr install bbding
sudo tlmgr install enumitem
sudo tlmgr install environ
sudo tlmgr install fancyhdr
sudo tlmgr install framed
sudo tlmgr install import
sudo tlmgr install lastpage # Enables Page X of Y
sudo tlmgr install mdframed
sudo tlmgr install multirow
sudo tlmgr install needspace
sudo tlmgr install ntheorem
sudo tlmgr install tabu
sudo tlmgr install tcolorbox
sudo tlmgr install textpos
sudo tlmgr install titlesec
sudo tlmgr install titling # Required for the fancy headers used
sudo tlmgr install threeparttable
sudo tlmgr install trimspaces
sudo tlmgr install tocloft # Required for \tableofcontents generation
sudo tlmgr install varwidth
sudo tlmgr install wrapfig
# Install fonts
sudo tlmgr install cmbright
sudo tlmgr install collection-fontsrecommended # And set up fonts
sudo tlmgr install fira
sudo tlmgr install fontaxes
sudo tlmgr install libertine # The main font the doc uses
sudo tlmgr install opensans
sudo tlmgr install sourceserifpro
```
#### References
The following guide was adapted to this project except that we install with Brew and have a few more packages.
Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x
### Usage
Use the `pandoc` command to generate a PDF.
```bash
pandoc usage-guide.md -o usage-guide.pdf
```
And you can open the PDF with the `open` command.
```bash
open usage-guide.pdf
```
Or use this one-liner
```bash
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

View File

@@ -1,316 +0,0 @@
---
geometry: margin=1in
header-includes:
- \usepackage{graphicx}
- \usepackage{titling}
- \usepackage{fancyhdr}
- \usepackage{lastpage}
- \pagestyle{fancy}
- \fancyhead[L]{Time Safari Usage Guide}
- \fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
- \fancyhead[R]{}
- \fancyfoot[L]{}
- \fancyfoot[C]{}
- \fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}
- \usepackage{tocloft}
- \usepackage{libertine}
- \renewcommand{\familydefault}{\sfdefault}
- \fancypagestyle{tocstyle}{
\fancyhead[L]{Time Safari Usage Guide}
\fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
\fancyhead[R]{}
\fancyfoot[L]{}
\fancyfoot[C]{}
\fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}}
---
\begin{titlepage}
\centering
\vspace*{\fill}
{\huge\textbf{TimeSafari Usage guide}}
\vspace{1cm}
{\Large Signing up users, adding contacts, and adding gifts.}
\vspace{1cm}
\includegraphics[width=0.5\textwidth]{images/timesafari-logo.png}
\vspace*{\fill}
\vspace{1cm}
{\Large Trent Larson, Kent Bull}
\vspace{0.5cm}
{\large 2024-06-25}
\end{titlepage}
\clearpage
\begin{center}
\includegraphics[width=2cm]{images/timesafari-logo-binoculars.png}
\end{center}
\tableofcontents
\clearpage
# Purpose of Document
Both end-users and development team members need to know how to use TimeSafari.
This document serves to show how to use every feature of the TimeSafari platform.
Sections of this document are geared specifically for software developers and quality assurance
team members.
Companion videos will also describe end-to-end workflows for the end-user.
# TimeSafari
## Overview
\pagebreak
# 1 - End Users
This section covers application usage for people who will use TimeSafari as intended. It is a
simplified guide illustrating how to gain value from using TimeSafari.
\pagebreak
# 2 - Software Developers
This section is tailored for software developers seeking to use the application during development,
quality assurance, and testing.
# Bootstrapping a local development environment
The first concern a software developer has when working on TimeSafari is to set up a local
development environment. This section will guide you through the process.
## Prerequisites
1. Have the following installed on your local machine:
- Node.js and NPM
- A web browser. For this guide, we will use Google Chrome.
- Git
- A code editor
2. Create an API key on Infura. This is necessary for the Endorser API to connect to the Ethereum
blockchain.
- You can create an account on Infura [here](https://infura.io/).\
Click "CREATE NEW API KEY" and label the key. Then click "API Keys" in the top menu bar to
be taken back to the list of keys.
Click "VIEW STATS" on the key you want to use.
![](images/01_infura-api-keys.png){ width=550px }
- Go to the key detail page. Then click "MANAGE API KEY".
![](images/02-infura-key-detail.png){ width=550px }
- Click the copy and paste button next to the string of alphanumeric characters.\
This is your API, also known as your project ID.
![](images/03-infura-api-key-id.png){width=550px }
- Save this for later during the Endorser API setup. This will go in your `INFURA_PROJECT_ID`
environment variable.
## Setup steps
### 1. Clone the following repositories from their respective Git hosts:
- [TimeSafari Frontend](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa)\
This is a Progressive Web App (PWA) built with VueJS and TypeScript.
Note that the clone command here is different from the one you would use for GitHub.
```bash
git clone git clone \
ssh://git@gitea.anomalistdesign.com:222/trent_larson/crowd-funder-for-time-pwa.git
```
- [TimeSafari Backend - Endorser API](https://github.com/trentlarson/endorser-ch)\
This is a NodeJS service providing the backend for TimeSafari.
```bash
git clone git@github.com:trentlarson/endorser-ch.git
```
\pagebreak
### 2. Database creation
#### Alternative 1 - use test data
To generate a development database and perform user setup you can run a local test with instructions
below to generate sample data. Then copy the test database, rename it to `-dev` as below:\
`cp ../endorser-ch-test-local.sqlite3 ../endorser-ch-dev.sqlite3` \
and rerun `npm run dev` to give yourself user #0 and others from the ETHR_CRED_DATA in [the endorser.ch test util file](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js#L90)
#### Alternative 2 - boostrap single seed user
In this method you will end up with two accounts in the database, one for the first boostrap user,
and the second as the primary user you will use during testing. The first user will invite the
second user to the app.
1. Install dependencies and environment variables.\
In endorser-ch install dependencies and set up environment variables to allow starting it up in
development mode.
```bash
cd endorser-ch
npm clean install # or npm ci
cp .env.local .env
```
Edit the .env file's INFURA_PROJECT_ID with the value you saved earlier in the
prerequisites.\
Then create the SQLite database by running `npm run flyway migrate` with environment variables
set correctly to select the default SQLite development user as follows.
```bash
export NODE_ENV=dev
export DBUSER=sa
export DBPASS=sasa
npm run flyway migrate
```
The first run of flyway migrate may take some time to complete because the entire Flyway
distribution must be downloaded prior to executing migrations.
Successful output looks similar to the following:
```
Database: jdbc:sqlite:../endorser-ch-dev.sqlite3 (SQLite 3.41)
Schema history table "main"."flyway_schema_history" does not exist yet
Successfully validated 10 migrations (execution time 00:00.034s)
Creating Schema History table "main"."flyway_schema_history" ...
Current version of schema "main": << Empty Schema >>
Migrating schema "main" to version "1 - initial-anew"
Migrating schema "main" to version "2 - registration"
Migrating schema "main" to version "3 - plan project"
Migrating schema "main" to version "4 - offer gave"
Migrating schema "main" to version "5 - more confirmations"
Migrating schema "main" to version "6 - providers urls"
Migrating schema "main" to version "7 - hash nonce"
Migrating schema "main" to version "8 - project location"
Migrating schema "main" to version "9 - plan links"
Migrating schema "main" to version "10 - gift or trade"
Successfully applied 10 migrations to schema "main", now at version v10 (execution time 00:00.043s)
A Flyway report has been generated here: /Users/kbull/code/timesafari/endorser-ch/report.html
```
\pagebreak
2. Generate the first user in TimeSafari PWA and bootstrap that user in Endorser's database.\
As TimeSafari is an invite-only platform the first user must be manually bootstrapped since
no other users exist to be able to invite the first user. This first user must be added manually
to the SQLite database used by Endorser. In this setup you generate the first user from the PWA.
This user is automatically generated on first usage of the TimeSafari PWA. Bootstrapping that
user is required so that this first user can register other users.
- Change directories into `crowd-funder-for-time-pwa`
```bash
cd ..
cd crowd-funder-for-time-pwa
```
- Ensure the `.env.development` file exists and has the following values:
```env
VITE_DEFAULT_ENDORSER_API_SERVER=http://127.0.0.1:3000
```
- Install dependencies and run in dev mode. For now don't worry about configuring the app. All we
need is to generate the first root user and this happens automatically on app startup.
```bash
npm clean install # or npm ci
npm run dev
```
- Open the app in a browser and go to the developer tools. It is recommended to use a completely
separate browser profile so you do not clear out your existing user account. We will be
completely resetting the PWA app state prior to generating the first user.
In the Developer Tools go to the Application tab.
![](images/04-pwa-chrome-devtools.png){width=350px}
Click the "Clear site data" button and then refresh the page.
- Click the account button in the bottom right corner of the page.
![](images/05-pwa-account-button.png){width=150px}
- This will take you to the account page titled "Your Identity" on which you can see your DID,
a `did:ethr` DID in this case.
![](images/06-pwa-account-page.png){width=350px}
- Copy the DID by selecting it and copying it to the clipboard or by clicking the copy and paste
button as shown in the image.
![](images/07-pwa-did-copied.png){width=200px}
In our case this DID is:\
`did:ethr:0xe4B783c74c8B0e229524e44d0cD898D272E02CD6`
- Add that DID to the following echoed SQL statement where it says `YOUR_DID`
```bash
echo "INSERT INTO registration (did, maxClaims, maxRegs, epoch)
VALUES ('YOUR_DID', 100, 10000, 1719348718092);"
| sqlite3 ./endorser-ch-dev.sqlite3
```
and run this command in the parent directory just above the `endorser-ch` directory.
It needs to be the parent directory of your `endorser-ch` repository because when
`endorser-ch` creates the SQLite database it depends on it creates it in the parent directory
of `endorser-ch`.
- You can verify with an SQL browser tool that your record has been added to the `registration`
table.
![](images/08-endorser-sqlite-row-added.png){width=350px}
3. Then start the Endorser service in development mode with the following commands.
```bash
cd ./endorser-ch
export NODE_ENV=dev
npm run dev
```
This starts the Endorser service on port 3000.
4. Create the second user by opening up a separate browser profile or incognito session, opening the
TimeSafari PWA at `http://localhost:8080`. You will see the yellow banner stating "Someone must
register you before you can give or offer."
![](images/09-pwa-second-profile-first-open.png){width=350px}
- If you want to ensure you have a fresh user account then open the developer tools, clear the
Application data as before, and then refresh the page. This will generate a new user in the
browser's IndexedDB database.
5. Go to the second users' account page to copy the DID.
![](images/10-pwa-second-user-did.png){width=350px}
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account
![](images/11-pwa-first-user-add-contact.png){width=350px}
7. Click the "+" plus icon to add the user.
![](images/12-pwa-first-user-contact-added.png){width=350px}
8. Then click the register button to register the second user.
![](images/13-pwa-first-user-register-second-user-btn.png){width=350px}
9. Click "YES" on the dialog that shows up.
![](images/14-pwa-first-user-register-yes.png){width=350px}
After this a notification will pop up indicating whether registration was successful or not.
10. You have finished the initial set up of users.

6644
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,15 @@
{
"name": "TimeSafari",
"version": "0.4.1",
"version": "0.3.15-beta",
"scripts": {
"dev": "vite",
"serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
"test-local": "npx playwright test -c playwright.config-local.ts --trace on",
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on"
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js"
},
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2",
"@capacitor/core": "^6.1.2",
"@capacitor/ios": "^6.1.2",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",
@@ -28,7 +22,6 @@
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0",
@@ -37,7 +30,6 @@
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^10.9.0",
"@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
@@ -47,24 +39,21 @@
"dexie": "^3.2.7",
"dexie-export-import": "^4.1.1",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.10.4",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"ramda": "^0.29.1",
"readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14",
@@ -82,23 +71,23 @@
"web-did-resolver": "^2.0.27"
},
"devDependencies": {
"@playwright/test": "^1.45.2",
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/node": "^20.14.11",
"@types/ramda": "^0.29.11",
"@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.23.0",
"leaflet": "^1.9.4",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.1",

View File

@@ -1,98 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./test-playwright",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8081",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
permissions: ["clipboard-read"],
},
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
{
name: "Google Chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" },
},
],
/* Configure global timeout; default is 30000 milliseconds */
// the image upload will often not succeed at 5 seconds
timeout: 30000, // various tests fail at various times with 25000
/* Run your local dev server before starting the tests */
/**
* This could be an array of servers, meaning we could start the Endorser server as well:
* {
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
* url: 'http://localhost:3000',
* reuseExistingServer: !process.env.CI,
* },
*
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
* in the user's settings so that it can be blanked out and the default is used.
*/
webServer: {
command:
"VITE_APP_SERVER=http://localhost:8081 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true npm run dev -- --port=8081",
url: "http://localhost:8081",
reuseExistingServer: !process.env.CI,
},
});

View File

@@ -1,82 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test-playwright',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'https://test.timesafari.app',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command:
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
// url: "http://localhost:8080",
// reuseExistingServer: !process.env.CI,
// },
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

View File

@@ -1,86 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2480 4005 c-25 -7 -58 -20 -75 -29 -16 -9 -40 -16 -52 -16 -17 0
-24 -7 -28 -27 -3 -16 -14 -45 -24 -65 -21 -41 -13 -55 18 -38 25 13 67 13 92
-1 15 -8 35 -4 87 17 99 39 130 41 197 10 64 -29 77 -31 107 -15 20 11 20 11
-3 35 -12 13 -30 24 -38 24 -24 1 -132 38 -148 51 -8 7 -11 20 -7 32 12 37
-40 47 -126 22z"/>
<path d="M1450 3775 c-7 -8 -18 -15 -24 -15 -7 0 -31 -14 -54 -32 -29 -22 -38
-34 -29 -40 17 -11 77 -10 77 1 0 5 16 16 35 25 60 29 220 19 290 -18 17 -9
33 -16 37 -16 4 0 31 -15 60 -34 108 -70 224 -215 282 -353 30 -71 53 -190 42
-218 -10 -27 -23 -8 -52 75 -30 90 -88 188 -120 202 -13 6 -26 9 -29 6 -3 -2
11 -51 30 -108 28 -83 35 -119 35 -179 0 -120 -22 -127 -54 -17 -11 37 -13 21
-18 -154 -5 -180 -8 -200 -32 -264 -51 -132 -129 -245 -199 -288 -21 -12 -79
-49 -129 -80 -161 -102 -294 -141 -473 -141 -228 0 -384 76 -535 259 -81 99
-118 174 -154 312 -31 121 -35 273 -11 437 19 127 19 125 -4 125 -23 0 -51
-34 -87 -104 -14 -28 -33 -64 -41 -81 -19 -34 -22 -253 -7 -445 9 -106 12
-119 44 -170 19 -30 42 -67 50 -81 64 -113 85 -140 130 -169 28 -18 53 -44 61
-62 8 -20 36 -45 83 -76 62 -39 80 -46 151 -54 44 -5 96 -13 115 -18 78 -20
238 -31 282 -19 24 6 66 8 95 5 76 -9 169 24 319 114 32 19 80 56 106 82 27
26 52 48 58 48 5 0 27 26 50 58 48 66 56 70 132 71 62 1 165 29 238 64 112 55
177 121 239 245 37 76 39 113 10 267 -12 61 -23 131 -26 156 -5 46 -5 47 46
87 92 73 182 70 263 -8 l51 -49 -6 -61 c-4 -34 -13 -85 -21 -113 -28 -103 -30
-161 -4 -228 16 -44 32 -67 55 -83 18 -11 39 -37 47 -58 10 -23 37 -53 73 -81
32 -25 69 -57 82 -71 14 -14 34 -26 47 -26 12 0 37 -7 56 -15 20 -8 66 -17
104 -20 107 -10 110 -11 150 -71 50 -75 157 -177 197 -187 18 -5 53 -24 78
-42 71 -51 176 -82 304 -89 61 -4 127 -12 147 -18 29 -9 45 -8 77 6 23 9 50
16 60 16 31 0 163 46 216 76 28 15 75 46 105 69 30 23 69 49 85 58 17 8 46 31
64 51 19 20 40 36 47 36 18 0 77 70 100 120 32 66 45 108 55 173 5 32 16 71
24 87 43 84 43 376 0 549 -27 105 -43 127 -135 188 -30 21 -65 46 -77 57 -13
11 -23 17 -23 14 0 -3 21 -46 47 -94 79 -151 85 -166 115 -263 25 -83 28 -110
28 -226 0 -144 -17 -221 -75 -335 -39 -77 -208 -244 -304 -299 -451 -263 -975
-67 -1138 426 -23 70 -26 95 -28 254 -1 108 -7 183 -14 196 -6 12 -11 31 -11
43 0 32 31 122 52 149 10 13 18 28 18 34 0 5 25 40 56 78 60 73 172 170 219
190 30 12 30 13 6 17 -15 2 -29 -2 -37 -12 -6 -9 -16 -16 -22 -16 -6 0 -23
-11 -39 -24 -15 -12 -33 -25 -40 -27 -17 -6 -82 -60 -117 -97 -65 -70 -75 -82
-107 -133 -23 -34 -35 -46 -37 -35 -3 16 20 87 44 134 6 12 9 34 6 48 -4 22
-8 25 -31 19 -14 -3 -38 -15 -53 -26 -34 -24 -34 -21 -6 28 65 112 184 206
291 227 15 3 39 9 55 12 l27 6 -24 9 c-90 35 -304 -66 -478 -225 -39 -36 -74
-66 -77 -66 -22 0 18 82 72 148 19 23 32 46 28 49 -4 4 -26 13 -49 19 -73 21
-161 54 -171 64 -6 6 -20 10 -32 10 -21 0 -21 -1 -8 -40 45 -130 8 -247 -93
-299 -25 -13 -31 0 -14 29 15 22 1 33 -22 17 -56 -36 -117 -22 -117 28 0 13
-16 47 -35 76 -22 34 -33 60 -29 73 4 16 -3 26 -26 39 -16 10 -30 21 -30 25 1
18 54 64 87 76 l38 13 -33 5 c-30 4 -115 -18 -154 -42 -13 -7 -20 -5 -27 8 -9
16 -12 16 -53 1 -160 -61 -258 -104 -258 -114 0 -7 10 -20 21 -31 103 -91 217
-297 249 -449 28 -135 41 -237 35 -276 -14 -91 -48 -170 -97 -220 -44 -47 -68
-60 -68 -40 0 6 4 12 8 15 5 3 24 35 42 72 l33 67 -6 141 c-4 103 -11 158 -26
205 -12 35 -21 70 -21 77 0 7 -20 56 -45 108 -82 173 -227 322 -392 401 -67
33 -90 39 -163 42 -108 5 -130 10 -130 28 0 20 -63 20 -80 0z"/>
<path d="M3710 3765 c0 -20 8 -28 39 -41 22 -8 42 -22 45 -30 5 -14 42 -19 70
-8 10 4 -7 21 -58 55 -41 27 -79 49 -85 49 -6 0 -11 -11 -11 -25z"/>
<path d="M3173 3734 c-9 -25 10 -36 35 -18 12 8 22 19 22 25 0 16 -50 10 -57
-7z"/>
<path d="M1982 3728 c6 -16 36 -34 44 -26 3 4 4 14 1 23 -7 17 -51 21 -45 3z"/>
<path d="M1540 3620 c0 -5 7 -10 16 -10 8 0 12 5 9 10 -3 6 -10 10 -16 10 -5
0 -9 -4 -9 -10z"/>
<path d="M4467 3624 c-4 -4 23 -27 60 -50 84 -56 99 -58 67 -9 -28 43 -107 79
-127 59z"/>
<path d="M655 3552 c-11 -2 -26 -9 -33 -14 -7 -6 -27 -18 -45 -27 -36 -18 -58
-64 -39 -83 9 -9 25 1 70 43 53 48 78 78 70 84 -2 1 -12 -1 -23 -3z"/>
<path d="M1015 3460 c-112 -24 -247 -98 -303 -165 -53 -65 -118 -214 -136
-311 -20 -113 -20 -145 -1 -231 20 -88 49 -153 102 -230 79 -113 186 -182 331
-214 108 -24 141 -24 247 1 130 30 202 72 316 181 102 100 153 227 152 384 0
142 -58 293 -150 395 -60 67 -180 145 -261 171 -75 23 -232 34 -297 19z m340
-214 c91 -43 174 -154 175 -234 0 -18 -9 -51 -21 -73 -19 -37 -19 -42 -5 -64
35 -54 12 -121 -48 -142 -22 -7 -47 -19 -55 -27 -9 -8 -41 -27 -71 -42 -50
-26 -64 -29 -155 -29 -111 0 -152 14 -206 68 -49 49 -63 85 -64 162 0 59 4 78
28 118 31 52 96 105 141 114 23 5 33 17 56 68 46 103 121 130 225 81z"/>
<path d="M3985 3464 c-44 -7 -154 -44 -200 -67 -55 -28 -138 -96 -162 -132
-10 -16 -39 -75 -64 -130 l-44 -100 0 -160 0 -160 45 -90 c53 -108 152 -214
245 -264 59 -31 215 -71 281 -71 53 0 206 40 255 67 98 53 203 161 247 253 53
113 74 193 74 280 -1 304 -253 564 -557 575 -49 2 -103 1 -120 -1z m311 -220
c129 -68 202 -209 160 -309 -15 -35 -15 -42 -1 -72 26 -55 -3 -118 -59 -129
-19 -3 -43 -15 -53 -26 -26 -29 -99 -64 -165 -78 -45 -10 -69 -10 -120 -1 -74
15 -113 37 -161 91 -110 120 -50 331 109 385 24 8 44 23 52 39 6 14 18 38 25
53 33 72 127 93 213 47z"/>
<path d="M487 3394 c-21 -12 -27 -21 -25 -40 2 -14 7 -26 12 -27 14 -3 48 48
44 66 -3 14 -6 14 -31 1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -45,7 +45,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
@@ -68,7 +68,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
@@ -91,7 +91,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
@@ -114,7 +114,7 @@
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<p class="text-sm">{{ notification.text }}</p>
<button
@click="close(notification.id)"
@@ -180,9 +180,7 @@
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes{{
notification.yesText ? ", " + notification.yesText : ""
}}
Yes
</button>
<button
@@ -194,7 +192,7 @@
"
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{{ notification.noText ? ", " + notification.noText : "" }}
No
</button>
<label
@@ -229,7 +227,7 @@
? notification.onCancel(stopAsking)
: null;
close(notification.id);
stopAsking = false; // reset value for next time they open this modal
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"
>
@@ -238,7 +236,63 @@
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-permission'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
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 v-if="serviceWorkerReady" class="text-lg mb-4">
Would you like to be notified of new activity once a day?
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 10
seconds...
<fa icon="spinner" spin />
</p>
<div v-if="serviceWorkerReady">
<span class="flex flex-row justify-center">
<span class="mt-2">Yes, tell me at: </span>
<input
type="number"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
v-model="hourInput"
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
>
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
<span v-else> PM <fa icon="chevron-up" /> </span>
</span>
</span>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="
() => {
if (checkHour()) {
close(notification.id);
turnOnNotifications();
}
}
"
>
Turn on Daily Message
</button>
</div>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
>
No, Not Now
</button>
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-mute'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -252,17 +306,17 @@
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
For 1 Day
For 1 Hour
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
For 2 Days
For 8 Hours
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
For 1 Week
For 24 Hours
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@@ -278,7 +332,6 @@
</div>
</div>
</div>
<div
v-if="notification.type === 'notification-off'"
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
@@ -288,17 +341,17 @@
>
<div class="w-full px-6 py-6 text-slate-900 text-center">
<p class="text-lg mb-4">
Would you like to <b>turn off</b> this notification?
Would you like to <b>turn off</b> notifications for this app?
</p>
<button
@click="
close(notification.id);
turnOffNotifications(notification);
turnOffNotifications();
"
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
>
Turn Off Notification
Turn Off Notifications
</button>
<button
@click="close(notification.id)"
@@ -318,116 +371,420 @@
<style></style>
<script lang="ts">
import axios from "axios";
import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import { NotificationIface } from "./constants/app";
import * as libsUtil from "@/libs/util";
interface ServiceWorkerMessage {
type: string;
data: string;
}
interface ServiceWorkerResponse {
// Define the properties and their types
success: boolean;
message?: string;
}
// Example interface for error
interface ErrorResponse {
message: string;
// Other properties as needed
}
interface VapidResponse {
data: {
vapidKey: string;
};
}
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
notifyTime: { utcHour: number };
}
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { sendTestThroughPushServer } from "@/libs/util";
@Component
export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false;
b64 = "";
hourAm = true;
hourInput = "8";
serviceWorkerReady = true;
truncateLongWords(sentence: string) {
return sentence
.split(" ")
.map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word))
.join(" ");
async mounted() {
try {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
if (pushUrl.startsWith("http://localhost")) {
console.log("Not checking for VAPID in this local environment.");
} else {
await axios
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.b64 = response.data?.vapidKey || "";
console.log("Got vapid key:", this.b64);
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("New service worker is now controlling the page");
});
});
if (!this.b64) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
-1,
);
}
}
} catch (error) {
if (window.location.host.startsWith("localhost")) {
console.log("Ignoring the error getting VAPID for local development.");
} else {
console.error("Got an error initializing notifications:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Got an error setting notifications.",
},
-1,
);
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
}
async turnOffNotifications(notification: NotificationIface) {
let subscription: object | null = null;
private sendMessageToServiceWorker(
message: ServiceWorkerMessage,
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (navigator.serviceWorker.controller) {
const messageChannel = new MessageChannel();
let allGoingOff = false;
const settings = await retrieveSettingsForActiveAccount();
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime;
if (!notifyingNewActivity || !notifyingReminder) {
// the other notification is already off, so fully unsubscribe now
allGoingOff = true;
messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) {
reject(event.data.error as ErrorResponse);
} else {
resolve(event.data as ServiceWorkerResponse);
}
};
navigator.serviceWorker.controller.postMessage(message, [
messageChannel.port2,
]);
} else {
reject("Service worker controller not available");
}
});
}
private askPermission(): Promise<NotificationPermission> {
console.log("Requesting permission for notifications:", navigator);
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
return Promise.reject("Service worker not available.");
}
await navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then(async (subscript: PushSubscription | null) => {
if (subscript) {
subscription = subscript.toJSON();
if (allGoingOff) {
await subscript.unsubscribe();
}
} else {
logConsoleAndDb("Subscription object is not available.");
}
})
.catch((error) => {
logConsoleAndDb(
"Push provider server communication failed: " + JSON.stringify(error),
true,
);
});
const secret = localStorage.getItem("secret");
if (!secret) {
return Promise.reject("No secret found.");
}
if (!subscription) {
// there is no endpoint or auth for the server to compare, so we're done
return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error));
}
private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = {
type: "SEND_LOCAL_DATA",
data: secret,
};
return this.sendMessageToServiceWorker(message).then((response) => {
console.log("Response from service worker:", response);
});
}
private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) {
alert("This browser does not support notifications.");
return Promise.reject("This browser does not support notifications.");
}
if (Notification.permission === "granted") {
return Promise.resolve();
}
return Promise.resolve();
}
private requestNotificationPermission(): Promise<NotificationPermission> {
return Notification.requestPermission().then((permission) => {
if (permission !== "granted") {
alert(
"Allow this app permission to make notifications for personal reminders." +
" You can adjust them at any time in your settings.",
);
throw new Error("We weren't granted permission.");
}
return permission;
});
}
// this allows us to show an error without closing the dialog
checkHour() {
if (!libsUtil.isNumeric(this.hourInput)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: "Notifications are off.", // a different message so I know there are none stored
type: "danger",
title: "Not a Number",
text: "The time must be an hour number.",
},
5000,
);
return true;
return false;
}
const hourNum = libsUtil.numberOrZero(this.hourInput);
if (!Number.isInteger(hourNum)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be a whole hour number.",
},
5000,
);
return false;
}
if (hourNum < 1 || 12 < hourNum) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Not a Whole Number",
text: "The time must be an hour between 1 and 12.",
},
5000,
);
return false;
}
return true;
}
// clone in order to get only the properties and allow stringify to work
const serverSubscription = {
...subscription,
};
if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title;
public async turnOnNotifications() {
return this.askPermission()
.then((permission) => {
console.log("Permission granted:", permission);
// Call the function and handle promises
this.subscribeToPush()
.then(() => {
console.log("Subscribed successfully.");
return navigator.serviceWorker?.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then(async (subscription) => {
if (subscription) {
await this.$notify(
{
group: "alert",
type: "info",
title: "Notification Setup Underway",
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
},
-1,
);
// we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
const hourNum = adjHourNum % 24;
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour },
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
return subscriptionWithTime;
} else {
throw new Error("Subscription object is not available.");
}
})
.then(async (subscription: PushSubscriptionWithTime) => {
console.log(
"Subscription data sent to server and all finished successfully.",
);
await sendTestThroughPushServer(subscription, true);
this.$notify(
{
group: "alert",
type: "success",
title: "Notifications Turned On",
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.",
},
-1,
);
})
.catch((error) => {
console.error(
"Subscription or server communication failed:",
error,
);
alert(
"Subscription or server communication failed. Try again in a while.",
);
});
})
.catch((error) => {
console.error(
"An error occurred setting notification permissions:",
error,
);
alert("Some error occurred setting notification permissions.");
});
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
};
navigator.serviceWorker.ready
.then((registration) => {
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
console.log("Push subscription successful:", subscription);
resolve();
})
.catch((error) => {
console.error("Push subscription failed:", error, options);
// Inform the user about the issue
alert(
"We encountered an issue setting up push notifications. " +
"If you wish to revoke notification permissions, please do so in your browser settings.",
);
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime,
): Promise<void> {
console.log("About to send subscription...", subscription);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to send subscription to server");
}
console.log("Subscription sent to server successfully.");
});
}
async turnOffNotifications() {
let subscription;
const pushProviderSuccess = await navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then((subscript) => {
subscription = subscript;
if (subscription) {
return subscription.unsubscribe();
} else {
console.log("Subscription object is not available.");
return false;
}
})
.catch((error) => {
console.error("Push provider server communication failed:", error);
return false;
});
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(serverSubscription),
body: JSON.stringify(subscription),
})
.then((response) => {
return response.ok;
})
.catch((error) => {
logConsoleAndDb(
"Push server communication failed: " + JSON.stringify(error),
true,
);
console.error("Push server communication failed:", error);
return false;
});
let message;
if (pushServerSuccess) {
message = "Notification is off.";
} else {
message = "Notification is still on. Try to turn it off again.";
}
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: message,
},
5000,
alert(
"Notifications are off. Push provider unsubscribe " +
(pushProviderSuccess ? "succeeded" : "failed") +
(pushProviderSuccess === pushServerSuccess ? " and" : " but") +
" push server unsubscribe " +
(pushServerSuccess ? "succeeded" : "failed") +
".",
);
if (notification.callback) {
// it's OK if the local notifications are still on (especially if the other notification is on)
notification.callback(pushServerSuccess);
}
}
}
</script>

View File

@@ -1,152 +0,0 @@
<template>
<NotificationGroup group="customModal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
leave="transition ease-in duration-500"
leave-from="opacity-100"
leave-to="opacity-0"
move="transition duration-500"
move-delay="delay-300"
>
<div
v-for="notification in notifications"
:key="notification.id"
class="w-full"
role="alert"
>
<div
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
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">
<span class="font-semibold text-lg">{{ title }}</span>
<p class="text-sm mb-2">{{ text }}</p>
<button
@click="handleOption1(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
>
{{ option1Text }}
</button>
<button
@click="handleOption2(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-700 text-white px-2 py-2 rounded-md mb-2"
>
{{ option2Text }}
</button>
<button
@click="handleOption3(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
{{ option3Text }}
</button>
<button
@click="handleCancel(close)"
class="block w-full text-center text-md font-bold capitalize bg-slate-600 text-white px-2 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</Notification>
</div>
</NotificationGroup>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@Component
export default class PromptDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
title = "";
text = "";
option1Text = "";
option2Text = "";
option3Text = "";
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
open(options: {
title: string;
text: string;
option1Text?: string;
option2Text?: string;
option3Text?: string;
onOption1?: () => void;
onOption2?: () => void;
onOption3?: () => void;
onCancel?: () => Promise<void>;
}) {
this.title = options.title;
this.text = options.text;
this.option1Text = options.option1Text || "";
this.option2Text = options.option2Text || "";
this.option3Text = options.option3Text || "";
this.onOption1 = options.onOption1;
this.onOption2 = options.onOption2;
this.onOption3 = options.onOption3;
this.onCancel = options.onCancel;
this.$notify(
{
group: "customModal",
type: "confirm",
title: this.title,
text: this.text,
option1Text: this.option1Text,
option2Text: this.option2Text,
option3Text: this.option3Text,
onOption1: this.onOption1,
onOption2: this.onOption2,
onOption3: this.onOption3,
onCancel: this.onCancel,
} as NotificationIface,
-1,
);
}
handleOption1(close: (id: string) => void) {
if (this.onOption1) {
this.onOption1();
}
close("string that does not matter");
}
handleOption2(close: (id: string) => void) {
if (this.onOption2) {
this.onOption2();
}
close("string that does not matter");
}
handleOption3(close: (id: string) => void) {
if (this.onOption3) {
this.onOption3();
}
close("string that does not matter");
}
handleCancel(close: (id: string) => void) {
if (this.onCancel) {
this.onCancel();
}
close("string that does not matter");
}
}
</script>

View File

@@ -1,102 +0,0 @@
<!-- similar to UserNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1>
{{ message }}
Note that their name is only stored on this device.
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="newText"
/>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
class="block w-full text-center 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 mb-2"
@click="onClickSaveChanges()"
>
Save
</button>
<button
type="button"
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-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
@Component
export default class ContactNameDialog extends Vue {
cancelCallback: () => void = () => {};
saveCallback: (name?: string) => void = () => {};
message = "";
newText = "";
title = "Contact Name";
visible = false;
async open(
title?: string,
message?: string,
saveCallback?: (name?: string) => void,
cancelCallback?: () => void,
defaultName?: string,
) {
this.cancelCallback = cancelCallback || this.cancelCallback;
this.saveCallback = saveCallback || this.saveCallback;
this.message = message ?? this.message;
this.newText = defaultName ?? "";
this.title = title ?? this.title;
this.visible = true;
}
async onClickSaveChanges() {
this.visible = false;
if (this.saveCallback) {
this.saveCallback(this.newText);
}
}
onClickCancel() {
this.visible = false;
if (this.cancelCallback) {
this.cancelCallback();
}
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -100,7 +100,7 @@ import {
} from "@vue-leaflet/vue-leaflet";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db } from "@/db/index";
@Component({
components: {
@@ -121,10 +121,11 @@ export default class FeedFilters extends Vue {
async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount();
this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.hasVisibleDid = !!settings?.filterFeedByVisible;
this.isNearby = !!settings?.filterFeedByNearby;
if (settings?.searchBoxes && settings.searchBoxes.length > 0) {
this.hasSearchBox = true;
}
@@ -132,18 +133,18 @@ export default class FeedFilters extends Vue {
this.visible = true;
}
async toggleHasVisibleDid() {
toggleHasVisibleDid() {
this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid,
});
}
async toggleNearby() {
toggleNearby() {
this.settingChanged = true;
this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
}
@@ -153,7 +154,7 @@ export default class FeedFilters extends Vue {
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false,
});
@@ -167,7 +168,7 @@ export default class FeedFilters extends Vue {
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true,
});
@@ -191,7 +192,6 @@ export default class FeedFilters extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
@@ -205,7 +205,7 @@ export default class FeedFilters extends Vue {
}
#dialogFeedFilters.dialog-overlay {
z-index: 100;
z-index: 99999;
overflow: scroll;
}

View File

@@ -7,7 +7,7 @@
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
:placeholder="prompt || 'What was given?'"
placeholder="What was given"
v-model="description"
/>
<div class="flex flex-row justify-center">
@@ -24,7 +24,6 @@
<fa icon="chevron-left" />
</div>
<input
id="inputGivenAmount"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
@@ -47,8 +46,7 @@
giverDid: giver?.did,
giverName: giver?.name,
offerId,
fulfillsProjectId: toProjectId,
providerProjectId: fromProjectId,
projectId,
recipientDid: receiver?.did,
recipientName: receiver?.name,
unitCode,
@@ -56,7 +54,7 @@
}"
class="text-blue-500"
>
Photo & more options ...
Photo & Details ...
</router-link>
</span>
</div>
@@ -93,19 +91,18 @@ import { NotificationIface } from "@/constants/app";
import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
GiverReceiverInputInfo,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { Contact } from "@/db/tables/contacts";
import { retrieveAccountDids } from "@/libs/util";
@Component
export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop fromProjectId = "";
@Prop toProjectId = "";
@Prop projectId = "";
activeDid = "";
allContacts: Array<Contact> = [];
@@ -116,27 +113,25 @@ export default class GiftedDialog extends Vue {
callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string;
description = "";
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = "";
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
receiver?: GiverReceiverInputInfo;
unitCode = "HUR";
visible = false;
libsUtil = libsUtil;
async open(
giver?: libsUtil.GiverReceiverInputInfo,
receiver?: libsUtil.GiverReceiverInputInfo,
giver?: GiverReceiverInputInfo,
receiver?: GiverReceiverInputInfo,
offerId?: string,
customTitle?: string,
prompt?: string,
callbackOnSuccess?: (amount: number) => void,
) {
this.customTitle = customTitle;
this.description = "";
this.giver = giver;
this.prompt = prompt || "";
this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true
this.amountInput = "0";
@@ -144,13 +139,16 @@ export default class GiftedDialog extends Vue {
this.offerId = offerId || "";
try {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.allMyDids = await retrieveAccountDids();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
if (this.giver && !this.giver.name) {
this.giver.name = didInfo(
@@ -208,7 +206,6 @@ export default class GiftedDialog extends Vue {
this.description = "";
this.giver = undefined;
this.amountInput = "0";
this.prompt = "";
this.unitCode = "HUR";
}
@@ -290,20 +287,19 @@ export default class GiftedDialog extends Vue {
unitCode: string = "HUR",
) {
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitGive(
this.axios,
this.apiServer,
this.activeDid,
giverDid as string,
recipientDid as string,
identity,
giverDid,
this.receiver?.did as string,
description,
amount,
unitCode,
this.toProjectId,
this.projectId,
this.offerId,
this.isTrade,
undefined,
this.fromProjectId,
);
if (
@@ -340,7 +336,7 @@ export default class GiftedDialog extends Vue {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
serverMessageForUser(error) ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
@@ -394,7 +390,6 @@ export default class GiftedDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center relative">
<h1 class="text-xl font-bold text-center mb-4 relative">
Here's one:
<div
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@@ -10,9 +10,8 @@
<fa icon="xmark" class="w-[1em]"></fa>
</div>
</h1>
<span class="mt-2 flex justify-between">
<span class="flex justify-between">
<span
v-if="currentCategory === CATEGORY_IDEAS"
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="prevIdea()"
>
@@ -20,21 +19,21 @@
</span>
<div class="m-2">
<span v-if="currentCategory === CATEGORY_IDEAS">
<p class="text-center text-lg">
<span v-if="currentIdeaIndex < IDEAS.length">
<p class="text-center text-lg font-bold">
{{ IDEAS[currentIdeaIndex] }}
</p>
</span>
<div v-if="currentCategory === CATEGORY_CONTACTS">
<div v-if="currentIdeaIndex == IDEAS.length + 0">
<p class="text-center">
<span
v-if="currentContact == null"
class="text-orange-500 text-lg"
class="text-orange-500 text-lg font-bold"
>
That's all your contacts.
</span>
<span v-else>
<span class="text-lg">
<span class="text-lg font-bold">
Did {{ currentContact.name || AppString.NO_CONTACT_NAME }}
<br />
or someone near them do anything &ndash; maybe a while ago?
@@ -62,7 +61,7 @@
</span>
<button
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 mt-4"
@click="proceed"
@click="cancel"
>
That's it!
</button>
@@ -72,175 +71,155 @@
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { GiverReceiverInputInfo } from "@/libs/util";
@Component
export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
CATEGORY_CONTACTS = 1;
CATEGORY_IDEAS = 0;
IDEAS = [
"What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)",
"What did a family member do? (How did you take better action because it made you feel loved?)",
"What compliment did someone give you? (What task could you tackle because it boosted your confidence?)",
"Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)",
"What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)",
"What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)",
"How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)",
"What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)",
"What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)",
"Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)",
"What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)",
"Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)",
"What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)",
"What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)",
"How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)",
"What is a surprise gift you received? (What extra possibilities did it give you?)",
"Did anyone fix food for you?",
"Did a family member do something for you?",
"Did anyone give you a compliment?",
"Who is someone you can always rely on, and how did they demonstrate that?",
"Did you see anyone give to someone else?",
"Is there someone who you have never met who has helped you somehow?",
"How did an artist or musician or author inspire you?",
"What inspiration did you get from someone who handled tragedy well?",
"Did some organization give something worth respect?",
"Who last gave you a good laugh?",
"Do you recall anything that was given to you while you were young?",
"Did someone forgive you or overlook a mistake?",
"Do you know of a way an ancestor contributed to your life?",
"Did anyone give you help at work?",
"How did a teacher or mentor or great example help you?",
];
OTHER_PROMPTS = 1;
CONTACT_PROMPT_INDEX = this.IDEAS.length; // expected after other prompts
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string,
) => void;
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS
currentContact: Contact | undefined = undefined;
currentIdeaIndex = 0;
numContacts = 0;
shownContactDbIndices: Array<boolean> = [];
shownContactDbIndices: number[] = [];
visible = false;
AppString = AppString;
async open(
callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo,
description?: string,
) => void,
) {
async open() {
this.visible = true;
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
await db.open();
this.numContacts = await db.contacts.count();
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
}
cancel() {
this.currentCategory = this.CATEGORY_IDEAS;
this.currentContact = undefined;
this.currentIdeaIndex = 0;
this.numContacts = 0;
this.shownContactDbIndices = [];
close() {
// close the dialog but don't change values (just in case some actions are added later)
this.visible = false;
}
proceed() {
// proceed with logic but don't change values (just in case some actions are added later)
this.visible = false;
if (this.currentCategory === this.CATEGORY_IDEAS) {
(this.$router as Router).push({
name: "contact-gift",
query: {
prompt: this.IDEAS[this.currentIdeaIndex],
},
});
} else {
// must be this.CATEGORY_CONTACTS
this.callbackOnFullGiftInfo?.(
this.currentContact as GiverReceiverInputInfo,
);
}
}
/**
* Get the next idea.
* If it is a contact prompt, loop through.
*/
async nextIdea() {
// check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex++;
if (this.currentIdeaIndex === this.IDEAS.length) {
// must have just finished ideas so move to contacts
this.findNextUnshownContact();
}
} else {
// must be this.CATEGORY_CONTACTS
// if we're incrementing to the contact prompt
// or if we're at the contact prompt and there was a previous contact...
if (
this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX - 1 ||
(this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX &&
this.shownContactDbIndices.length < this.numContacts)
) {
this.currentIdeaIndex = this.CONTACT_PROMPT_INDEX;
this.findNextUnshownContact();
// when that's finished, it'll reset to ideas
} else {
// we're not at the contact prompt (or we ran out), so increment the idea index
this.currentIdeaIndex =
(this.currentIdeaIndex + 1) % (this.IDEAS.length + this.OTHER_PROMPTS);
// ... and clear out any other prompt info
this.currentContact = undefined;
this.shownContactDbIndices = [];
}
}
/**
* Get the previous idea.
* If it is a contact prompt, loop through.
*/
async prevIdea() {
// check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) {
prevIdea() {
if (
this.currentIdeaIndex ==
(this.CONTACT_PROMPT_INDEX + 1) %
(this.IDEAS.length + this.OTHER_PROMPTS) ||
(this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX &&
this.shownContactDbIndices.length < this.numContacts)
) {
this.currentIdeaIndex = this.CONTACT_PROMPT_INDEX;
this.findNextUnshownContact();
} else {
// we're not at the contact prompt (or we ran out), so increment the idea index
this.currentIdeaIndex--;
if (this.currentIdeaIndex < 0) {
// must have just finished ideas so move to contacts
this.findNextUnshownContact();
this.currentIdeaIndex = this.IDEAS.length - 1 + this.OTHER_PROMPTS;
}
} else {
// must be this.CATEGORY_CONTACTS
this.findNextUnshownContact();
// when that's finished, it'll reset to ideas
// ... and clear out any other prompt info
this.currentContact = undefined;
this.shownContactDbIndices = [];
}
}
nextIdeaPastContacts() {
this.currentIdeaIndex = 0;
this.currentContact = undefined;
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
this.currentCategory = this.CATEGORY_IDEAS;
// look at the previous idea and switch to the other side of the list
this.currentIdeaIndex =
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1;
this.shownContactDbIndices = [];
}
async findNextUnshownContact() {
if (this.currentCategory === this.CATEGORY_IDEAS) {
// we're not in the contact prompts, so reset index array
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
}
this.currentCategory = this.CATEGORY_CONTACTS;
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0;
// as long as the index has an entry, loop
while (
this.shownContactDbIndices[someContactDbIndex] != null &&
count++ < this.numContacts
) {
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
}
if (count >= this.numContacts) {
// all contacts have been shown
this.nextIdeaPastContacts();
// get a random contact
if (this.shownContactDbIndices.length === this.numContacts) {
// no more contacts to show
this.currentContact = undefined;
} else {
// get a random contact that hasn't been shown yet
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
// and guarantee that one is found by walking past shown contacts
let shownContactIndex =
this.shownContactDbIndices.indexOf(someContactDbIndex);
while (shownContactIndex !== -1) {
// increment both indices until we find a spot where "shown" skips a spot
shownContactIndex = (shownContactIndex + 1) % this.numContacts;
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
if (
this.shownContactDbIndices[shownContactIndex] !== someContactDbIndex
) {
// we found a contact that hasn't been shown yet
break;
}
// continue
// ... and there must be at least one because shownContactDbIndices length < numContacts
}
this.shownContactDbIndices.push(someContactDbIndex);
this.shownContactDbIndices.sort();
// get the contact at that offset
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
this.shownContactDbIndices[someContactDbIndex] = true;
}
}
cancel() {
this.currentContact = undefined;
this.currentIdeaIndex = 0;
this.numContacts = 0;
this.shownContactDbIndices = [];
this.close();
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -1,182 +0,0 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>
<button @click="close" class="text-gray-500 hover:text-gray-700">
<fa icon="times" />
</button>
</div>
<!-- Content -->
<!-- This is somewhat similar to ClaimView.vue and ConfirmGiftView.vue -->
<div class="mb-4">
<p class="mb-4">
<span v-if="R.isEmpty(visibleToDids)">
The {{ roleName }} is not visible to you or any of your contacts.
</span>
<span v-else> The {{ roleName }} is not visible to you. </span>
</p>
<div v-if="R.isEmpty(visibleToDids)">
<p class="mt-2">
You can ask one of your contacts to take a look and see if their
contacts can see more details. Someone is connected to people closer
to them; if you don't know who to ask, try the person who registered
you.
</p>
</div>
<div v-else>
<p class="mb-2">
They are visible to some of your contacts. If you'd like an
introduction, ask them if they'll tell you more.
</p>
<div class="ml-4">
<ul>
<li
v-for="(visDid, idx) of visibleToDids"
:key="idx"
class="list-disc ml-4 mb-2"
>
<div class="text-sm">
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a
:href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</span>
</span>
</div>
</li>
</ul>
</div>
</div>
<div class="mt-4">
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
>click here to share the information with them and ask if they'll
tell you more about the {{ roleName }}.</a
>
</span>
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
</span>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end">
<button
@click="close"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Close
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { Contact } from "@/db/tables/contacts";
import * as serverUtil from "@/libs/endorserServer";
import { NotificationIface } from "@/constants/app";
@Component
export default class HiddenDidDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
isOpen = false;
roleName = "";
visibleToDids: string[] = [];
allContacts: Array<Contact> = [];
activeDid = "";
allMyDids: Array<string> = [];
canShare = false;
windowLocation = window.location.href;
R = R;
serverUtil = serverUtil;
created() {
// 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;
}
open(
roleName: string,
visibleToDids: string[],
allContacts: Array<Contact>,
activeDid: string,
allMyDids: Array<string>,
) {
this.roleName = roleName;
this.visibleToDids = visibleToDids;
this.allContacts = allContacts;
this.activeDid = activeDid;
this.allMyDids = allMyDids;
this.isOpen = true;
}
close() {
this.isOpen = false;
}
didInfo(did: string) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
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,
);
});
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
});
}
}
</script>

View File

@@ -6,7 +6,7 @@
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"
>
Add Photo
Camera or Other?
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@@ -18,7 +18,7 @@
<div>
<div class="text-center mt-8">
<div>
<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"
@@ -155,7 +155,6 @@ export default class ImageMethodDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -1,94 +0,0 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50 flex flex-col bg-black/90">
<!-- Header bar - fixed height to prevent overlap -->
<div class="h-16 flex justify-between items-center px-4 bg-black">
<button
class="text-white text-2xl p-2 rounded-full hover:bg-white/10"
@click="close"
>
<fa icon="xmark" />
</button>
<!-- Mobile share button -->
<button
v-if="isMobile"
class="text-white text-xl p-2 rounded-full hover:bg-white/10"
@click="handleShare"
>
<fa icon="ellipsis" />
</button>
</div>
<!-- Image container - fill remaining space -->
<div class="flex-1 flex items-center justify-center p-2">
<div class="w-full h-full flex items-center justify-center">
<img
:src="imageUrl"
class="max-h-[calc(100vh-5rem)] w-full h-full object-contain"
@click.stop
alt="expanded shared content"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import { UAParser } from "ua-parser-js";
@Component({ emits: ["update:isOpen"] })
export default class ImageViewer extends Vue {
@Prop() imageUrl!: string;
@Prop() imageData!: Blob | null;
@Prop() isOpen!: boolean;
userAgent = new UAParser();
get isMobile() {
const os = this.userAgent.getOS().name;
return os === "iOS" || os === "Android";
}
close() {
this.$emit("update:isOpen", false);
}
async handleShare() {
const os = this.userAgent.getOS().name;
try {
if (os === "iOS" || os === "Android") {
if (navigator.share) {
// Always share the URL since it's more reliable across platforms
await navigator.share({
url: this.imageUrl,
});
} else {
// Fallback for browsers without share API
window.open(this.imageUrl, "_blank");
}
}
} catch (error) {
console.warn("Share failed, opening in new tab:", error);
window.open(this.imageUrl, "_blank");
}
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,119 +0,0 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Invitation & Notes</h1>
These are optional notes for your use; they are comments to help you
recall who it is when they accept it. These notes are sent to the server.
If you want to store your own way, the invitation ID is:
{{ inviteIdentifier }}
<input
type="text"
placeholder="Notes"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="text"
/>
<!-- Add date selection element -->
Expiration
<input
type="date"
class="block rounded border border-slate-400 mb-4 px-3 py-2"
v-model="expiresAt"
/>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
class="block w-full text-center 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 mb-2"
@click="onClickSaveChanges()"
>
Save
</button>
<!-- SHOW ME instead while processing saving changes -->
<button
type="button"
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-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@Component
export default class InviteDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (text: string, expiresAt: string) => void = () => {};
inviteIdentifier = "";
text = "";
visible = false;
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
.toISOString()
.substring(0, 10);
async open(
inviteIdentifier: string,
aCallback: (text: string, expiresAt: string) => void,
) {
this.callback = aCallback;
this.inviteIdentifier = inviteIdentifier;
this.visible = true;
}
async onClickSaveChanges() {
if (!this.expiresAt) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Needs Expiration",
text: "You must select an expiration date.",
},
5000,
);
} else {
this.callback(this.text, this.expiresAt);
this.visible = false;
}
}
onClickCancel() {
this.visible = false;
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -1,522 +0,0 @@
<template>
<div class="space-y-4">
<!-- Loading State -->
<div
v-if="isLoading"
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
>
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Members List -->
<div v-else>
<div class="text-center text-red-600 py-4">
{{ decryptionErrorMessage() }}
</div>
<div v-if="missingMyself" class="py-4 text-red-600">
You are not currently admitted by the organizer.
</div>
<div v-if="!firstName" class="py-4 text-red-600">
Your name is not set, so others may not recognize you. Reload this page
to set it.
</div>
<div>
<span
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
>
<span class="inline-flex items-center">
&bull; Click
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="plus" class="text-sm" />
</span>
/
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span>
</span>
</div>
<div>
<span
v-if="membersToShow().length > 0"
class="inline-flex items-center"
>
&bull; Click
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
>
<fa icon="circle-user" class="text-xl" />
</span>
to add them to your contacts.
</span>
</div>
<div class="flex justify-center">
<!-- always have at least one refresh button even without members in case the organizer changes the password -->
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<div
v-for="member in membersToShow()"
:key="member.member.memberId"
class="mt-2 p-4 bg-gray-50 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-lg font-medium">{{ member.name }}</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end"
>
<button
@click="addAsContact(member)"
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800 transition-colors"
title="Add as contact"
>
<fa icon="circle-user" class="text-xl" />
</button>
</div>
<button
v-if="member.did !== activeDid"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
class="ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Contact info"
>
<fa icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
<span
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center"
>
<button
@click="checkWhetherContactBeforeAdmitting(member)"
class="mr-2 w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
>
<fa
:icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
/>
</button>
<button
@click="informAboutAdmission()"
class="mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Admission info"
>
<fa icon="circle-info" class="text-base" />
</button>
</span>
</div>
</div>
<p class="text-sm text-gray-600 truncate">
{{ member.did }}
</p>
</div>
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<p v-if="members.length === 0" class="text-gray-500 py-4">
No members have joined this meeting yet
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
db,
} from "@/db/index";
import {
errorStringForLog,
getHeaders,
register,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import { NotificationIface } from "@/constants/app";
interface Member {
admitted: boolean;
content: string;
memberId: number;
}
interface DecryptedMember {
member: Member;
name: string;
did: string;
isRegistered: boolean;
}
@Component
export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
@Prop({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = [];
firstName = "";
isLoading = true;
isOrganizer = false;
members: Member[] = [];
missingPassword = false;
missingMyself = false;
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
await this.fetchMembers();
await this.loadContacts();
}
async fetchMembers() {
try {
this.isLoading = true;
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`,
{ headers },
);
if (response.data && response.data.data) {
this.members = response.data.data;
await this.decryptMemberContents();
}
} catch (error) {
logConsoleAndDb(
"Error fetching members: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) || "Failed to fetch members.",
);
} finally {
this.isLoading = false;
}
}
async decryptMemberContents() {
this.decryptedMembers = [];
if (!this.password) {
this.missingPassword = true;
return;
}
let isFirstEntry = true,
foundMyself = false;
for (const member of this.members) {
try {
const decryptedContent = await decryptMessage(
member.content,
this.password,
);
const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({
member: member,
name: content.name,
did: content.did,
isRegistered: !!content.isRegistered,
});
if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true;
}
if (content.did === this.activeDid) {
foundMyself = true;
}
} catch (error) {
// do nothing, relying on the count of members to determine if there was an error
}
isFirstEntry = false;
}
this.missingMyself = !foundMyself;
}
decryptionErrorMessage(): string {
if (this.isOrganizer) {
if (this.decryptedMembers.length < this.members.length) {
return "Some members have data that cannot be decrypted with that password.";
} else {
// the lists must be equal
return "";
}
} else {
// non-organizers should only see problems if the first (organizer) member is not decrypted
if (
this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) {
return "Your password is not the same as the organizer. Reload or have them check their password.";
} else {
// the first (organizer) member was decrypted OK
return "";
}
}
}
membersToShow(): DecryptedMember[] {
if (this.isOrganizer) {
if (this.showOrganizerTools) {
return this.decryptedMembers;
} else {
return this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted,
);
}
}
// non-organizers only get visible members from server
return this.decryptedMembers;
}
informAboutAdmission() {
this.$notify(
{
group: "alert",
type: "info",
title: "Admission info",
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
},
10000,
);
}
informAboutAddingContact(contactImportedAlready: boolean) {
if (contactImportedAlready) {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Exists",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
},
10000,
);
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Available",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
},
10000,
);
}
}
async loadContacts() {
this.contacts = await db.contacts.toArray();
}
getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did);
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog
this.$notify(
{
group: "modal",
type: "confirm",
title: "Add as Contact First?",
text: "This person is not in your contacts. Would you like to add them as a contact first?",
yesText: "Add as Contact",
noText: "Skip Adding Contact",
onYes: async () => {
await this.addAsContact(decrMember);
// After adding as contact, proceed with admission
await this.toggleAdmission(decrMember);
},
onNo: async () => {
// If they choose not to add as contact, show second confirmation
this.$notify(
{
group: "modal",
type: "confirm",
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
yesText: "Continue",
onYes: async () => {
await this.toggleAdmission(decrMember);
},
onCancel: async () => {
// Do nothing, effectively canceling the operation
},
},
-1,
);
},
},
-1,
);
} else {
// If already a contact, proceed directly with admission
this.toggleAdmission(decrMember);
}
}
async toggleAdmission(decrMember: DecryptedMember) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
{ admitted: !decrMember.member.admitted },
{ headers },
);
// Update local state
decrMember.member.admitted = !decrMember.member.admitted;
const oldContact = this.getContactFor(decrMember.did);
// if admitted, now register that user if they are not registered
if (
decrMember.member.admitted &&
!decrMember.isRegistered &&
!oldContact?.registered
) {
const contactOldOrNew: Contact = oldContact || {
did: decrMember.did,
name: decrMember.name,
};
try {
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contactOldOrNew,
);
if (result.success) {
decrMember.isRegistered = true;
if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true });
oldContact.registered = true;
}
this.$notify(
{
group: "alert",
type: "success",
title: "Registered",
text: "Besides being admitted, they were also registered.",
},
3000,
);
} else {
throw result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
// registration failure is likely explained by a message from the server
const additionalInfo =
serverMessageForUser(error) || error?.error || "";
this.$notify(
{
group: "alert",
type: "warning",
title: "Registration failed",
text:
"They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
additionalInfo,
},
12000,
);
}
}
} catch (error) {
logConsoleAndDb(
"Error toggling admission: " + errorStringForLog(error),
true,
);
this.$emit(
"error",
serverMessageForUser(error) ||
"Failed to update member admission status.",
);
}
}
async addAsContact(member: DecryptedMember) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await db.contacts.add(newContact);
this.contacts.push(newContact);
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: "They were added to your contacts.",
},
3000,
);
} catch (err) {
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
let message = "An error prevented adding this contact.";
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
message = "This person is already in your contact list.";
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Not Added",
text: message,
},
5000,
);
}
}
}
</script>

View File

@@ -4,9 +4,8 @@
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
<input
type="text"
data-testId="inputDescription"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Description of what is offered"
placeholder="Description, prerequisites, terms, etc."
v-model="description"
/>
<div class="flex flex-row mt-2">
@@ -24,7 +23,6 @@
<fa icon="chevron-left" />
</div>
<input
data-testId="inputOfferAmount"
type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="amountInput"
@@ -36,27 +34,18 @@
<fa icon="chevron-right" />
</div>
</div>
<div class="mt-4 flex justify-center">
<span>
<router-link
:to="{
name: 'offer-details',
query: {
amountInput,
description,
offererDid: activeDid,
projectId,
projectName,
recipientDid,
recipientName,
unitCode: amountUnitCode,
},
}"
class="text-blue-500"
>
Conditions & more options...
</router-link>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Expiration
</span>
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="datePlaceholder()"
v-model="expirationDateInput"
/>
</div>
<p class="text-center mt-6 mb-2 italic">
Sign & Send to publish to the world
@@ -80,22 +69,20 @@
</template>
<script lang="ts">
import { DateTime } from "luxon";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import {
createAndSubmitOffer,
serverMessageForUser,
} from "@/libs/endorserServer";
import { createAndSubmitOffer } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@Component
export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId?: string;
@Prop projectName?: string;
@Prop projectId? = "";
activeDid = "";
apiServer = "";
@@ -105,19 +92,18 @@ export default class OfferDialog extends Vue {
description = "";
expirationDateInput = "";
recipientDid? = "";
recipientName? = "";
visible = false;
libsUtil = libsUtil;
async open(recipientDid?: string, recipientName?: string) {
async open(recipientDid?: string) {
try {
this.recipientDid = recipientDid;
this.recipientName = recipientName;
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
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) {
@@ -158,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();
@@ -210,9 +202,9 @@ export default class OfferDialog extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identity before you can record an offer.",
text: "You must select an identifier before you can record an offer.",
},
7000,
-1,
);
return;
}
@@ -231,14 +223,14 @@ export default class OfferDialog extends Vue {
}
try {
const identity = await libsUtil.getIdentity(this.activeDid);
const result = await createAndSubmitOffer(
this.axios,
this.apiServer,
this.activeDid,
identity,
description,
amount,
unitCode,
"",
expirationDateInput,
this.recipientDid,
this.projectId,
@@ -267,7 +259,7 @@ export default class OfferDialog extends Vue {
title: "Success",
text: "That offer was recorded.",
},
5000,
10000,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -307,9 +299,9 @@ export default class OfferDialog extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getOfferCreationErrorMessage(result: any) {
return (
serverMessageForUser(result) ||
result.error?.userMessage ||
result.error?.error
result.error?.error ||
result.response?.data?.error?.message
);
}
}
@@ -317,7 +309,6 @@ export default class OfferDialog extends Vue {
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;

View File

@@ -1,286 +0,0 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div v-if="page === OnboardPage.Home" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Welcome to Time Safari
<br />
- Showcasing Gratitude & Magnifying Time
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
</div>
</h1>
<p v-if="isRegistered" class="mt-4">
You can now log things that you've seen:
<span v-if="numContacts > 0">
click on any name (like {{ firstContactName }}) or
</span>
click on the
<span class="bg-green-600 text-white rounded-full">
<fa icon="plus" class="fa-fw" />
</span>
button to express your appreciation for... whatever -- maybe thanks for
showing you all these fascinating stories of
<em>gratitude</em>.
</p>
<p v-else class="mt-4">
The feed underneath this pop-up shows the latest gifts that others have
recognized. Once someone registers you, you can log your appreciation,
too.
</p>
<p class="mt-4">
The more you illuminate cool things people are doing, the more you
attract people to work with you.
</p>
<p class="mt-4 flex items-center">
The
<fa
icon="house-chimney"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
button below brings you back to this feed screen.
</p>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
data-testId="closeOnboardingAndFinish"
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-2 py-3 rounded-md mb-2"
@click="onClickClose(true)"
>
That's enough help, thanks.
</button>
<button
type="button"
class="block w-full 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-2 py-3 rounded-md mb-2"
@click="$router.push({ name: 'discover' })"
>
Show me more!
</button>
</div>
</div>
<p class="mt-4 flex items-center">
To see these instructions and more, click above on
<span
class="ml-1 mr-1 text-xs 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-1 rounded-md"
>
Help
</span>
</p>
</div>
<div v-if="page === OnboardPage.Discover" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Offer to Interesting Events & People
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
</div>
</h1>
<p>
Once you've seen things that others have given or done, you may find
ways you want to contribute, too. It turns out others have proposed
activities together, and this page is where you find projects.
</p>
<p class="mt-4">
Search for a topic, or search around your neighborhod under "Nearby".
</p>
<p class="mt-4">
When you find some that seem interesting, you can offer your help. You
are welcome to make your offer conditional, for example if they get 2
other people to help besides you.
</p>
<p class="mt-4 flex items-center">
The
<fa
icon="magnifying-glass"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
button below brings you to this discovery screen.
</p>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
data-testId="closeOnboardingAndFinish"
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-2 py-3 rounded-md mb-2"
@click="onClickClose(true)"
>
No more help, thanks.
</button>
<button
type="button"
class="block w-full 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-2 py-3 rounded-md mb-2"
@click="$router.push({ name: 'projects' })"
>
Show me even more.
</button>
</div>
</div>
</div>
<div v-if="page === OnboardPage.Create" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Fish for Others with Your Projects
<div
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
</div>
</h1>
<p class="relative">
Now you can take a turn: click on the
<span class="bg-green-600 text-white rounded-full">
<fa icon="plus" class="fa-fw" />
</span>
button to throw out projects of your own... anything you'd like to see
happen. If your first idea doesn't catch anyone, try, try again... and
let others know that this is a good place to find help.
</p>
<p class="mt-4 flex items-center">
The
<fa
icon="hand"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
button below brings you here to see your ideas.
</p>
<p class="mt-4">
By the way, one good way to get to know your neighbors and their
interests is to offer time directly to them. You can do this on the
contacts screen
<fa icon="users" class="text-slate-500" />
which is a great way to get to know a neighbor's interests.
</p>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
data-testId="closeOnboardingAndFinish"
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-2 py-3 rounded-md mb-2"
@click="onClickClose(true, true)"
>
Let's go!
<br />
See & record gratitude.
</button>
<button
type="button"
class="block w-full 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-2 py-3 rounded-md mb-2"
@click="$router.push({ name: 'help' })"
>
I want to read more Help.
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "@/constants/app";
import {
db,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
import { OnboardPage } from "@/libs/util";
@Component({
computed: {
OnboardPage() {
return OnboardPage;
},
},
components: { OnboardPage },
})
export default class OnboardingDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
firstContactName = null;
givenName = "";
isRegistered = false;
numContacts = 0;
page = OnboardPage.Home;
visible = false;
async open(page: OnboardPage) {
this.page = page;
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
const contacts = await db.contacts.toArray();
this.numContacts = contacts.length;
if (this.numContacts > 0) {
this.firstContactName = contacts[0].name;
}
this.visible = true;
if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
}
}
async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false;
if (done) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
if (goHome) {
(this.$router as Router).push({ name: "home" });
}
}
}
}
</script>
<style>
.dialog-overlay {
z-index: 40;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -76,8 +76,7 @@
</div>
<div v-else ref="cameraContainer">
<!--
Camera "resolution" doesn't change how it shows on screen but rather stretches the result,
eg. the following which just stretches it vertically:
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically:
:resolution="{ width: 375, height: 812 }"
-->
<camera
@@ -127,7 +126,9 @@ import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import { getIdentity } from "@/libs/util";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
@Component({ components: { Camera, VuePictureCropper } })
@@ -151,8 +152,9 @@ export default class PhotoDialog extends Vue {
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
@@ -346,10 +348,10 @@ export default class PhotoDialog extends Vue {
this.blob = (await cropper?.getBlob()) || undefined;
}
const token = await accessToken(this.activeDid);
const identifier = await getIdentity(this.activeDid);
const token = await accessToken(identifier);
const headers = {
Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
};
const formData = new FormData();
if (!this.blob) {
@@ -409,7 +411,6 @@ export default class PhotoDialog extends Vue {
<style>
.dialog-overlay {
z-index: 60;
position: fixed;
top: 0;
left: 0;

View File

@@ -1,574 +0,0 @@
<template>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
class="fixed z-[100] top-0 inset-x-0 w-full inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<div
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 v-if="serviceWorkerReady && vapidKey" class="text-lg mb-4">
<span v-if="pushType === DAILY_CHECK_TITLE">
Would you like to be notified of new activity, up to once a day?
</span>
<span v-else>
Would you like to get a reminder message once a day?
</span>
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 5 seconds...
<fa icon="spinner" spin />
</p>
<div v-if="serviceWorkerReady && vapidKey">
<div v-if="pushType === DAILY_CHECK_TITLE">
<span>Yes, send me a message when there is new data for me</span>
</div>
<div v-else>
<span>Yes, send me this message:</span>
<!-- eslint-disable -->
<textarea
type="text"
id="push-message"
v-model="messageInput"
class="rounded border border-slate-400 mt-2 px-2 py-2 w-full"
maxlength="100"
></textarea
>
<!-- eslint-enable -->
<span class="w-full flex justify-between text-xs text-slate-500">
<span></span>
<span>(100 characters max)</span>
</span>
</div>
<div>
<span class="flex flex-row justify-center">
<span class="mt-2">... at: </span>
<input
type="number"
@change="checkHourInput"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
v-model="hourInput"
/>
<input
type="number"
@change="checkMinuteInput"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
v-model="minuteInput"
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
>
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
<span v-else> PM <fa icon="chevron-up" /> </span>
</span>
</span>
</div>
<button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="
close();
turnOnNotifications();
"
>
Turn on Daily Message
</button>
</div>
<button
@click="close()"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
>
No, Not Now
</button>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
secretDB,
} from "@/db/index";
import { MASTER_SECRET_KEY } from "@/db/tables/secret";
import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util";
import * as libsUtil from "@/libs/util";
// Example interface for error
interface ErrorResponse {
message: string;
}
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
message?: string;
notifyTime: { utcHour: number; minute: number };
notifyType: string;
}
interface ServiceWorkerMessage {
type: string;
data: string;
}
interface ServiceWorkerResponse {
// Define the properties and their types
success: boolean;
message?: string;
}
interface VapidResponse {
data: {
vapidKey: string;
};
}
@Component
export default class PushNotificationPermission extends Vue {
// eslint-disable-next-line
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE;
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE;
callback: (success: boolean, time: string, message?: string) => void =
() => {};
hourAm = true;
hourInput = "8";
isVisible = false;
messageInput = "";
minuteInput = "00";
pushType = "";
serviceWorkerReady = false;
vapidKey = "";
async open(
pushType: string,
callback?: (success: boolean, time: string, message?: string) => void,
) {
this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
try {
const settings = await retrieveSettingsForActiveAccount();
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
if (pushUrl.startsWith("http://localhost")) {
logConsoleAndDb("Not checking for VAPID in this local environment.");
} else {
let responseData = "";
await this.axios
.get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => {
this.vapidKey = response.data?.vapidKey || "";
logConsoleAndDb("Got vapid key: " + this.vapidKey);
responseData = JSON.stringify(response.data);
navigator.serviceWorker?.addEventListener(
"controllerchange",
() => {
logConsoleAndDb(
"New service worker is now controlling the page",
);
},
);
});
if (!this.vapidKey) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Could not set notifications.",
},
5000,
);
logConsoleAndDb(
"Error Setting Notifications: web push server response didn't have vapidKey: " +
responseData,
true,
);
}
}
} catch (error) {
if (window.location.host.startsWith("localhost")) {
logConsoleAndDb(
"Ignoring the error getting VAPID for local development.",
);
} else {
logConsoleAndDb(
"Got an error initializing notifications: " + JSON.stringify(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notifications",
text: "Got an error setting notifications.",
},
5000,
);
}
}
// there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true;
});
if (this.pushType === this.DIRECT_PUSH_TITLE) {
this.messageInput =
"Click to share some gratitude with the world -- even if they're unnamed.";
// focus on the message input
setTimeout(function () {
document.getElementById("push-message")?.focus();
}, 100);
} else {
// not critical but doesn't make sense in a daily check
this.messageInput = "";
}
}
private close() {
this.isVisible = false;
}
private sendMessageToServiceWorker(
message: ServiceWorkerMessage,
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (navigator.serviceWorker?.controller) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) {
reject(event.data.error as ErrorResponse);
} else {
resolve(event.data as ServiceWorkerResponse);
}
};
navigator.serviceWorker?.controller.postMessage(message, [
messageChannel.port2,
]);
} else {
reject("Service worker controller not available");
}
});
}
private async askPermission(): Promise<NotificationPermission> {
// console.log(
// "Requesting permission for notifications: " + JSON.stringify(navigator),
// );
if (
!("serviceWorker" in navigator && navigator.serviceWorker?.controller)
) {
return Promise.reject("Service worker not available.");
}
await secretDB.open();
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret;
if (!secret) {
return Promise.reject("No secret found.");
}
return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error));
}
private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = {
type: "SEND_LOCAL_DATA",
data: secret,
};
return this.sendMessageToServiceWorker(message).then((response) => {
logConsoleAndDb(
"Response from service worker: " + JSON.stringify(response),
);
});
}
private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Browser Notifications Are Not Supported",
text: "This browser does not support notifications.",
},
3000,
);
return Promise.reject("This browser does not support notifications.");
}
if (window.Notification.permission === "granted") {
return Promise.resolve();
}
return Promise.resolve();
}
private requestNotificationPermission(): Promise<NotificationPermission> {
return window.Notification.requestPermission().then(
(permission: string) => {
if (permission !== "granted") {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Requesting Notification Permission",
text:
"Allow this app permission to make notifications for personal reminders." +
" You can adjust them at any time in your settings.",
},
-1,
);
throw new Error("Permission was not granted to this app.");
}
return permission;
},
);
}
private checkHourInput() {
const hourNum = parseInt(this.hourInput);
if (isNaN(hourNum)) {
this.hourInput = "12";
} else if (hourNum < 1) {
this.hourInput = "12";
this.hourAm = !this.hourAm;
} else if (hourNum > 12) {
this.hourInput = "1";
this.hourAm = !this.hourAm;
} else {
this.hourInput = hourNum.toString();
}
}
private checkMinuteInput() {
const minuteNum = parseInt(this.minuteInput);
if (isNaN(minuteNum)) {
this.minuteInput = "00";
} else if (minuteNum < 0) {
this.minuteInput = "59";
} else if (minuteNum < 10) {
this.minuteInput = "0" + minuteNum;
} else if (minuteNum > 59) {
this.minuteInput = "00";
} else {
this.minuteInput = minuteNum.toString();
}
}
private async turnOnNotifications() {
let notifyCloser = () => {};
return this.askPermission()
.then((permission) => {
logConsoleAndDb("Permission granted: " + JSON.stringify(permission));
// Call the function and handle promises
return this.subscribeToPush();
})
.then(() => {
logConsoleAndDb("Subscribed successfully.");
return navigator.serviceWorker?.ready;
})
.then((registration) => {
return registration.pushManager.getSubscription();
})
.then(async (subscription) => {
if (subscription) {
notifyCloser = await this.$notify(
{
group: "alert",
type: "info",
title: "Notification Setup Underway",
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
},
-1,
);
// we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
const adjHourNum = this.hourAm
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum
rawHourNum === 12
? 0
: rawHourNum
: // Otherwise it's PM, so keep a 12 but otherwise add 12
rawHourNum === 12
? 12
: rawHourNum + 12;
const hourNum = adjHourNum % 24; // probably unnecessary now
const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const minuteNum = libsUtil.numberOrZero(this.minuteInput);
const utcMinute =
minuteNum + Math.round(new Date().getTimezoneOffset() % 60);
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60;
const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
notifyType: this.pushType,
message: this.messageInput,
...subscription.toJSON(),
};
await this.sendSubscriptionToServer(subscriptionWithTime);
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1
logConsoleAndDb(
"Subscription data sent to server with endpoint: " +
subscription.endpoint,
);
return subscriptionWithTime;
} else {
throw new Error("Subscription object is not available.");
}
})
.then(async (subscription: PushSubscriptionWithTime) => {
logConsoleAndDb(
"Subscription data sent to server and all finished successfully.",
);
await libsUtil.sendTestThroughPushServer(subscription, true);
notifyCloser();
setTimeout(() => {
this.$notify(
{
group: "alert",
type: "success",
title: "Notification Is On",
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.",
},
7000,
);
}, 500);
const timeText =
// eslint-disable-next-line
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
this.callback(true, timeText, this.messageInput);
})
.catch((error) => {
logConsoleAndDb(
"Got an error setting notification permissions: " +
" string " +
error.toString() +
" JSON " +
JSON.stringify(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Notification Permissions",
text: "Could not set notification permissions.",
},
3000,
);
// if we want to also unsubscribe, be sure to do that only if no other notification is active
});
}
private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (window.Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
console.warn(errorMsg);
return reject(new Error(errorMsg));
}
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey);
const options: PushSubscriptionOptions = {
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
};
navigator.serviceWorker?.ready
.then((registration) => {
return registration.pushManager.subscribe(options);
})
.then((subscription) => {
logConsoleAndDb(
"Push subscription successful: " + JSON.stringify(subscription),
);
resolve();
})
.catch((error) => {
logConsoleAndDb(
"Push subscription failed: " +
JSON.stringify(error) +
" - " +
JSON.stringify(options),
true,
);
// Inform the user about the issue
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Push Notifications",
text:
"We encountered an issue setting up push notifications. " +
"If you wish to revoke notification permissions, please do so in your browser settings.",
},
-1,
);
reject(error);
});
});
}
private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime,
): Promise<void> {
logConsoleAndDb(
"About to send subscription... " + JSON.stringify(subscription),
);
return fetch("/web-push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
console.error("Bad response subscribing to web push: ", response);
throw new Error("Failed to send push subscription to server");
}
logConsoleAndDb("Push subscription sent to server successfully.");
});
}
}
</script>
<style scoped>
/* Add any specific styles for this component here */
</style>

View File

@@ -11,11 +11,8 @@
'text-slate-500': selected !== 'Home',
}"
>
<router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
<div class="flex flex-col items-center">
<fa icon="house-chimney" class="fa-fw" />
<span class="text-xs mt-1">feed</span>
</div>
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
<fa icon="house-chimney" class="fa-fw" />
</router-link>
</li>
<!-- Search -->
@@ -29,12 +26,9 @@
>
<router-link
:to="{ name: 'discover' }"
class="block text-center py-2 px-1"
class="block text-center py-3 px-1"
>
<div class="flex flex-col items-center">
<fa icon="magnifying-glass" class="fa-fw" />
<span class="text-xs mt-1">search</span>
</div>
<fa icon="magnifying-glass" class="fa-fw" />
</router-link>
</li>
<!-- Projects -->
@@ -48,12 +42,9 @@
>
<router-link
:to="{ name: 'projects' }"
class="block text-center py-2 px-1"
class="block text-center py-3 px-1"
>
<div class="flex flex-col items-center">
<fa icon="hand" class="fa-fw" />
<span class="text-xs mt-1">your work</span>
</div>
<fa icon="hand" class="fa-fw" />
</router-link>
</li>
<!-- Contacts -->
@@ -67,12 +58,9 @@
>
<router-link
:to="{ name: 'contacts' }"
class="block text-center py-2 px-1"
class="block text-center py-3 px-1"
>
<div class="flex flex-col items-center">
<fa icon="users" class="fa-fw" />
<span class="text-xs mt-1">contacts</span>
</div>
<fa icon="users" class="fa-fw" />
</router-link>
</li>
<!-- Profile -->
@@ -86,18 +74,9 @@
>
<router-link
:to="{ name: 'account' }"
class="block text-center py-2 px-1"
class="block text-center py-3 px-1"
>
<div class="flex flex-col items-center">
<fa icon="circle-user" class="fa-fw" />
<!--
We used to say "account", so we'll keep that in the code,
but it isn't accurate because we don't hold anything for them.
We'll say "profile" to the users.
(Or: settings, face, registry, cache, repo, vault... or separate preferences from identity.)
-->
<span class="text-xs mt-1">profile</span>
</div>
<fa icon="circle-user" class="fa-fw" />
</router-link>
</li>
</ul>

View File

@@ -1,22 +1,13 @@
<template>
<div class="absolute right-5 top-3">
<span class="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2">
<router-link
:to="{ name: 'help' }"
class="text-xs 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-1 rounded-md ml-1"
>
Help
</router-link>
</span>
</div>
<div class="text-center text-red-500">{{ message }}</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import { AppString, NotificationIface } from "@/constants/app";
import { retrieveSettingsForActiveAccount } from "@/db/index";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component
export default class TopMessage extends Vue {
@@ -28,15 +19,17 @@ export default class TopMessage extends Vue {
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
if (
settings.warnIfTestServer &&
settings?.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're linked to a non-prod server, user " + didPrefix;
} else if (
settings.warnIfProdServer &&
settings?.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);

View File

@@ -1,108 +0,0 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1>
{{ sharingExplanation }}
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="givenName"
/>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
class="block w-full text-center 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 mb-2"
@click="onClickSaveChanges()"
>
Save
</button>
<button
type="button"
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-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component
export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop({
default:
"This is not sent to servers. It is only shared with people when you send it to them.",
})
sharingExplanation!: string;
@Prop({ default: false }) callbackOnCancel!: boolean;
callback: (name?: string) => void = () => {};
givenName = "";
visible = false;
/**
* @param aCallback - callback function for name, which may be ""
*/
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || "";
this.visible = true;
}
async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName,
});
this.visible = false;
this.callback(this.givenName);
}
onClickCancel() {
this.visible = false;
if (this.callbackOnCancel) {
this.callback();
}
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

View File

@@ -1,10 +1,12 @@
import axios from "axios";
import * as R from "ramda";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js";
import { retrieveSettingsForActiveAccount } from "@/db";
import { getHeaders } from "@/libs/endorserServer";
import { accountsDB, db } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
const ANIMATION_DURATION_SECS = 10;
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
@@ -13,10 +15,21 @@ export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
try {
const settings = await retrieveSettingsForActiveAccount();
const activeDid = settings.activeDid || "";
const apiServer = settings.apiServer;
const headers = await getHeaders(activeDid);
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
const activeDid = settings?.activeDid || "";
const apiServer = settings?.apiServer;
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === activeDid, accounts);
const headers = {
"Content-Type": "application/json",
};
const identity = JSON.parse(account?.identity || "null");
if (identity) {
const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token;
}
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
const resp = await axios.get(url, { headers: headers });

View File

@@ -4,32 +4,21 @@
* See also ../libs/veramo/setup.ts
*/
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch",
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
PROD_PUSH_SERVER = "https://timesafari.app",
TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
NO_CONTACT_NAME = "(no name)",
}
export const APP_SERVER =
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER;
@@ -38,32 +27,22 @@ export const DEFAULT_IMAGE_API_SERVER =
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER;
export const DEFAULT_PARTNER_API_SERVER =
import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER ||
AppString.TEST_PARTNER_API_SERVER;
export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host;
export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
/**
* The possible values for "group" and "type" are in App.vue.
* Some of this comes from the notiwind package, some is custom.
* From the notiwind package
*/
export interface NotificationIface {
group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string;
text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action
noText?: string;
onCancel?: (stopAsking?: boolean) => Promise<void>;
onNo?: (stopAsking?: boolean) => Promise<void>;
onCancel?: (stopAsking: boolean) => Promise<void>;
onNo?: (stopAsking: boolean) => Promise<void>;
onYes?: () => Promise<void>;
promptToStopAsking?: boolean;
yesText?: string;
}

View File

@@ -1,11 +1,8 @@
import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda";
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts";
import { Log, LogSchema } from "./tables/logs";
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret";
import {
MASTER_SETTINGS_KEY,
Settings,
@@ -15,7 +12,6 @@ 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
type SecretTable = { secret: Table<Secret> };
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>;
@@ -25,222 +21,39 @@ type NonsensitiveTables = {
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
//// Initialize the DBs, starting with the sensitive ones.
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
secretDB.version(1).stores(SecretSchema);
// Initialize Dexie database for accounts
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside.
//
// This is a promise because the decryption key comes from IndexedDB
// and someday it may come from a password or keystore or external wallet.
// It's important that usages take into account that there may be a delay due
// to a user action required to unlock the data.
export const accountsDBPromise = useSecretAndInitializeAccountsDB(
secretDB,
accountsDexie,
);
//// Now initialize the other DB.
// Initialize Dexie databases for non-sensitive data
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
// 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,
...{ settings: "id" }, // old Settings schema
...SettingsSchema,
});
// v3 added Temp
db.version(3).stores(TempSchema);
db.version(4)
.stores(SettingsSchema)
.upgrade((tx) => {
return tx
.table("settings")
.toCollection()
.modify((settings) => {
settings.accountDid = ""; // make it non-null for the default master settings, but still indexable
});
});
const DEFAULT_SETTINGS = {
id: MASTER_SETTINGS_KEY,
activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER,
};
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", async () => {
await db.settings.add(DEFAULT_SETTINGS);
db.on("populate", () => {
db.settings.add({
id: MASTER_SETTINGS_KEY,
apiServer: DEFAULT_ENDORSER_API_SERVER,
});
});
// Manage the encryption key.
// It's not really secure to maintain the secret next to the user's data.
// However, until we have better hooks into a real wallet or reliable secure
// storage, we'll do this for user convenience. As they sign more records
// and integrate with more people, they'll value it more and want to be more
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
// PWA so it's not in a browser... and then we hope to be integrated with a
// real wallet or something else more secure.
// One might ask: why encrypt at all? We figure a basic encryption is better
// than none. Plus, we expect to support their own password or keystore or
// external wallet as better signing options in the future, so it's gonna be
// important to have the structure where each account access might require
// user action.
// (Once upon a time we stored the secret in localStorage, but it frequently
// got erased, even though the IndexedDB still had the identity data. This
// ended up throwing lots of errors to the user... and they'd end up in a state
// where they couldn't take action because they couldn't unlock that identity.)
// check for the secret in storage
async function useSecretAndInitializeAccountsDB(
secretDB: SecretDexie,
accountsDB: SensitiveDexie,
): Promise<SensitiveDexie> {
return secretDB
.open()
.then(() => {
return secretDB.secret.get(MASTER_SECRET_KEY);
})
.then((secretRow?: Secret) => {
let secret = secretRow?.secret;
if (secret != null) {
// they already have it in IndexedDB, so just pass it along
return secret;
} else {
// check localStorage (for users before v 0.3.37)
const localSecret = localStorage.getItem("secret");
if (localSecret != null) {
// they had one, so we want to move it to IndexedDB
secret = localSecret;
} else {
// they didn't have one, so let's generate one
secret = Encryption.createRandomEncryptionKey();
}
// it is not in IndexedDB, so add it now
return secretDB.secret
.add({ id: MASTER_SECRET_KEY, secret })
.then(() => {
return secret;
});
}
})
.then((secret?: string) => {
if (secret == null) {
throw new Error("No secret found or created.");
} else {
// apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
accountsDB.version(1).stores(AccountsSchema);
accountsDB.open();
return accountsDB;
}
})
.catch((error) => {
logConsoleAndDb("Error processing secret & encrypted accountsDB.", error);
// alert("There was an error processing encrypted data. See the Help page.");
throw error;
});
}
// retrieves default settings
// calls db.open()
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
await db.open();
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS;
}
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
const defaultSettings = await retrieveSettingsForDefaultAccount();
if (!defaultSettings.activeDid) {
return defaultSettings;
} else {
const overrideSettings =
(await db.settings
.where("accountDid")
.equals(defaultSettings.activeDid)
.first()) || {};
return R.mergeDeepRight(defaultSettings, overrideSettings);
}
}
// Update settings for the given account, or in MASTER_SETTINGS_KEY if no accountDid is provided.
// Don't expose this because we should be explicit on whether we're updating the default settings or account settings.
async function updateSettings(settingsChanges: Settings): Promise<void> {
await db.open();
if (!settingsChanges.accountDid) {
// ensure there is no "id" that would override the key
delete settingsChanges.id;
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
} else {
const result = await db.settings
.where("accountDid")
.equals(settingsChanges.accountDid)
.modify(settingsChanges);
if (result === 0) {
if (!settingsChanges.id) {
// It is unfortunate that we have to set this explicitly.
// We didn't make id a "++id" at the beginning and Dexie won't let us change it,
// plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
settingsChanges.id = (await db.settings.count()) + 1;
}
await db.settings.add(settingsChanges);
}
}
}
export async function updateDefaultSettings(settings: Settings): Promise<void> {
delete settings.accountDid; // just in case
await updateSettings(settings);
}
export async function updateAccountSettings(
accountDid: string,
settings: Settings,
): Promise<void> {
settings.accountDid = accountDid;
await updateSettings(settings);
}
// similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb(
message: string,
isError = false,
): Promise<void> {
if (isError) {
console.error(`${new Date().toISOString()} ${message}`);
} else {
console.log(`${new Date().toISOString()} ${message}`);
}
await db.open();
const todayKey = new Date().toDateString();
// only keep one day's worth of logs
const previous = await db.logs.get(todayKey);
if (!previous) {
// when this is today's first log, clear out everything previous
await db.logs.clear();
}
const prevMessages = (previous && previous.message) || "";
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
await db.logs.update(todayKey, { message: fullMessage });
}

View File

@@ -1 +0,0 @@
Check the contact & settings export to see whether you want your new table to be included in it.

View File

@@ -5,7 +5,7 @@ export type Account = {
/**
* Auto-generated ID by Dexie
*/
id?: number; // this is only blank on input, when the database assigns it
id?: number;
/**
* The date the account was created
@@ -48,7 +48,7 @@ export type Account = {
/**
* Schema for the accounts table in the database.
* Fields starting with a $ character are encrypted.
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon#added-schema-syntax}
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
*/
export const AccountsSchema = {
accounts:

View File

@@ -1,22 +1,11 @@
export interface ContactMethod {
label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string;
}
export interface Contact {
//
// When adding a property, consider whether it should be added when exporting & sharing contacts.
did: string;
contactMethods?: Array<ContactMethod>;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;
profileImageUrl?: string;
publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting
registered?: boolean; // cached value of the server setting
seesMe?: boolean;
registered?: boolean;
}
export const ContactSchema = {

View File

@@ -1,18 +0,0 @@
/**
* Represents an account stored in the database.
*/
export type Secret = {
/**
* Auto-generated ID by Dexie
*/
id: number;
/**
* The secret key used to decrypt the identity if they're not using their own password
*/
secret: string;
};
export const SecretSchema = { secret: "++id, secret" };
export const MASTER_SECRET_KEY = 0;

View File

@@ -12,42 +12,23 @@ export type BoundingBox = {
* Settings type encompasses user-specific configuration details.
*/
export type Settings = {
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
id?: number; // this is erased for all those entries that are keyed with accountDid
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY
// if supplied, this settings record overrides the master record when the user switches to this account
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry
// active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
apiServer: string; // API server URL
activeDid?: string; // Active Decentralized ID
apiServer?: string; // API server URL
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
finishedOnboarding?: boolean; // the user has completed the onboarding process
firstName?: string; // user's full name, may be null if unwanted for a particular account
firstName?: string; // user's full name
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean;
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
lastName?: string; // deprecated - put all names in firstName
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string;
lastViewedClaimId?: string;
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
partnerApiServer?: string; // partner server API URL
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
profileImageUrl?: string; // may be null if unwanted for a particular account
profileImageUrl?: string;
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
reminderOn?: boolean; // Toggle to enable or disable reminders
// Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{
@@ -56,7 +37,6 @@ export type Settings = {
}>;
showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
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
@@ -64,20 +44,18 @@ export type Settings = {
webPushServer?: string; // Web Push server URL
};
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
export function isAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings.filterFeedByNearby || settings.filterFeedByVisible);
}
/**
* Schema for the Settings table in the database.
*/
export const SettingsSchema = {
settings: "id, &accountDid",
settings: "id",
};
/**
* Constants.
*/
export const MASTER_SETTINGS_KEY = 1;
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

View File

@@ -2,11 +2,12 @@
export type Temp = {
id: string;
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
blobB64?: string; // base64-encoded blob
blob?: Blob;
};
/**
* Schema for the Temp table in the database.
*/
export const TempSchema = { temp: "id" };
export const TempSchema = {
temp: "id",
};

View File

@@ -3,13 +3,10 @@ import { getRandomBytesSync } from "ethereum-cryptography/random";
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode";
import * as didJwt from "did-jwt";
import * as u8a from "uint8arrays";
import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
} from "@/libs/endorserServer";
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
@@ -52,7 +49,7 @@ export const newIdentifier = (
*
*
* @param {string} mnemonic
* @return {[string, string, string, string]} address, privateHex, publicHex, derivationPath
* @return {*} {[string, string, string, string]}
*/
export const deriveAddress = (
mnemonic: string,
@@ -86,51 +83,101 @@ export const generateSeed = (): string => {
};
/**
* Retrieve an access token, or "" if no DID is provided.
* Retreive an access token
*
* @param {string} did
* @return {string} JWT with basic payload
* @param {IIdentifier} identifier
* @return {*}
*/
export const accessToken = async (did?: string) => {
if (did) {
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + 60; // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
return createEndorserJwtForDid(did, tokenPayload);
} else {
return "";
}
export const accessToken = async (identifier: IIdentifier) => {
const did: string = identifier.did;
const privateKeyHex: string = identifier.keys[0].privateKeyHex as string;
const signer = SimpleSigner(privateKeyHex);
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + 60; // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
const jwt: string = await didJwt.createJWT(tokenPayload, {
alg,
issuer: did,
signer,
});
return jwt;
};
export const sign = async (privateKeyHex: string) => {
const signer = SimpleSigner(privateKeyHex);
return signer;
};
/**
@return payload of JWT pulled out of any recognized URL path (if any)
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(import.meta.env.PRIVATE_KEY)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
export function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
export function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
/**
@return results of uportJwtPayload:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
Note that similar code is also contained in time-safari
*/
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText;
const appImportConfirmUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
);
if (appImportConfirmUrlLoc > -1) {
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
if (endorserContextLoc > -1) {
jwtText = jwtText.substring(
appImportConfirmUrlLoc +
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length,
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
);
}
const appImportOneUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
);
if (appImportOneUrlLoc > -1) {
jwtText = jwtText.substring(
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length,
);
}
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
if (endorserUrlPathLoc > -1) {
jwtText = jwtText.substring(
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
);
}
return jwtText;
// JWT format: { header, payload, signature, data }
const jwt = didJwt.decodeJWT(jwtText);
return jwt.payload;
};
export const nextDerivationPath = (origDerivPath: string) => {
@@ -148,156 +195,3 @@ export const nextDerivationPath = (origDerivPath: string) => {
.join("/");
return newDerivPath;
};
// Base64 encoding/decoding utilities for browser
function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer));
return btoa(binary);
}
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const KEY_LENGTH = 256;
const ITERATIONS = 100000;
// Encryption helper function
export async function encryptMessage(message: string, password: string) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH },
false,
["encrypt"],
);
// Encrypt the message
const encryptedContent = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
key,
encoder.encode(message),
);
// Return a JSON structure with base64-encoded components
const result = {
salt: arrayBufferToBase64(salt),
iv: arrayBufferToBase64(iv),
encrypted: arrayBufferToBase64(encryptedContent),
};
return btoa(JSON.stringify(result));
}
// Decryption helper function
export async function decryptMessage(encryptedJson: string, password: string) {
const decoder = new TextDecoder();
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
// Convert base64 components back to Uint8Arrays
const saltArray = base64ToArrayBuffer(salt);
const ivArray = base64ToArrayBuffer(iv);
const encryptedContent = base64ToArrayBuffer(encrypted);
// Derive the same key using PBKDF2 with the extracted salt
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: saltArray,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH },
false,
["decrypt"],
);
// Decrypt the content
const decryptedContent = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: ivArray,
},
key,
encryptedContent,
);
// Convert the decrypted content back to a string
return decoder.decode(decryptedContent);
}
// Test function to verify encryption/decryption
export async function testEncryptionDecryption() {
try {
const testMessage = "Hello, this is a test message! 🚀";
const testPassword = "myTestPassword123";
console.log("Original message:", testMessage);
// Test encryption
console.log("Encrypting...");
const encrypted = await encryptMessage(testMessage, testPassword);
console.log("Encrypted result:", encrypted);
// Test decryption
console.log("Decrypting...");
const decrypted = await decryptMessage(encrypted, testPassword);
console.log("Decrypted result:", decrypted);
// Verify
const success = testMessage === decrypted;
console.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
console.log("Messages match:", success);
// Test with wrong password
console.log("\nTesting with wrong password...");
try {
await decryptMessage(encrypted, "wrongPassword");
console.log("Should not reach here");
} catch (error) {
console.log("Correctly failed with wrong password ✅");
}
return success;
} catch (error) {
console.error("Test failed with error:", error);
return false;
}
}

View File

@@ -55,8 +55,8 @@ export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined;
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
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
@@ -67,28 +67,25 @@ export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
* 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: Promise<{ subtle: SubtleCrypto }> = 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());
},
);
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;
}
class MissingWebCrypto extends Error {
export class MissingWebCrypto extends Error {
constructor() {
const message = "An instance of the Crypto API could not be located";
super(message);
@@ -96,10 +93,10 @@ class MissingWebCrypto extends Error {
}
}
// Make it possible to stub return values during testing
const _getWebCryptoInternals = {
export const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => {
setCachedCrypto: (newCrypto: unknown) => {
webCrypto = newCrypto;
},
};
};

View File

@@ -1,46 +0,0 @@
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* This effectively hard codes the did:ethr DID resolver to use the address as the public key.
* @param did : string
* @returns {Promise<DIDResolutionResult>}
*
* Similar code resides in endorser-ch and image-api
*/
export const didEthLocalResolver = async (did: string) => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1]; // Extract eth address: 0x...
const publicKeyHex = address; // Use the address directly as a public key placeholder
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSecp256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};

View File

@@ -1,96 +0,0 @@
import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x";
import { bytesToMultibase, multibaseToBytes } from "did-jwt";
import { getWebCrypto } from "@/libs/crypto/vc/passkeyHelpers";
export const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
/**
*
*
* similar code is in crowd-funder-for-time-pwa libs/crypto/vc/passkeyDidPeer.ts verifyJwtWebCrypto
*
* @returns {Promise<boolean>}
*/
export async function verifyPeerSignature(
payloadBytes: Buffer,
issuerDid: string,
signatureBytes: Uint8Array,
): Promise<boolean> {
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,
signatureBytes,
payloadBytes,
);
return verified;
}
export 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 };
}
export function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
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;
}

View File

@@ -1,201 +0,0 @@
/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*
* The goal is to make this folder similar across projects, then move it to a library.
* Other projects: endorser-ch, image-api
*
*/
import { Buffer } from "buffer/";
import * as didJwt from "did-jwt";
import { JWTVerified } from "did-jwt";
import { Resolver } from "did-resolver";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util";
export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });
/**
* Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
expiresIn?: number,
) {
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
const options = {
// alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests
issuer: account.did,
signer: signer,
expiresIn: undefined as number | undefined,
};
if (expiresIn) {
options.expiresIn = expiresIn;
}
return didJwt.createJWT(payload, options);
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(privateKeyHexString)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
// We should be calling 'verify' in more places, showing warnings if it fails.
// @returns JWTDecoded with { header: JWTHeader, payload: any, signature: string, data: string } (but doesn't verify the signature)
export function decodeEndorserJwt(jwt: string) {
return didJwt.decodeJWT(jwt);
}
// return Promise of at least { issuer, payload, verified boolean }
// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer
export async function decodeAndVerifyJwt(
jwt: string,
): Promise<Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt">> {
const pieces = jwt.split(".");
const header = JSON.parse(base64urlDecodeString(pieces[0]));
const payload = JSON.parse(base64urlDecodeString(pieces[1]));
const issuerDid = payload.iss;
if (!issuerDid) {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await didJwt.verifyJWT(jwt, {
resolver: ethLocalResolver,
});
return verified;
} catch (e: unknown) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const verified = await verifyPeerSignature(
Buffer.from(payload),
issuerDid,
urlBase64ToUint8Array(pieces[2]),
);
if (!verified) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
} else {
return { issuer: issuerDid, payload: payload, verified: true };
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}

View File

@@ -1,11 +0,0 @@
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@@ -1,5 +1,7 @@
import asn1 from "asn1-ber";
import { Buffer } from "buffer/";
import { JWTPayload } from "did-jwt";
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 {
@@ -19,15 +21,10 @@ import {
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/types";
import { AppString } from "@/constants/app";
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers";
import {
arrayToBase64Url,
cborToKeys,
peerDidToPublicKeyBytes,
verifyPeerSignature,
} from "@/libs/crypto/vc/didPeer";
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;
@@ -35,12 +32,20 @@ export interface JWK {
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: AppString.APP_NAME,
rpName: "Time Safari",
rpID: window.location.hostname,
userName: passkeyName || AppString.APP_NAME + " User",
userName: passkeyName || "Time Safari User",
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: "none",
@@ -69,7 +74,7 @@ export async function registerCredential(passkeyName?: string) {
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) {
console.log("Warning! The raw ID does not match the credential ID.");
console.log("Warning! The raw ID does not match the credential ID.")
}
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),
@@ -87,6 +92,21 @@ export async function registerCredential(passkeyName?: string) {
};
}
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;
@@ -97,17 +117,13 @@ export class PeerSetup {
issuerDid: string,
payload: object,
credIdHex: string,
expMinutes: number = 1,
) {
const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const issuedAt = Math.floor(Date.now() / 1000);
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
const fullPayload = {
...payload,
exp: expiryTime,
iat: issuedAt,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
@@ -143,8 +159,7 @@ export class PeerSetup {
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
exp: expiryTime,
iat: issuedAt,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
@@ -163,14 +178,10 @@ export class PeerSetup {
issuerDid: string,
payload: object,
credIdHex: string,
expMinutes: number = 1,
) {
const issuedAt = Math.floor(Date.now() / 1000);
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
const fullPayload = {
...payload,
exp: expiryTime,
iat: issuedAt,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataToSignString = JSON.stringify(fullPayload);
@@ -184,12 +195,12 @@ export class PeerSetup {
allowCredentials: [
{
id: credentialId,
type: "public-key" as const,
type: "public-key",
},
],
challenge: this.challenge.buffer,
rpID: window.location.hostname,
userVerification: "preferred" as const,
userVerification: "preferred",
},
};
@@ -198,7 +209,7 @@ export class PeerSetup {
this.authenticatorData = credential?.response.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData as ArrayBuffer,
this.authenticatorData,
);
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
@@ -216,8 +227,7 @@ export class PeerSetup {
const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
exp: expiryTime,
iat: issuedAt,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
@@ -227,9 +237,8 @@ export class PeerSetup {
.replace(/\//g, "_")
.replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature).toString(
"base64",
);
const origSignature = Buffer.from(credential?.response.signature)
.toString("base64")
this.signature = origSignature
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -239,9 +248,6 @@ export class PeerSetup {
return jwt;
}
// To use this, add the asn1-ber library and add this import:
// import asn1 from "asn1-ber";
//
// return a low-level signing function, similar to createJWS approach
// async webAuthnES256KSigner(credentialID: string) {
// return async (data: string | Uint8Array) => {
@@ -298,16 +304,6 @@ export class PeerSetup {
// }
}
export async function createDidPeerJwt(
did: string,
credIdHex: string,
payload: object,
): Promise<string> {
const peerSetup = new PeerSetup();
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex);
return jwt;
}
// I'd love to use this but it doesn't verify.
// Requires:
// npm install @noble/curves
@@ -380,7 +376,6 @@ export async function verifyJwtSimplewebauthn(
return verification.verified;
}
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
export async function verifyJwtWebCrypto(
credId: Base64URLString,
issuerDid: string,
@@ -399,10 +394,35 @@ export async function verifyJwtWebCrypto(
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
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;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) {
throw new Error(
@@ -411,21 +431,13 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
}
// 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)
/**
* Looks like JsonWebKey2020 isn't too difficult:
* - change context security/suites link to jws-2020/v1
* - change publicKeyMultibase to publicKeyJwk generated with cborToKeys
* - change type to JsonWebKey2020
*/
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/secp256k1-2019/v1",
"https://w3id.org/security/suites/jws-2020/v1",
],
assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis],
@@ -451,15 +463,12 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
}
// convert COSE public key to PEM format
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because it complains about the type of x and y
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format
@@ -470,18 +479,7 @@ ${pubKeyBuffer.toString("base64")}
return pem;
}
// tried the base64url library but got an error using their Buffer
export function base64urlDecodeString(input: string) {
return atob(input.replace(/-/g, "+").replace(/_/g, "/"));
}
// tried the base64url library but got an error using their Buffer
export function base64urlEncodeString(input: string) {
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecodeArrayBuffer(input: string) {
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);
@@ -492,14 +490,13 @@ function base64urlDecodeArrayBuffer(input: string) {
return bytes.buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) {
function base64urlEncode(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return base64urlEncodeString(str);
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
function arrayBufferToBase64URLString(buffer) {
const bytes = new Uint8Array(buffer);
let str = "";
for (const charCode of bytes) {
@@ -523,7 +520,31 @@ function base64URLStringToArrayBuffer(base64URLString: string) {
return buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
export interface UserProfile {
description: string;
locLat?: number;
locLon?: number;
locLat2?: number;
locLon2?: number;
issuerDid: string;
rowId?: string; // set on profile retrieved from server
}

View File

@@ -1,54 +1,24 @@
// many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
updateAccountSettings,
updateDefaultSettings,
} from "@/db/index";
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "@/db/tables/settings";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer";
import {
containsHiddenDid,
GenericCredWrapper,
GenericVerifiableCredential,
GiveSummaryRecord,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import { KeyMeta } from "@/libs/crypto/vc";
import { createPeerDid } from "@/libs/crypto/vc/didPeer";
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
export interface GiverReceiverInputInfo {
did?: string;
name?: string;
}
export enum OnboardPage {
Home = "HOME",
Discover = "DISCOVER",
Create = "CREATE",
Contact = "CONTACT",
Account = "ACCOUNT",
}
export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
"The data you send be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to those you allow.";
/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
"BTC": "BTC",
"BX": "BX",
"BTC": "BTC",
"ETH": "ETH",
"HUR": "Hours",
"USD": "US $",
@@ -57,8 +27,8 @@ export const UNIT_SHORT: Record<string, string> = {
/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
"BTC": "Bitcoin",
"BX": "Buxbe",
"BTC": "Bitcoin",
"ETH": "Ethereum",
"HUR": "hours",
"USD": "dollars",
@@ -102,51 +72,8 @@ export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
};
export const isGiveClaimType = (claimType?: string) => {
return claimType === "GiveAction";
};
export const isGiveAction = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return isGiveClaimType(veriClaim.claimType);
};
export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) {
return (
did.substring(0, "did:peer:".length + 2) +
"..." +
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
"..."
);
} else if (did.startsWith("did:ethr:")) {
return did.substring(0, "did:ethr:".length + 9) + "...";
} else {
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
}
};
export const nameForDid = (
activeDid: string,
contacts: Array<Contact>,
did: string,
): string => {
if (did === activeDid) {
return "you";
}
const contact = R.find((con) => con.did == did, contacts);
return nameForContact(contact);
};
export const nameForContact = (
contact?: Contact,
capitalize?: boolean,
): string => {
return (
(contact?.name as string) ||
(capitalize ? "This" : "this") + " unnamed user"
);
export const isGiveAction = (veriClaim: GenericCredWrapper) => {
return veriClaim.claimType === "GiveAction";
};
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
@@ -156,211 +83,30 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
.then(() => setTimeout(fn, 2000));
};
export interface ConfirmerData {
confirmerIdList: string[];
confsVisibleToIdList: string[];
numConfsNotVisible: number;
}
// // This is meant to be a second argument to JSON.stringify to avoid circular references.
// // Usage: JSON.stringify(error, getCircularReplacer())
// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed
// function getCircularReplacer() {
// const seen = new WeakSet();
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// return (obj: any, key: string, value: any): any => {
// if (typeof value === "object" && value !== null) {
// if (seen.has(value)) {
// return "[circular ref]";
// }
// seen.add(value);
// }
// return value;
// };
// }
/**
* @return only confirmers, excluding the issuer and hidden DIDs
*/
export async function retrieveConfirmerIdList(
apiServer: string,
claimId: string,
claimIssuerId: string,
userDid: string,
): Promise<ConfirmerData | undefined> {
const confirmUrl =
apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
const confirmHeaders = await serverUtil.getHeaders(userDid);
const response = await axios.get(confirmUrl, {
headers: confirmHeaders,
});
if (response.status === 200) {
const resultList1 = response.data.result || [];
//const publicUrls = resultList.publicUrls || [];
delete resultList1.publicUrls;
// exclude hidden DIDs
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
// exclude the issuer
const resultList3 = R.reject(
(did: string) => did === claimIssuerId,
resultList2,
);
const confirmerIdList = resultList3;
let 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
numConfsNotVisible = numConfsNotVisible - 1;
}
const confsVisibleToIdList = response.data.result.resultVisibleToDids || [];
const result: ConfirmerData = {
confirmerIdList,
confsVisibleToIdList,
numConfsNotVisible,
};
return result;
} else {
console.error(
"Bad response status of",
response.status,
"for confirmers:",
response,
);
return undefined;
}
}
/**
* @returns true if the user can confirm the claim
* @param veriClaim is expected to have fields: claim, claimType, and issuer
*/
export function isGiveRecordTheUserCanConfirm(
isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
export const isGiveRecordTheUserCanConfirm = (
veriClaim: GenericCredWrapper,
activeDid: string,
confirmerIdList: string[] = [],
): boolean {
) => {
return (
isRegistered &&
isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim)
);
}
export function notifyWhyCannotConfirm(
notifyFun: (notification: NotificationIface, timeout: number) => void,
isRegistered: boolean,
claimType: string | undefined,
giveDetails: GiveSummaryRecord | undefined,
activeDid: string,
confirmerIdList: string[] = [],
) {
if (!isRegistered) {
notifyFun(
{
group: "alert",
type: "info",
title: "Not Registered",
text: "Someone needs to register you before you can confirm.",
},
3000,
);
} else if (!isGiveClaimType(claimType)) {
notifyFun(
{
group: "alert",
type: "info",
title: "Not A Give",
text: "This is not a giving action to confirm.",
},
3000,
);
} else if (confirmerIdList.includes(activeDid)) {
notifyFun(
{
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You already confirmed this claim.",
},
3000,
);
} else if (giveDetails?.issuerDid == activeDid) {
notifyFun(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because you issued this claim.",
},
3000,
);
} else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) {
notifyFun(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because some people are hidden.",
},
3000,
);
} else {
notifyFun(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.",
},
3000,
);
}
}
export async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
// Extract the content type and the Base64 data
const [metadata, base64] = base64DataUrl.split(",");
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
const byteCharacters = atob(base64);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: contentType });
}
};
/**
* @returns the DID of the person who offered, or undefined if hidden
* @param veriClaim is expected to have fields: claim and issuer
*/
export function offerGiverDid(
veriClaim: GenericCredWrapper<OfferVerifiableCredential>,
): string | undefined {
export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = (
veriClaim,
) => {
let giver;
if (
veriClaim.claim.offeredBy?.identifier &&
@@ -371,19 +117,14 @@ export function offerGiverDid(
giver = veriClaim.issuer;
}
return giver;
}
};
/**
* @returns true if the user can fulfill the offer
* @param veriClaim is expected to have fields: claim, claimType, and issuer
*/
export const canFulfillOffer = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return (
veriClaim.claimType === "Offer" &&
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
);
export const canFulfillOffer = (veriClaim: GenericCredWrapper) => {
return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim));
};
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
@@ -452,72 +193,20 @@ export function findAllVisibleToDids(
*
**/
export interface AccountKeyInfo extends Account, KeyMeta {}
export const retrieveAccountCount = async (): Promise<number> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
return await accountsDB.accounts.count();
};
export const retrieveAccountDids = async (): Promise<string[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
const allDids = allAccounts.map((acc) => acc.did);
return allDids;
};
// This is provided and recommended when the full key is not necessary so that
// future work could separate this info from the sensitive key material.
export const retrieveAccountMetadata = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
export const getIdentity = async (activeDid: string): Promise<IIdentifier> => {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
} else {
return undefined;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
`Attempted to load identity ${activeDid} but no identifier was found`,
);
}
};
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
return array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
});
};
export const retrieveFullyDecryptedAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
return account;
};
// let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountKeyInfo>
> => {
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
return allAccounts;
return identity;
};
/**
@@ -533,8 +222,7 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
@@ -544,71 +232,34 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
publicKeyHex: newId.keys[0].publicKeyHex,
});
await updateDefaultSettings({ activeDid: newId.did });
//console.log("Updated default settings in util");
await updateAccountSettings(newId.did, { isRegistered: false });
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
return newId.did;
};
export const registerAndSavePasskey = async (
keyName: string,
): Promise<Account> => {
const cred = await registerCredential(keyName);
const publicKeyBytes = cred.publicKeyBytes;
const did = createPeerDid(publicKeyBytes as Uint8Array);
const passkeyCredIdHex = cred.credIdHex as string;
const account = {
dateCreated: new Date().toISOString(),
did,
passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
};
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add(account);
return account;
};
export const registerSaveAndActivatePasskey = async (
keyName: string,
): Promise<Account> => {
const account = await registerAndSavePasskey(keyName);
await updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false });
return account;
};
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
const settings = await retrieveSettingsForActiveAccount();
return (
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60
);
};
// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
export const DAILY_CHECK_TITLE = "DAILY_CHECK";
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean,
): Promise<AxiosResponse> => {
const settings = await retrieveSettingsForActiveAccount();
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;
}
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
// This is shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
const newPayload = {
...subscriptionJSON,
// ... overridden with the following
// eslint-disable-next-line prettier/prettier
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
...subscriptionJSON,
};
console.log("Sending a test web push message:", newPayload);
const payloadStr = JSON.stringify(newPayload);

View File

@@ -22,8 +22,6 @@ import {
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@@ -41,13 +39,9 @@ import {
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
@@ -60,8 +54,6 @@ import {
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
@@ -74,7 +66,6 @@ import {
faPlus,
faQuestion,
faQrcode,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
@@ -101,8 +92,6 @@ library.add(
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
@@ -120,13 +109,9 @@ library.add(
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
@@ -139,8 +124,6 @@ library.add(
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
@@ -154,7 +137,6 @@ library.add(
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faShareNodes,
faSpinner,
faSquare,
@@ -180,14 +162,11 @@ function setupGlobalErrorHandler(app: VueApp) {
info: string,
) => {
console.error(
"Ouch! Global Error Handler.",
"Ouch! Global Error Handler. Info:",
info,
"Error:",
err,
"- Error toString:",
err.toString(),
"- Info:",
info,
"- Instance:",
"Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.

View File

@@ -2,7 +2,6 @@
import { register } from "register-service-worker";
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
if (import.meta.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", {
ready() {

View File

@@ -5,7 +5,7 @@ import {
RouteLocationNormalized,
RouteRecordRaw,
} from "vue-router";
import { accountsDBPromise } from "@/db/index";
import { accountsDB } from "@/db/index";
/**
*
@@ -18,8 +18,7 @@ const enterOrStart = async (
from: RouteLocationNormalized,
next: NavigationGuardNext,
) => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.open();
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
next();
@@ -39,16 +38,6 @@ const routes: Array<RouteRecordRaw> = [
name: "claim",
component: () => import("../views/ClaimView.vue"),
},
{
path: "/claim-add-raw/:id?",
name: "claim-add-raw",
component: () => import("../views/ClaimAddRawView.vue"),
},
{
path: "/claim-cert/:id",
name: "claim-cert",
component: () => import("../views/ClaimCertificateView.vue"),
},
{
path: "/confirm-contact",
name: "confirm-contact",
@@ -64,21 +53,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-amounts",
component: () => import("../views/ContactAmountsView.vue"),
},
{
path: "/contact-edit/:did",
name: "contact-edit",
component: () => import("../views/ContactEditView.vue"),
},
{
path: "/contact-gift",
name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"),
},
{
path: "/contact-import/:jwt?",
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
},
{
path: "/contact-qr",
name: "contact-qr",
@@ -102,7 +81,7 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/gifted-details",
name: "gifted-details",
component: () => import("@/views/GiftedDetailsView.vue"),
component: () => import("../views/GiftedDetails.vue"),
},
{
path: "/help",
@@ -114,11 +93,6 @@ const routes: Array<RouteRecordRaw> = [
name: "help-notifications",
component: () => import("../views/HelpNotificationsView.vue"),
},
{
path: "/help-notification-types",
name: "help-notification-types",
component: () => import("../views/HelpNotificationTypesView.vue"),
},
{
path: "/help-onboarding",
name: "help-onboarding",
@@ -144,21 +118,6 @@ const routes: Array<RouteRecordRaw> = [
name: "import-derive",
component: () => import("../views/ImportDerivedAccountView.vue"),
},
{
path: "/invite-one",
name: "invite-one",
component: () => import("../views/InviteOneView.vue"),
},
{
path: "/invite-one-accept/:jwt?",
name: "InviteOneAcceptView",
component: () => import("@/views/InviteOneAcceptView.vue"),
},
{
path: "/new-activity",
name: "new-activity",
component: () => import("../views/NewActivityView.vue"),
},
{
path: "/new-edit-account",
name: "new-edit-account",
@@ -174,26 +133,6 @@ const routes: Array<RouteRecordRaw> = [
name: "new-identifier",
component: () => import("../views/NewIdentifierView.vue"),
},
{
path: "/offer-details/:id?",
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{
path: "/onboard-meeting-list",
name: "onboard-meeting-list",
component: () => import("../views/OnboardMeetingListView.vue"),
},
{
path: "/onboard-meeting-members/:groupId",
name: "onboard-meeting-members",
component: () => import("../views/OnboardMeetingMembersView.vue"),
},
{
path: "/onboard-meeting-setup",
name: "onboard-meeting-setup",
component: () => import("../views/OnboardMeetingSetupView.vue"),
},
{
path: "/project/:id?",
name: "project",
@@ -220,16 +159,6 @@ const routes: Array<RouteRecordRaw> = [
name: "quick-action-bvc-end",
component: () => import("../views/QuickActionBvcEndView.vue"),
},
{
path: "/recent-offers-to-user",
name: "recent-offers-to-user",
component: () => import("../views/RecentOffersToUserView.vue"),
},
{
path: "/recent-offers-to-user-projects",
name: "recent-offers-to-user-projects",
component: () => import("../views/RecentOffersToUserProjectsView.vue"),
},
{
path: "/scan-contact",
name: "scan-contact",
@@ -245,19 +174,11 @@ const routes: Array<RouteRecordRaw> = [
name: "seed-backup",
component: () => import("../views/SeedBackupView.vue"),
},
{
path: "/share-my-contact-info",
name: "share-my-contact-info",
component: () => import("@/views/ShareMyContactInfoView.vue"),
},
{
path: "/shared-photo",
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
// /share-target is also an endpoint in the service worker
{
path: "/start",
name: "start",
@@ -273,11 +194,6 @@ const routes: Array<RouteRecordRaw> = [
name: "test",
component: () => import("../views/TestView.vue"),
},
{
path: "/userProfile/:id?",
name: "userProfile",
component: () => import("../views/UserProfileView.vue"),
},
];
/** @type {*} */
@@ -294,7 +210,6 @@ const errorHandler = (
) => {
// Handle the error here
console.error("Caught in top level error handler:", error, to, from);
alert("Something is very wrong. Try reloading or restarting the app.");
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
};

20
src/store/app.ts Normal file
View File

@@ -0,0 +1,20 @@
// @ts-check
import { defineStore } from "pinia";
export const useAppStore = defineStore({
id: "app",
state: () => ({
_projectId:
typeof localStorage.getItem("projectId") === "undefined"
? ""
: localStorage.getItem("projectId"),
}),
getters: {
projectId: (state): string => state._projectId as string,
},
actions: {
async setProjectId(newProjectId: string) {
localStorage.setItem("projectId", newProjectId);
},
},
});

View File

@@ -1,13 +1,11 @@
import axios from "axios";
import * as didJwt from "did-jwt";
import { AppString } from "@/constants/app";
import { retrieveSettingsForActiveAccount } from "../db";
import { SERVICE_ID } from "@/libs/endorserServer";
import { deriveAddress, newIdentifier } from "@/libs/crypto";
import { db } from "../db";
import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
/**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid.
*/
export async function testServerRegisterUser() {
const testUser0Mnem =
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";
@@ -16,7 +14,8 @@ export async function testServerRegisterUser() {
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
const settings = await retrieveSettingsForActiveAccount();
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
// Make a claim
const vcClaim = {
@@ -24,7 +23,7 @@ export async function testServerRegisterUser() {
"@type": "RegisterAction",
agent: { did: identity0.did },
object: SERVICE_ID,
participant: { did: settings.activeDid },
participant: { did: settings?.activeDid },
};
// Make a payload for the claim
const vcPayload = {
@@ -51,7 +50,7 @@ export async function testServerRegisterUser() {
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const endorserApiServer =
settings.apiServer || AppString.TEST_ENDORSER_API_SERVER;
settings?.apiServer || AppString.TEST_ENDORSER_API_SERVER;
const url = endorserApiServer + "/api/claim";
const headers = {
"Content-Type": "application/json",

File diff suppressed because it is too large Load Diff

View File

@@ -1,143 +0,0 @@
<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>
Raw Claim
</h1>
</div>
<div class="flex">
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea>
</div>
<button
class="block w-full text-center 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"
@click="submitClaim()"
>
Sign &amp; Send
</button>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import * as serverUtil from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { errorStringForLog } from "@/libs/endorserServer";
@Component({
components: { QuickNav },
})
export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
accountIdentityStr: string = "null";
activeDid = "";
apiServer = "";
claimStr = "";
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.claimStr = (this.$route as Router).query["claim"];
if (this.claimStr) {
try {
const veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(veriClaim, null, 2);
} catch (e) {
// ignore a parse error
}
} else {
// there may be no link that uses this, meaning you'd have to enter it in a browser
const claimJwtId = (this.$route as Router).query["claimJwtId"];
if (claimJwtId) {
const urlPath = libsUtil.isGlobalUri(claimJwtId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimJwtId);
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const response = await this.axios.get(url, { headers });
if (response.status === 200) {
const claim = response.data?.claim;
claim.lastClaimId = serverUtil.stripEndorserPrefix(claimJwtId);
this.claimStr = JSON.stringify(claim, null, 2);
} else {
throw {
message: "Got an error loading that claim.",
response: {
status: response.status,
statusText: response.statusText,
// url is in "fetch" response but not in AxiosResponse
},
};
}
} catch (error: unknown) {
logConsoleAndDb(
"Error retrieving claim: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error retrieving claim data.",
},
3000,
);
}
}
}
}
async submitClaim() {
const fullClaim = JSON.parse(this.claimStr);
const result = await serverUtil.createAndSubmitClaim(
fullClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Claim submitted.",
},
5000,
);
} else {
console.error("Got error submitting the claim:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the claim.",
},
5000,
);
}
}
}
</script>

View File

@@ -1,270 +0,0 @@
<template>
<section id="Content">
<div class="flex items-center justify-center h-screen">
<div v-if="claimData">
<router-link :to="'/claim/' + this.claimId">
<canvas class="w-full block mx-auto" ref="claimCanvas"></canvas>
</router-link>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as serverUtil from "@/libs/endorserServer";
@Component
export default class ClaimCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claimId = "";
claimData = null;
serverUtil = serverUtil;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,
);
this.claimId = pathParams;
await this.fetchClaim();
}
async fetchClaim() {
try {
const headers = await serverUtil.getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/claim/${this.claimId}`,
{ headers },
);
if (response.status === 200) {
this.claimData = await response.data;
const claimEntryIds = [this.claimId];
const headers = await serverUtil.getHeaders(this.activeDid);
const confirmerResponse = await this.axios.post(
`${this.apiServer}/api/v2/report/confirmers/?claimEntryIds=${this.claimId}`,
{ claimEntryIds },
{ headers },
);
let confirmerIds: Array<string> = [];
if (confirmerResponse.status === 200) {
confirmerIds = await confirmerResponse.data.data;
}
await nextTick(); // Wait for the DOM to update
if (this.claimData) {
this.drawCanvas(this.claimData, confirmerIds);
}
} else {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the claim.",
});
}
}
async drawCanvas(
claimData: serverUtil.GenericCredWrapper<serverUtil.GenericVerifiableCredential>,
confirmerIds: Array<string>,
) {
await db.open();
const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d");
if (ctx) {
// Load the background image
const backgroundImage = new Image();
backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => {
// Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Set font and styles
ctx.fillStyle = "black";
// Draw claim type
ctx.font = "bold 20px Arial";
const claimTypeText =
claimData.claimType === "GiveAction"
? "Gift"
: claimData.claimType === "PlanAction"
? "Project"
: this.serverUtil.capitalizeAndInsertSpacesBeforeCaps(
claimData.claimType || "",
);
const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText(
claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33,
);
if (claimData.claimType === "GiveAction" && claimData.claim.agent) {
const presentedText = "Thanks To";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
const agentDid =
claimData.claim.agent.identifier || claimData.claim.agent;
const agentText = serverUtil.didInfoForCertificate(
agentDid,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.41,
);
}
// alternatively, show some offer details
if (claimData.claimType === "Offer") {
const presentedText = "To";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
// fulfills
const agentDid =
claimData.claim.agent.identifier || claimData.claim.agent;
const agentText = serverUtil.didInfoForCertificate(
agentDid,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.41,
);
}
const descriptionText =
claimData.claim.name ||
claimData.claim.description ||
claimData.claim.itemOffered?.description; // for Offers
if (descriptionText) {
const descriptionLine =
descriptionText.length > 50
? descriptionText.substring(0, 75) + "..."
: descriptionText;
ctx.font = "14px Arial";
const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText(
descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.495,
);
}
const possibleObject =
claimData.claim.object || // for GiveActions
claimData.claim.includesObject; // for Offers
if (possibleObject?.amountOfThisGood && possibleObject?.unitCode) {
const amount = possibleObject.amountOfThisGood;
const unit = possibleObject.unitCode;
const amountText = serverUtil.displayAmount(unit, amount);
const amountWidth = ctx.measureText(amountText).width;
// if there was no description then put this in that spot, otherwise put it below the description
const yPos = descriptionText
? CANVAS_HEIGHT * 0.525
: CANVAS_HEIGHT * 0.495;
ctx.font = "14px Arial";
ctx.fillText(amountText, (CANVAS_WIDTH - amountWidth) / 2, yPos);
}
// Draw claim issuer
if (
claimData.issuer == null ||
serverUtil.isHiddenDid(claimData.issuer) ||
// don't show if issuer claimed for themselves
// (The confirmations are the good stuff anyway, and self-issued certs shouldn't detract from that.)
claimData.issuer !== claimData.claim.agent?.identifier
) {
ctx.font = "14px Arial";
let fullIssuer = serverUtil.didInfoForCertificate(
claimData.issuer,
allContacts,
);
if (fullIssuer.length > 30) {
fullIssuer = fullIssuer.substring(0, 30) + "...";
}
const issuerText = "Issued by " + fullIssuer;
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
}
// Draw number of claim confirmers
if (confirmerIds.length > 0) {
const confirmerText =
"Confirmed by " +
confirmerIds.length +
(confirmerIds.length === 1 ? " person" : " people");
ctx.font = "14px Arial";
ctx.fillText(
confirmerText,
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.63,
);
}
// Draw claim ID
ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
ctx.fillText(
"via EndorserSearch.com",
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73,
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
};
}
}
}
}
</script>

View File

@@ -1,190 +0,0 @@
<template>
<section id="Content">
<div v-if="claimData">
<canvas ref="claimCanvas"></canvas>
</div>
</section>
</template>
<style scoped>
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import * as endorserServer from "@/libs/endorserServer";
@Component
export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claimId = "";
claimData = null;
endorserServer = endorserServer;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,
);
this.claimId = pathParams;
await this.fetchClaim();
}
async fetchClaim() {
try {
const response = await fetch(
`${this.apiServer}/api/claim/${this.claimId}`,
);
if (response.ok) {
this.claimData = await response.json();
await nextTick(); // Wait for the DOM to update
if (this.claimData) {
this.drawCanvas(this.claimData);
}
} else {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem loading the claim.",
});
}
}
async drawCanvas(
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
) {
await db.open();
const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d");
if (ctx) {
// Load the background image
const backgroundImage = new Image();
backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => {
// Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Set font and styles
ctx.fillStyle = "black";
// Draw claim type
ctx.font = "bold 20px Arial";
const claimTypeText =
this.endorserServer.capitalizeAndInsertSpacesBeforeCaps(
claimData.claimType || "",
);
const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText(
claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33,
);
if (claimData.claim.agent) {
const presentedText = "Presented to ";
ctx.font = "14px Arial";
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
);
const agentText = endorserServer.didInfoForCertificate(
claimData.claim.agent,
allContacts,
);
ctx.font = "bold 20px Arial";
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.4,
);
}
const descriptionText =
claimData.claim.name || claimData.claim.description;
if (descriptionText) {
const descriptionLine =
descriptionText.length > 50
? descriptionText.substring(0, 75) + "..."
: descriptionText;
ctx.font = "14px Arial";
const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText(
descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.45,
);
}
// Draw claim issuer & recipient
if (claimData.issuer) {
ctx.font = "14px Arial";
const issuerText =
"Issued by " +
endorserServer.didInfoForCertificate(
claimData.issuer,
allContacts,
);
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
}
// Draw claim ID
ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
ctx.fillText(
"via EndorserSearch.com",
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73,
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
};
}
}
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
@@ -16,7 +15,6 @@
<span
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
@@ -25,17 +23,16 @@
>
Do you agree?
</span>
<span v-else> Confirmation Details </span>
<span v-else> Details </span>
</h1>
</div>
<div v-if="giveDetails && !isLoading">
<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(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
@@ -54,15 +51,21 @@
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="flex gap-4 overflow-hidden">
<div class="block flex gap-4 overflow-hidden">
<div class="overflow-hidden">
<div class="text-sm">
<div>
<fa icon="arrow-left" class="fa-fw text-slate-400" />
<fa icon="arrow-down" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
@@ -77,7 +80,7 @@
</div>
<div class="ml-6">to</div>
<div>
<fa icon="arrow-right" class="fa-fw text-slate-400" />
<fa icon="arrow-up" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
@@ -93,7 +96,7 @@
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId || '')
encodeURIComponent(giveDetails?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
@@ -114,7 +117,7 @@
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId || '')
encodeURIComponent(giveDetails?.fulfillsHandleId)
"
class="text-blue-500 mt-2 cursor-pointer"
target="_blank"
@@ -122,7 +125,7 @@
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails?.fulfillsType || "",
giveDetails.fulfillsType,
)
}}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
@@ -142,9 +145,11 @@
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
<span v-else-if="totalConfirmers() === 1">
One person confirmed this.
One person has confirmed this.
</span>
<span v-else>
{{ totalConfirmers() }} people have confirmed this.
</span>
<span v-else> {{ totalConfirmers() }} people confirmed this. </span>
<div v-if="totalConfirmers() > 0">
<div
@@ -162,10 +167,10 @@
"
>
<!-- Only show if this person has links to confirmers (below). -->
Nobody that you know issued or confirmed this claim.
Nobody that you know has issued or confirmed this claim.
</div>
<div v-if="confirmerIdList.length > 0">
The following people confirmed this claim.
The following people have issued or confirmed this claim.
<ul class="ml-4">
<li
v-for="confirmerId in confirmerIdList"
@@ -197,7 +202,7 @@
<!--
Never need to show this message:
"Nobody that you know can see someone who confirmed this claim."
"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.
@@ -205,7 +210,7 @@
<!-- Now show anyone linked to confirmers. -->
<div v-if="confsVisibleToIdList.length > 0">
The following people can connect you with people who issued or
The following people can connect you with people who have issued or
confirmed this claim.
<ul class="ml-4">
<li
@@ -241,29 +246,27 @@
<!-- explain if user cannot confirm -->
<!-- Note that these conditions are mirrored in userCanConfirm(). -->
<div v-if="!isRegistered">
You cannot confirm this because you are not registered. Find someone
to register you, maybe on the Help page.
<div v-if="confirmerIdList.includes(activeDid)">
You have confirmed this claim.
</div>
<div v-else-if="giveDetails.issuerDid == activeDid">
<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 some people are hidden.
You cannot confirm this because it contains hidden identifiers.
</div>
</div>
<!-- Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue -->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer"
@click="showDetails = !showDetails"
>
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
{{ 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="showVeriClaimDump">
<div v-if="showDetails">
<div
v-if="
serverUtil.containsHiddenDid(veriClaim) &&
@@ -274,26 +277,22 @@
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">
You can ask one of your contacts to take a look and see if their
contacts can see more details:
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 page info</a
>click to send them this info</a
>
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
and see if they are willing to make an introduction.
</span>
<span v-else>
You can ask one of your contacts to take a look and see if their
contacts can see more details:
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('A link to this page', windowLocation)"
@click="copyToClipboard('Location', windowLocation.href)"
class="text-blue-500"
>click to copy this page info</a
>share this page with them</a
>
and see if they can make an introduction. Someone is connected to
people closer to them; if you don't know who to ask, try the person
who registered you.
and see if they are willing to make an introduction.
</span>
</div>
@@ -310,7 +309,7 @@
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
@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
@@ -370,63 +369,51 @@
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
<div class="mt-2 ml-2">
<a
@click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" />
See All Generic Info
</a>
</div>
<div class="mt-2 ml-2">
<a
v-if="isRegistered"
class="text-blue-500 cursor-pointer"
:href="urlForNewGive"
>
<fa icon="file-lines" />
Record a Give Similar to the Original
</a>
</div>
</div>
</div>
<div v-else-if="!isLoading">This does not have details to confirm.</div>
<div v-else>This does not have details to confirm.</div>
<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 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 } from "axios";
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 { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
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, GiveSummaryRecord } from "@/libs/endorserServer";
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { isGiveAction, retrieveAccountDids } from "@/libs/util";
import TopMessage from "@/components/TopMessage.vue";
import { isGiveAction } from "@/libs/util";
@Component({
methods: { displayAmount },
components: { TopMessage, QuickNav },
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> = [];
@@ -436,19 +423,17 @@ export default class ClaimView extends Vue {
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?: GiveSummaryRecord;
giveDetails = null;
giverName = "";
issuerName = "";
isLoading = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showVeriClaimDump = false;
showDetails = false;
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
windowLocation = window.location.href;
windowLocation = window.location;
R = R;
yaml = yaml;
@@ -459,8 +444,7 @@ export default class ClaimView extends Vue {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = undefined;
this.isRegistered = false;
this.giveDetails = null;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
@@ -468,14 +452,19 @@ export default class ClaimView extends Vue {
}
async mounted() {
this.isLoading = true;
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
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();
this.isRegistered = settings.isRegistered || false;
this.allMyDids = await retrieveAccountDids();
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,
@@ -483,7 +472,7 @@ export default class ClaimView extends Vue {
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, this.activeDid);
await this.loadClaim(claimId, identity);
} else {
this.$notify(
{
@@ -499,8 +488,6 @@ export default class ClaimView extends Vue {
// 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;
this.isLoading = false;
}
// insert a space before any capital letters except the initial letter
@@ -532,6 +519,33 @@ export default class ClaimView extends Vue {
);
}
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(
@@ -542,14 +556,14 @@ export default class ClaimView extends Vue {
);
}
async loadClaim(claimId: string, userDid: string) {
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 serverUtil.getHeaders(userDid);
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/
@@ -589,7 +603,7 @@ export default class ClaimView extends Vue {
this.apiServer +
"/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await serverUtil.getHeaders(userDid);
const giveHeaders = await this.getHeaders(identity);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
@@ -607,12 +621,6 @@ export default class ClaimView extends Vue {
},
3000,
);
return;
}
// the logic already stops earlier if the claim doesn't exist but this helps with typechecking
if (!this.giveDetails) {
return;
}
this.urlForNewGive = "/gifted-details?";
@@ -653,26 +661,39 @@ export default class ClaimView extends Vue {
this.giveDetails.fulfillsHandleId
) {
this.urlForNewGive +=
"&offerId=" +
encodeURIComponent(this.giveDetails?.fulfillsHandleId as string);
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId);
}
if (this.giveDetails.fulfillsPlanHandleId) {
this.urlForNewGive +=
"&fulfillsProjectId=" +
"&projectId=" +
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
}
// retrieve the list of confirmers
const confirmerInfo = await libsUtil.retrieveConfirmerIdList(
this.apiServer,
claimId,
this.veriClaim.issuer,
userDid,
);
if (confirmerInfo) {
this.confirmerIdList = confirmerInfo.confirmerIdList;
this.confsVisibleToIdList = confirmerInfo.confsVisibleToIdList;
this.numConfsNotVisible = confirmerInfo.numConfsNotVisible;
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.";
@@ -726,7 +747,7 @@ export default class ClaimView extends Vue {
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
await this.getIdentity(this.activeDid),
this.apiServer,
this.axios,
);
@@ -747,7 +768,7 @@ export default class ClaimView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation.",
text: "There was a problem submitting the confirmation. See logs for more info.",
},
5000,
);
@@ -758,12 +779,24 @@ export default class ClaimView extends Vue {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
(this.$router as Router).push(route).then(async () => {
this.$router.push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
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)
@@ -781,28 +814,7 @@ export default class ClaimView extends Vue {
}
notifyWhyCannotConfirm() {
libsUtil.notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
this.veriClaim.claimType,
this.giveDetails,
this.activeDid,
this.confirmerIdList,
);
}
notifyWhyCannotConfirmBak() {
if (!this.isRegistered) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not Registered",
text: "Someone needs to register you before you can contribute.",
},
3000,
);
} else if (!isGiveAction(this.veriClaim)) {
if (!isGiveAction(this.veriClaim)) {
this.$notify(
{
group: "alert",
@@ -818,11 +830,11 @@ export default class ClaimView extends Vue {
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You already confirmed this claim.",
text: "You have already confirmed this claim.",
},
3000,
);
} else if (this.giveDetails?.issuerDid == this.activeDid) {
} else if (this.giveDetails.agentDid == this.activeDid) {
this.$notify(
{
group: "alert",
@@ -832,13 +844,13 @@ export default class ClaimView extends Vue {
},
3000,
);
} else if (serverUtil.containsHiddenDid(this.giveDetails?.fullClaim)) {
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because some people are hidden.",
text: "You cannot confirm this because it contains hidden identifiers.",
},
3000,
);
@@ -848,7 +860,7 @@ export default class ClaimView extends Vue {
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim. There are no other details, but we can help more if you contact us and send us screenshots.",
text: "You cannot confirm this claim.",
},
3000,
);
@@ -856,11 +868,10 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
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,
url: this.windowLocation.href,
});
}
}

View File

@@ -55,7 +55,7 @@
{{ new Date(record.issuedAt).toLocaleString() }}
</td>
<td class="p-1">
<span v-if="record.agentDid == contact?.did">
<span v-if="record.agentDid == contact.did">
<div class="font-bold">
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
@@ -71,7 +71,7 @@
</span>
</td>
<td class="p-1">
<span v-if="record.agentDid == contact?.did">
<span v-if="record.agentDid == contact.did">
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
</span>
<span v-else>
@@ -79,7 +79,7 @@
</span>
</td>
<td class="p-1">
<span v-if="record.agentDid != contact?.did">
<span v-if="record.agentDid != contact.did">
<div class="font-bold">
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
@@ -105,25 +105,25 @@
</template>
<script lang="ts">
import { AxiosError, AxiosRequestHeaders } from "axios";
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken, SimpleSigner } from "@/libs/crypto";
import {
AgreeVerifiableCredential,
createEndorserJwtVcFromClaim,
displayAmount,
getHeaders,
GiveSummaryRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "@/libs/endorserServer";
import { retrieveAccountCount } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class ContactAmountssView extends Vue {
@@ -138,15 +138,42 @@ export default class ContactAmountssView extends Vue {
displayAmount = displayAmount;
async beforeCreate() {
this.numAccounts = await retrieveAccountCount();
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = await accountsDB.accounts
.where("did")
.equals(activeDid)
.first();
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
async created() {
try {
const contactDid = (this.$route as Router).query["contactDid"] as string;
await db.open();
const contactDid = this.$route.query.contactDid as string;
this.contact = (await db.contacts.get(contactDid)) || null;
const settings = await retrieveSettingsForActiveAccount();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
@@ -165,21 +192,22 @@ export default class ContactAmountssView extends Vue {
err.userMessage ||
"There was an error retrieving your settings or contacts or gives.",
},
5000,
-1,
);
}
}
async loadGives(activeDid: string, contact: Contact) {
try {
const identity = await this.getIdentity(this.activeDid);
let result: Array<GiveSummaryRecord> = [];
const url =
this.apiServer +
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(this.activeDid) +
encodeURIComponent(identity.did) +
"&recipientDid=" +
encodeURIComponent(contact.did);
const headers = await getHeaders(activeDid);
const headers = await this.getHeaders(identity);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
result = resp.data.data;
@@ -196,7 +224,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
5000,
-1,
);
}
@@ -205,8 +233,8 @@ export default class ContactAmountssView extends Vue {
"/api/v2/report/gives?agentDid=" +
encodeURIComponent(contact.did) +
"&recipientDid=" +
encodeURIComponent(this.activeDid);
const headers2 = await getHeaders(activeDid);
encodeURIComponent(identity.did);
const headers2 = await this.getHeaders(identity);
const resp2 = await this.axios.get(url2, { headers: headers2 });
if (resp2.status === 200) {
result = R.concat(result, resp2.data.data);
@@ -223,7 +251,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: "Got an error retrieving your given time from the server.",
},
5000,
-1,
);
}
@@ -241,7 +269,7 @@ export default class ContactAmountssView extends Vue {
title: "Error With Server",
text: error as string,
},
5000,
-1,
);
}
}
@@ -261,44 +289,66 @@ export default class ContactAmountssView extends Vue {
object: origClaim,
};
const vcJwt: string = await createEndorserJwtVcFromClaim(
this.activeDid,
vcClaim,
);
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
},
};
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
// Create a signature using private key of identity
const identity = await this.getIdentity(this.activeDid);
if (identity.keys[0].privateKeyHex !== null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
const signer = await SimpleSigner(privateKeyHex);
const alg = undefined;
// Create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg,
issuer: identity.did,
signer: signer,
});
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success) {
record.amountConfirmed =
(origClaim.object?.amountOfThisGood as number) || 1;
}
} catch (error) {
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
// Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim";
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
try {
const resp = await this.axios.post(url, payload, { headers });
if (resp.data?.success) {
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
}
} else {
userMessage = error as string;
} catch (error) {
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
-1,
);
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Error With Server",
text: userMessage,
},
5000,
);
}
}
@@ -310,7 +360,7 @@ export default class ContactAmountssView extends Vue {
title: "Not Allowed",
text: "Only the recipient can confirm final receipt.",
},
5000,
-1,
);
}
}

View File

@@ -1,238 +0,0 @@
<template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="ContactEdit" class="p-6 max-w-3xl mx-auto">
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-4xl 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>
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h1>
</div>
<!-- Contact Name -->
<div class="mt-4 flex" data-testId="contactName">
<label
for="contactName"
class="block text-sm font-medium text-gray-700 mt-2"
>
Name
</label>
<input
type="text"
class="block w-full ml-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactName"
/>
</div>
<!-- Contact Notes -->
<div class="mt-4">
<label for="contactNotes" class="block text-sm font-medium text-gray-700">
Notes
</label>
<textarea
id="contactNotes"
rows="4"
class="block w-full mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactNotes"
></textarea>
</div>
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
type="text"
v-model="method.label"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
type="text"
v-model="method.type"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
@click="toggleDropdown(index)"
class="px-2 py-1 bg-gray-200 rounded-md"
>
<fa icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
@click="setMethodType(index, 'CELL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
CELL
</div>
<div
@click="setMethodType(index, 'EMAIL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
EMAIL
</div>
<div
@click="setMethodType(index, 'WHATSAPP')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
>
WHATSAPP
</div>
</div>
</div>
<input
type="text"
v-model="method.value"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button @click="removeContactMethod(index)" class="ml-2 text-red-500">
<fa icon="trash-can" class="fa-fw" />
</button>
</div>
<button @click="addContactMethod" class="mt-2">
<fa
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
/>
</button>
</div>
<!-- Save Button -->
<div class="mt-8 flex justify-between">
<button
class="px-4 py-2 bg-blue-500 text-white rounded-md"
@click="saveEdit"
>
Save
</button>
<button
class="ml-4 px-4 py-2 bg-slate-500 text-white rounded-md"
@click="$router.go(-1)"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation, Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact, ContactMethod } from "@/db/tables/contacts";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class ContactEditView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
contact: Contact = {
did: "",
name: "",
notes: "",
};
contactName = "";
contactNotes = "";
contactMethods: Array<ContactMethod> = [];
dropdownIndex: number | null = null;
AppString = AppString;
async created() {
const contactDid = (this.$route as RouteLocation).params.did;
const contact = await db.contacts.get(contactDid || "");
if (contact) {
this.contact = contact;
this.contactName = contact.name || "";
this.contactNotes = contact.notes || "";
this.contactMethods = contact.contactMethods || [];
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Contact Not Found",
text: "There is no contact with DID " + contactDid,
});
(this.$router as Router).push({ path: "/contacts" });
return;
}
}
addContactMethod() {
this.contactMethods.push({ label: "", type: "", value: "" });
}
removeContactMethod(index: number) {
this.contactMethods.splice(index, 1);
}
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
async saveEdit() {
// without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned."
const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods));
const contactMethods = contactMethodsObj.map((method: ContactMethod) =>
R.set(R.lensProp("type"), method.type.toUpperCase(), method),
);
if (!R.equals(contactMethodsObj, contactMethods)) {
this.contactMethods = contactMethods;
this.$notify(
{
group: "alert",
type: "warning",
title: "Contact Methods Updated",
text: "Note that some methods have been updated, such as uppercasing 'email' to 'EMAIL'. Save again if the changes are acceptable.",
},
15000,
);
return;
}
await db.contacts.update(this.contact.did, {
name: this.contactName,
notes: this.contactNotes,
contactMethods: contactMethods,
});
this.$notify({
group: "alert",
type: "success",
title: "Contact Saved",
text: "The contact info has been updated successfully.",
});
(this.$router as Router).push({
path: "/did/" + encodeURIComponent(this.contact.did),
});
}
}
</script>

View File

@@ -11,7 +11,8 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
Given by...
Give to Contacts
</h1>
</div>
@@ -65,21 +66,24 @@
</li>
</ul>
<GiftedDialog ref="customDialog" :toProjectId="projectId" />
<GiftedDialog ref="customDialog" :projectId="projectId" />
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { IIdentifier } from "@veramo/core";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { db, accountsDB } from "@/db/index";
import { Account, AccountsSchema } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { GiverReceiverInputInfo } from "@/libs/util";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import { GiverReceiverInputInfo } from "@/libs/endorserServer";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
@@ -90,15 +94,19 @@ export default class ContactGiftingView extends Vue {
activeDid = "";
allContacts: Array<Contact> = [];
apiServer = "";
description = "";
projectId = "";
prompt = "";
accounts: typeof AccountsSchema;
projectId = localStorage.getItem("projectId") || "";
async beforeCreate() {
accountsDB.open();
}
async created() {
try {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
// .orderBy("name") wouldn't retrieve any entries with a blank name
// .toCollection.sortBy("name") didn't sort in an order I understood
@@ -107,9 +115,7 @@ export default class ContactGiftingView extends Vue {
(a.name || "").localeCompare(b.name || ""),
);
this.projectId = (this.$route as Router).query["projectId"] || "";
this.prompt = (this.$route as Router).query["prompt"] ?? this.prompt;
localStorage.removeItem("projectId");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
@@ -123,12 +129,37 @@ export default class ContactGiftingView extends Vue {
err.message ||
"There was an error retrieving your settings or contacts.",
},
5000,
-1,
);
}
}
openDialog(giver?: GiverReceiverInputInfo) {
public async getIdentity(activeDid: string) {
await accountsDB.open();
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
const identity = JSON.parse(account?.identity || "null");
if (!identity) {
throw new Error(
"Attempted to load Give records with no identifier available.",
);
}
return identity;
}
public async getHeaders(identity: IIdentifier) {
const token = await accessToken(identity);
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
};
return headers;
}
openDialog(giver: GiverReceiverInputInfo) {
const recipient = this.projectId
? undefined
: { did: this.activeDid, name: "you" };
@@ -137,7 +168,6 @@ export default class ContactGiftingView extends Vue {
recipient,
undefined,
"Given by " + (giver?.name || "someone not named"),
this.prompt,
);
}
}

View File

@@ -1,392 +0,0 @@
<template>
<QuickNav selected="Contacts"></QuickNav>
<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-8">
Contact Import
</h1>
<div v-if="checkingImports" class="text-center">
<fa icon="spinner" class="animate-spin" />
</div>
<div v-else>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div>
<!-- Results List -->
<ul
v-if="contactsImporting.length > sameCount"
class="border-t border-slate-300"
>
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div></div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[
contact.did
]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else-if="contactsImporting.length > 0">
All those contacts are already in your list with the same information.
</p>
<div v-else>
There are no contacts in that import. If some were sent, try again to
get the full text and paste it. (Note that iOS cuts off data in text
messages.) Ask the person to send the data a different way, eg. email.
<div class="mt-4 text-center">
<textarea
v-model="inputJwt"
placeholder="Contact-import data"
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkContactJwt(inputJwt)"
/>
<br />
<button
@click="() => processContactJwt(inputJwt)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Check Import
</button>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { APP_SERVER, AppString, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact, ContactMethod } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
import {
capitalizeAndInsertSpacesBeforeCaps,
errorStringForLog,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { getContactJwtFromJwtUrl } from "@/libs/crypto";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
})
export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil;
R = R;
activeDid = "";
apiServer = "";
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
contactsImporting: Array<Contact> = []; // contacts from the import
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
contactDifferences: Record<
string,
Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
checkingImports = false;
inputJwt: string = "";
makeVisible = true;
sameCount = 0;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
// look for any imported contact array from the query parameter
const importedContacts = (this.$route as RouteLocationNormalizedLoaded)
.query["contacts"] as string;
if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts));
}
// look for a JWT after /contact-import/ in the window.location.pathname
const jwt = window.location.pathname.match(
/\/contact-import\/(ey.+)$/,
)?.[1];
if (jwt) {
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
// eslint-disable-next-line prettier/prettier
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
// decode the JWT
const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> =
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) {
// handle this single-contact JWT in the contacts page, better suited to single additions
(this.$router as Router).push({
name: "contacts",
query: { contactJwt: jwt },
});
}
if (contacts) {
await this.setContactsSelected(contacts);
} else {
// no contacts found so default message should be OK
}
}
if (
this.contactsImporting.length === 1 &&
R.isEmpty(this.contactsExisting)
) {
// if there is only one contact and it's new, then we will automatically import it
this.contactsSelected[0] = true;
this.importContacts(); // ... which routes to the contacts list
}
}
async setContactsSelected(contacts: Array<Contact>) {
this.contactsImporting = contacts;
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
await db.open();
const baseContacts = await db.contacts.toArray();
// set the existing contacts, keyed by DID, if they exist in contactsImporting
for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i];
const existingContact = baseContacts.find(
(contact) => contact.did === contactIn.did,
);
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const differences: Record<
string,
{
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
> = {};
Object.keys(contactIn).forEach((key) => {
// eslint-disable-next-line prettier/prettier
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
differences[key] = {
old: existingContact[key as keyof Contact],
new: contactIn[key as keyof Contact],
};
}
});
this.contactDifferences[contactIn.did] = differences;
if (R.isEmpty(differences)) {
this.sameCount++;
}
// don't automatically import previous data
this.contactsSelected[i] = false;
}
}
}
// check the contact-import JWT
async checkContactJwt(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("contact-import") ||
jwtInput.endsWith("contact-import/")
) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
},
5000,
);
}
}
// process the invite JWT and/or text message containing the URL with the JWT
async processContactJwt(jwtInput: string) {
this.checkingImports = true;
try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
// JWT format: { header, payload, signature, data }
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {
await this.setContactsSelected(payload.contacts);
} else {
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
}
} catch (error) {
const fullError = "Error importing contacts: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing the contact-import data.",
},
3000,
);
}
this.checkingImports = false;
}
async importContacts() {
this.checkingImports = true;
let importedCount = 0,
updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
const existingContact = this.contactsExisting[contact.did];
if (existingContact) {
await db.contacts.update(contact.did, contact);
updatedCount++;
} else {
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
// DataError: Failed to execute 'add' on 'IDBObjectStore': Evaluating the object store's key path yielded a value that is not a valid key.
await db.contacts.add(R.clone(contact));
importedCount++;
}
}
}
if (this.makeVisible) {
const failedVisibileToContacts = [];
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
if (contact) {
const visResult = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
true,
);
if (!visResult.success) {
failedVisibileToContacts.push(contact);
}
}
}
}
if (failedVisibileToContacts.length > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Visibility Error",
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
failedVisibileToContacts.length == 1 ? "" : "s"
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
},
-1,
);
}
}
this.checkingImports = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Imported",
text:
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
(updatedCount ? ` ${updatedCount} updated.` : ""),
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<QuickNav selected="Profile" />
<QuickNav selected="Profile"></QuickNav>
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
@@ -10,7 +10,7 @@
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 icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
@@ -24,24 +24,16 @@
>
<span class="text-red">Beware!</span>
You aren't sharing your name, so quickly
<br />
<span
@click="
() => $refs.userNameDialog.open((name) => (this.givenName = name))
"
<router-link
:to="{ name: 'new-edit-account' }"
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-1.5 py-1 rounded-md"
>
click here to set it for them.
</span>
</router-link>
</p>
</div>
<UserNameDialog ref="userNameDialog" />
<div
@click="onCopyUrlToClipboard()"
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="text-center"
>
<div @click="onCopyToClipboard()" v-if="activeDid" class="text-center">
<!--
Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
@@ -52,18 +44,8 @@
:dotsOptions="{ type: 'square' }"
class="flex justify-center"
/>
<span>
Click the QR code to copy your contact info to your clipboard.
</span>
</div>
<div v-else-if="activeDid" class="text-center">
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
<span @click="onCopyDidToClipboard()" class="text-blue-500">
Click here to copy your DID to your clipboard.
</span>
<span>
Then give it to them so they can paste it in their list of People.
</span>
<span> Click that QR to copy your contact URL to your clipboard. </span>
<div>Not scanning? Show it in pieces.</div>
</div>
<div class="text-center" v-else>
You have no identitifiers yet, so
@@ -90,33 +72,41 @@
<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";
import * as R from "ramda";
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 UserNameDialog from "@/components/UserNameDialog.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getContactJwtFromJwtUrl } from "@/libs/crypto";
import {
generateEndorserJwtUrlForAccount,
deriveAddress,
getContactPayloadFromJwtUrl,
nextDerivationPath,
SimpleSigner,
} from "@/libs/crypto";
import {
CONTACT_URL_PREFIX,
ENDORSER_JWT_URL_LOCATION,
isDid,
register,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "@/libs/crypto/vc";
import { retrieveAccountMetadata } from "@/libs/util";
import { Buffer } from "buffer/";
@Component({
components: {
QrcodeStream,
QRCodeVue3,
QuickNav,
UserNameDialog,
},
})
export default class ContactQRScanShow extends Vue {
@@ -129,30 +119,56 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false;
qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
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;
!!settings?.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
const account = await retrieveAccountMetadata(this.activeDid);
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const account = R.find((acc) => acc.did === this.activeDid, accounts);
if (account) {
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3
const identity = await this.getIdentity(this.activeDid);
const publicKeyHex = identity.keys[0].publicKeyHex;
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl,
false,
);
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
Buffer.from(nextPublicEncKeyHash).toString("base64");
const contactInfo = {
iat: Date.now(),
iss: this.activeDid,
own: {
name:
(settings?.firstName || "") +
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
publicEncKey,
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
profileImageUrl: settings?.profileImageUrl,
registered: settings?.isRegistered,
},
};
const alg = undefined;
const privateKeyHex: string = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex);
// create a JWT for the request
const vcJwt: string = await didJwt.createJWT(contactInfo, {
alg: alg,
issuer: identity.did,
signer: signer,
});
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
this.qrValue = viewPrefix + vcJwt;
}
}
@@ -168,6 +184,23 @@ export default class ContactQRScanShow extends Vue {
);
}
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
@@ -179,8 +212,8 @@ export default class ContactQRScanShow extends Vue {
if (url) {
let newContact: Contact;
try {
const jwt = getContactJwtFromJwtUrl(url);
if (!jwt) {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
{
group: "alert",
@@ -192,9 +225,8 @@ export default class ContactQRScanShow extends Vue {
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
newContact = {
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49
did: payload.iss as string,
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
@@ -249,7 +281,7 @@ export default class ContactQRScanShow extends Vue {
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
@@ -257,7 +289,7 @@ export default class ContactQRScanShow extends Vue {
},
onNo: async (stopAsking: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
@@ -361,7 +393,7 @@ export default class ContactQRScanShow extends Vue {
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an 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) {
@@ -401,12 +433,12 @@ export default class ContactQRScanShow extends Vue {
);
}
onCopyUrlToClipboard() {
onCopyToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.qrValue)
.then(() => {
// console.log("Contact URL:", this.qrValue);
console.log("Contact URL:", this.qrValue);
this.$notify(
{
group: "alert",
@@ -418,22 +450,5 @@ export default class ContactQRScanShow extends Vue {
);
});
}
onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(this.activeDid)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
},
5000,
);
});
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 id="ViewHeading" class="text-lg text-center font-light relative px-7">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
@@ -19,115 +19,37 @@
</div>
<!-- Identity Details -->
<div
v-if="!!contactFromDid"
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 mb-4">
<div>
<h2 class="text-xl font-semibold">
{{ contactFromDid?.name || "(no name)" }}
<router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link>
{{
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
.displayName
}}
</h2>
<button
@click="showDidDetails = !showDidDetails"
class="ml-2 mr-2 mt-4"
>
Details
<fa v-if="showDidDetails" icon="chevron-down" class="text-blue-400" />
<fa v-else icon="chevron-right" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
v-if="showDidDetails"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ contactYaml }}</pre
>
<span class="mt-2 text-xl font-semibold break-words">
{{ viewingDid }}
</span>
</div>
<div class="flex justify-center mt-4">
<span
v-if="contactFromDid?.profileImageUrl"
class="flex justify-between"
>
<span v-if="contact?.profileImageUrl" class="flex justify-between">
<EntityIcon
:icon-size="96"
:profileImageUrl="contactFromDid?.profileImageUrl"
:profileImageUrl="contact?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = contactFromDid?.profileImageUrl"
@click="showLargeIdenticonUrl = contact?.profileImageUrl"
/>
</span>
</div>
<div class="flex justify-between mt-4">
<div class="flex items-center">
<div v-if="activeDid" class="flex justify-between">
<div>
<button
v-if="
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contactFromDid, false)"
title="They can see you"
>
<fa icon="eye" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contactFromDid, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<button
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contactFromDid)"
title="Check Visibility"
v-if="contactFromDid?.did !== activeDid"
>
<fa icon="rotate" class="fa-fw" />
</button>
</div>
<button
@click="confirmRegister(contactFromDid)"
class="text-sm 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 ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
v-if="contactFromDid?.did !== activeDid"
title="Registration"
>
<fa
v-if="contactFromDid?.registered"
icon="person-circle-check"
class="fa-fw"
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
</button>
</div>
<button
@click="confirmDeleteContact(contactFromDid)"
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Delete"
>
<fa icon="trash-can" class="fa-fw" />
</button>
</div>
<div v-if="!contactFromDid?.profileImageUrl">
<div>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 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
@@ -150,14 +72,6 @@
</div>
</div>
</div>
<div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<!-- !contactFromDid -->
<div>
<h2 class="text-xl font-semibold">
{{ isMyDid ? "You" : "(no name)" }}
</h2>
</div>
</div>
<!-- Loading Animation -->
<div
@@ -168,9 +82,7 @@
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
<div class="text-l font-bold text-center">
Claims That Involve {{ isMyDid ? "You" : "Them" }}
</div>
<div class="text-l font-bold text-center">Claims That Involve Them</div>
</div>
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
@@ -193,7 +105,10 @@
{{ claimDescription(claim) }}
</span>
<span class="col-span-1">
<a @click="onClickLoadClaim(claim.id)" class="cursor-pointer">
<a
@click="onClickLoadClaim(claim.handleId)"
class="cursor-pointer"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</span>
@@ -206,38 +121,31 @@
v-if="!isLoading && claims.length === 0"
class="flex justify-center mt-4"
>
<span v-if="isMyDid">You have no claims yet.</span>
<span v-else>They are in no claims visible to you.</span>
<span>They Are in No Claims Visible to You</span>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { accountsDB, db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { BoundingBox } from "@/db/tables/settings";
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
getHeaders,
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
register,
setVisibilityUtil,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
@@ -251,19 +159,14 @@ import EntityIcon from "@/components/EntityIcon.vue";
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
yaml = yaml;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contactFromDid?: Contact;
contactYaml = "";
claims: Array<GenericCredWrapper> = [];
contact?: Contact;
hitEnd = false;
isLoading = false;
isMyDid = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showDidDetails = false;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
viewingDid?: string;
@@ -273,37 +176,55 @@ export default class DIDView extends Vue {
displayAmount = displayAmount;
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
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);
let showDid = pathParam;
if (!showDid) {
showDid = this.activeDid;
if (showDid) {
this.$notify(
{
group: "alert",
type: "toast",
title: "Your Info",
text: "No user was specified so showing your info.",
},
3000,
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.",
);
}
}
if (showDid) {
this.viewingDid = decodeURIComponent(showDid);
this.contactFromDid = await db.contacts.get(this.viewingDid);
if (this.contactFromDid) {
this.contactYaml = yaml.dump(this.contactFromDid);
}
await this.loadClaimsAbout();
const allAccountDids = await libsUtil.retrieveAccountDids();
this.isMyDid = allAccountDids.includes(this.viewingDid);
headers["Authorization"] = "Bearer " + (await accessToken(identity));
} else {
// it's OK without auth... we just won't get any identifiers
}
return headers;
}
/**
@@ -316,133 +237,6 @@ export default class DIDView extends Vue {
}
}
// prompt with confirmation if they want to delete a contact
confirmDeleteContact(contact: Contact) {
let message =
"Are you sure you want to remove " +
libsUtil.nameForContact(contact, false) +
" from your contact list?";
if (contact.seesMe) {
message +=
" Note that they can see your activity, so if you want to hide your activity from them then you should do that first.";
}
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete",
text: message,
onYes: async () => {
await this.deleteContact(contact);
},
},
-1,
);
}
async deleteContact(contact: Contact) {
await db.open();
await db.contacts.delete(contact.did);
this.$notify(
{
group: "alert",
type: "success",
title: "Deleted",
text: "Contact has been removed.",
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
// confirm to register a new contact
async confirmRegister(contact: Contact) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text:
"Are you sure you want to register " +
libsUtil.nameForContact(this.contactFromDid, false) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?",
onYes: async () => {
await this.register(contact);
},
},
-1,
);
}
// note that this is also in ContactView.vue
async register(contact: Contact) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
await 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.";
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,
);
}
}
public async loadClaimsAbout() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
@@ -461,7 +255,7 @@ export default class DIDView extends Vue {
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix,
{
method: "GET",
headers: await getHeaders(this.activeDid),
headers: await this.buildHeaders(),
},
);
@@ -494,7 +288,7 @@ export default class DIDView extends Vue {
title: "Error",
text: e.userMessage || "There was a problem retrieving claims.",
},
3000,
-1,
);
} finally {
this.isLoading = false;
@@ -505,7 +299,7 @@ export default class DIDView extends Vue {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
(this.$router as Router).push(route);
this.$router.push(route);
}
public claimAmount(claim: GenericVerifiableCredential) {
@@ -539,168 +333,5 @@ export default class DIDView extends Vue {
claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || "";
}
// note that this is also in ContactView.vue
async confirmSetVisibility(contact: Contact, visibility: boolean) {
const visibilityPrompt = visibility
? "Are you sure you want to make your activity visible to them?"
: "Are you sure you want to hide all your activity from them?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Visibility",
text: visibilityPrompt,
onYes: async () => {
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
},
},
-1,
);
}
// note that this is also in ContactView.vue
async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) {
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
(contact.name || "That user") +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
}
return true;
} else {
console.error("Got strange result from setting visibility:", result);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Visibility",
text: message,
},
5000,
);
return false;
}
}
// note that this is also in ContactView.vue
async checkVisibility(contact: Contact) {
const url =
this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" +
encodeURIComponent(contact.did);
const headers = await getHeaders(this.activeDid);
if (!headers["Authorization"]) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Identity",
text: "There is no identity to use to check visibility.",
},
3000,
);
return;
}
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const visibility = resp.data;
contact.seesMe = visibility;
//console.log("Visi check:", visibility, contact.seesMe, contact.did);
await db.contacts.update(contact.did, { seesMe: visibility });
this.$notify(
{
group: "alert",
type: "info",
title: "Visibility Refreshed",
text:
libsUtil.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
} else {
console.error("Got bad server response checking visibility:", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Checking Visibility",
text: message,
},
5000,
);
}
} catch (err) {
console.error("Caught error from request to check visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Checking Visibility",
text: "Check connectivity and try again.",
},
3000,
);
}
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

File diff suppressed because it is too large Load Diff

601
src/views/GiftedDetails.vue Normal file
View File

@@ -0,0 +1,601 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<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="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<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">
<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"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
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] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<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" />
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !givenToUser"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<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 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 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center 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"
@click="confirm"
>
Sign &amp; Send
</button>
<button
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"
@click="cancel"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
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, getPlanFromCache } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { accessToken } from "@/libs/crypto";
@Component({
components: {
ImageMethodDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
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.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.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.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 =
(this.$route.query.imageUrl as string) ||
localStorage.getItem("imageUrl") ||
"";
// 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();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
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);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-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() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
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();
}
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "GiveAction");
}
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,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
localStorage.removeItem("imageUrl");
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);
localStorage.removeItem("imageUrl");
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,
);
}
}
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
2000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.description && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
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
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
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,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
projectId,
this.offerId,
this.isTrade,
this.imageUrl,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
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 errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1,
);
}
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

View File

@@ -1,919 +0,0 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<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="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<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">
<span>
From
{{
providedByProject
? providerProjectName
: providedByGiver
? giverName
: "someone not named"
}}
</span>
<br />
<span>
to
{{
givenToProject
? fulfillsProjectName
: givenToRecipient
? recipientName
: "someone not named"
}}</span
>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
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] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex justify-center mt-4" data-testId="imagery">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank">
<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" />
<div class="mt-4 flex justify-between gap-2">
<!-- First Column for Giver -->
<div class="flex-grow border border-slate-400 p-2 rounded-md">
<div class="flex">
<input
v-if="giverDid && !providedByProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByGiver"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
giverDid
? "This was provided by " + giverName + "."
: "No named individual gave."
}}
</label>
<fa
v-if="!giverDid || providedByProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserOfGiver()"
/>
</div>
<div class="flex">
<input
v-if="providerProjectId && !providedByGiver"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByProject"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
providerProjectId
? "This was provided by " + providerProjectName + "."
: "This was not provided by a project."
}}
</label>
<fa
v-if="!providerProjectId || providedByGiver"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserOfProvidingProject()"
/>
</div>
</div>
<div class="flex-shrink flex justify-center items-center">
<fa icon="arrow-right" class="fa-fw h-7" />
</div>
<!-- Third Column for Recipient -->
<div class="flex-grow border border-slate-400 p-2 rounded-md">
<div class="flex">
<input
v-if="recipientDid && !givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToRecipient"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName + "."
: "No individual benefitted."
}}
</label>
<fa
v-if="!recipientDid || givenToProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserOfRecipient()"
/>
</div>
<div class="flex">
<input
v-if="fulfillsProjectId && !givenToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<fa
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
/>
<label class="text-sm mt-1">
{{
fulfillsProjectId
? "This was given to " + fulfillsProjectName + ". "
: "No project benefitted."
}}
</label>
<fa
v-if="!fulfillsProjectId || givenToRecipient"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@click="notifyUserFulfillsProject()"
/>
</div>
</div>
</div>
<div class="mt-8 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
<div v-if="showGeneralAdvanced" class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructGiveParam(),
},
}"
class="text-blue-500"
>
Edit Raw Data
</router-link>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center 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"
@click="confirm"
>
Sign &amp; Send
</button>
<button
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"
@click="cancel"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
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, retrieveSettingsForActiveAccount } from "@/db/index";
import {
createAndSubmitGive,
didInfo,
editAndSubmitGive,
GenericCredWrapper,
getHeaders,
getPlanFromCache,
GiveVerifiableCredential,
hydrateGive,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { retrieveAccountDids } from "@/libs/util";
@Component({
components: {
ImageMethodDialog,
QuickNav,
TopMessage,
},
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
description = "";
destinationPathAfter = "";
fulfillsProjectId = "";
fulfillsProjectName = "a project";
givenToProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
givenToRecipient = false; // basically static, based on input; if we allow changing then let's fix things (see below)
giverDid = "";
giverName = "";
hideBackButton = false;
imageUrl = "";
isTrade = false;
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
providerProjectId = "";
providerProjectName = "a project";
providedByProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
providedByGiver = false; // basically static, based on input; if we allow changing then let's fix things (see below)
recipientDid = "";
recipientName = "";
showGeneralAdvanced = false;
unitCode = "HUR";
libsUtil = libsUtil;
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
) as GenericCredWrapper<GiveVerifiableCredential>)
: undefined;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
);
}
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.description =
(this.$route as Router).query["description"] ||
this.prevCredToEdit?.claim?.description ||
this.description;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.giverDid = ((this.$route as Router).query["giverDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.giverDid) as string;
this.giverName =
((this.$route as Router).query["giverName"] as string) || "";
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any offer ID
const fulfills = this.prevCredToEdit?.claim?.fulfills;
const fulfillsArray = Array.isArray(fulfills)
? fulfills
: fulfills
? [fulfills]
: [];
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer");
this.offerId = ((this.$route as Router).query["offerId"] ||
offer?.identifier ||
this.offerId) as string;
// find any fulfills project ID
const fulfillsProject = fulfillsArray.find(
(rec) => rec["@type"] === "PlanAction",
);
// eslint-disable-next-line prettier/prettier
this.fulfillsProjectId =
((this.$route as Router).query["fulfillsProjectId"] ||
fulfillsProject?.identifier ||
this.fulfillsProjectId) as string;
// find any provider project ID
const provider = this.prevCredToEdit?.claim?.provider;
const providerArray = Array.isArray(provider)
? provider
: provider
? [provider]
: [];
const providerProject = providerArray.find(
(rec) => rec["@type"] === "PlanAction",
);
this.providerProjectId = ((this.$route as Router).query[
"providerProjectId"
] ||
providerProject?.identifier ||
this.providerProjectId) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.prevCredToEdit?.claim?.object?.unitCode ||
this.unitCode) as string;
this.imageUrl =
((this.$route as Router).query["imageUrl"] as string) ||
this.prevCredToEdit?.claim?.image ||
localStorage.getItem("imageUrl") ||
this.imageUrl;
// 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 as Router).query["shareTitle"]) {
this.description =
((this.$route as Router).query["shareTitle"] as string) +
(this.description ? "\n" + this.description : "");
}
if ((this.$route as Router).query["shareText"]) {
this.description =
(this.description ? this.description + "\n" : "") +
((this.$route as Router).query["shareText"] as string);
}
if ((this.$route as Router).query["shareUrl"]) {
this.imageUrl = (this.$route as Router).query["shareUrl"] as string;
}
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
if (
(this.giverDid && !this.giverName) ||
(this.recipientDid && !this.recipientName)
) {
const allContacts = await db.contacts.toArray();
const allMyDids = await retrieveAccountDids();
if (this.giverDid && !this.giverName) {
this.giverName = didInfo(
this.giverDid,
this.activeDid,
allMyDids,
allContacts,
);
}
if (this.recipientDid && !this.recipientName) {
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.fulfillsProjectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
// these should be functions but something's wrong with the syntax in the <> conditional
this.providedByProject = !!this.providerProjectId;
this.providedByGiver = !this.providedByProject && !!this.giverDid;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
if (this.fulfillsProjectId) {
// console.log("Getting project name from cache", this.fulfillsProjectId);
const fulfillsProject = await getPlanFromCache(
this.fulfillsProjectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.fulfillsProjectName = fulfillsProject?.name
? `the project "${fulfillsProject.name}"`
: "a project";
}
if (this.providerProjectId) {
// console.log("Getting project name from cache", this.providerProjectId);
const providerProject = await getPlanFromCache(
this.providerProjectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.providerProjectName = providerProject?.name
? `the project "${providerProject.name}"`
: "a project";
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
(this.$router as Router).back();
}
openImageDialog() {
(this.$refs.imageDialog as ImageMethodDialog).open((imgUrl) => {
this.imageUrl = imgUrl;
}, "GiveAction");
}
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 headers = await getHeaders(this.activeDid);
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
{ headers },
);
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,
);
// keep the imageUrl in localStorage so the user can try again if they want
return;
}
localStorage.removeItem("imageUrl");
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("Weird: the image was already deleted.", error);
localStorage.removeItem("imageUrl");
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,
);
}
}
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a give.",
},
2000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.description && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordGive();
}
notifyUserOfGiver() {
if (!this.giverDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Go To The Contacts Page",
text: "To assign a giver, you must open this page from a contact.",
},
3000,
);
} else {
this.$notify(
{
group: "alert",
type: "warning",
title: "Unavailable",
text: "You cannot assign both a giver and a project.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Go To The Contacts Page",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
);
} else {
// must be because givenToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Unavailable",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
);
}
}
notifyUserOfProvidingProject() {
// we're here because they clicked and either there is no provider project or there is a giver chosen
if (!this.providerProjectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Go To The Project Page",
text: "To select a project as a provider, you must open this page through a project.",
},
3000,
);
} else {
// no providing project was chosen
this.$notify(
{
group: "alert",
type: "warning",
title: "Unavailable",
text: "You cannot select both a giving project and person.",
},
3000,
);
}
}
notifyUserFulfillsProject() {
// we're here because they clicked and either there is no fulfills project or there is a recipient chosen
if (!this.fulfillsProjectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Go To The Project Page",
text: "To assign to a project, you must open this page through a project.",
},
3000,
);
} else {
// no fulfills project was chosen
this.$notify(
{
group: "alert",
type: "warning",
title: "Unavailable",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
/**
*
* @param giverDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordGive() {
try {
const giverDid = this.providedByGiver ? this.giverDid : undefined;
const recipientDid = this.givenToRecipient
? this.recipientDid
: undefined;
const fulfillsProjectId = this.givenToProject
? this.fulfillsProjectId
: undefined;
let result;
if (this.prevCredToEdit) {
// don't create from a blank one in case some properties were set from a different interface
result = await editAndSubmitGive(
this.axios,
this.apiServer,
this.prevCredToEdit,
this.activeDid,
giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
);
} else {
result = await createAndSubmitGive(
this.axios,
this.apiServer,
this.activeDid,
giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
);
}
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
},
3000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
5000,
);
}
}
constructGiveParam() {
const giverDid = this.providedByGiver ? this.giverDid : undefined;
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const fulfillsProjectId = this.givenToProject
? this.fulfillsProjectId
: undefined;
const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential,
giverDid,
recipientDid,
this.description,
parseFloat(this.amountInput),
this.unitCode,
fulfillsProjectId,
this.offerId,
this.isTrade,
this.imageUrl,
this.providerProjectId,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(giveClaim);
return claimStr;
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
7000,
);
}
}
</script>

View File

@@ -1,68 +0,0 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- 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-8">
Notification Types
</h1>
</div>
<!-- eslint-disable prettier/prettier -->
<div>
<p>There are two types of notifications:</p>
<h2 class="text-xl font-semibold mt-4">Reminder Notifications</h2>
<div>
<p>
The Reminder Notification will be sent to you daily with a specific message,
at whatever time you choose. Use it to remind
yourself to act, for example: pause and consider who has given you
something, so you can record thanks in here.
</p>
<p>
This is a reliable message, but it doesn't contain any details about
activity that might be especially interesting to you.
</p>
</div>
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2>
<div>
<p>
The New Activity Notification will be sent to you when there is new, relevant activity for you.
It will only trigger if something involves you or a project of interest; it will not
bug you for other, general activity.
</p>
<p>
This type is not as reliable as a Reminder Notification because mobile devices often suppress
such notifications to save battery. (If you want to quickly check for relevant activity daily,
use the Reminder Notification and open the app and look for a large green button that points out new
activity that is personal to you. We are working on other ways to notify you more
reliably -- <router-link class="text-blue-500" to="/help">go here to follow us or contact us</router-link>.)
</p>
</div>
</div>
<!-- eslint-enable -->
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class HelpNotificationTypesView extends Vue {}
</script>

View File

@@ -39,15 +39,6 @@
</p>
</div>
<h2 class="text-xl font-semibold mt-4">Android Users</h2>
<div>
<p>
Note that you may not receive notifications when the app is in the
background. When you're done working, close the app, and then you'll
get the reminder notifications.
</p>
</div>
<h2 class="text-xl font-semibold mt-4">
If this app doesn't support notifications...
<!-- Note that that exact verbiage shows in a message elsewhere. -->
@@ -75,7 +66,6 @@
<button class="text-blue-500" @click="showNotificationChoice()">
Click here.
</button>
<PushNotificationPermission ref="pushNotificationPermission" />
</p>
</div>
@@ -194,18 +184,14 @@
<h2 class="text-xl font-semibold mt-4">Reinstall</h2>
<div>
<p>
If all else fails, it's best to start over.
If all else fails, uninstall the app, ensure all the browser tabs with
it are closed, and clear out caches and storage.
</p>
<p>
Of course, you'll want to back up all your data first -- all seeds as
well as the contacts & settings -- on the Profile
well as the contacts & settings -- on the Account
<fa icon="circle-user" /> page.
</p>
<p>
Here are instructions to uninstall the app and clear out caches and storage.
Note that you should first ensure check that the browser tabs with Time Safari are closed.
(If any are open then that will interfere with your refresh.)
</p>
<ul class="ml-4 list-disc">
<li>
Clear cache.
@@ -309,12 +295,9 @@ import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { DIRECT_PUSH_TITLE, sendTestThroughPushServer } from "@/libs/util";
import PushNotificationPermission from "@/components/PushNotificationPermission.vue";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { sendTestThroughPushServer } from "@/libs/util";
@Component({ components: { PushNotificationPermission, QuickNav } })
@Component({ components: { QuickNav } })
export default class HelpNotificationsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -322,8 +305,8 @@ export default class HelpNotificationsView extends Vue {
async mounted() {
try {
const registration = await navigator.serviceWorker?.ready;
const fullSub = await registration?.pushManager.getSubscription();
const registration = await navigator.serviceWorker.ready;
const fullSub = await registration.pushManager.getSubscription();
this.subscriptionJSON = fullSub?.toJSON();
} catch (error) {
console.error("Mount error:", error);
@@ -331,10 +314,10 @@ export default class HelpNotificationsView extends Vue {
}
alertWebPushSubscription() {
// console.log(
// "Web push subscription:",
// JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
// );
console.log(
"Web push subscription:",
JSON.parse(JSON.stringify(this.subscriptionJSON)), // gives more info than plain console logging
);
alert(JSON.stringify(this.subscriptionJSON));
}
@@ -348,7 +331,7 @@ export default class HelpNotificationsView extends Vue {
// Note that this exact verbiage shows in help text.
text: "You must enable notifications before testing the web push.",
},
5000,
-1,
);
return;
}
@@ -365,7 +348,7 @@ export default class HelpNotificationsView extends Vue {
"Check your device for the test web push message" +
(skipFilter ? "." : " if there are new items in your feed."),
},
5000,
-1,
);
} catch (error) {
console.error("Got an error sending test notification:", error);
@@ -376,14 +359,14 @@ export default class HelpNotificationsView extends Vue {
title: "Error Sending Test",
text: "Got an error sending the test web push notification.",
},
5000,
-1,
);
}
}
showTestNotification() {
const TEST_NOTIFICATION_TITLE = "It Worked";
navigator.serviceWorker?.ready
navigator.serviceWorker.ready
.then((registration) => {
return registration.showNotification(TEST_NOTIFICATION_TITLE, {
body: "This is your test notification.",
@@ -409,25 +392,20 @@ export default class HelpNotificationsView extends Vue {
title: "Failed",
text: "Got an error sending a notification.",
},
5000,
-1,
);
});
}
showNotificationChoice() {
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => {
if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingReminderMessage: message,
notifyingReminderTime: timeText,
});
this.notifyingReminder = true;
this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText;
}
this.$notify(
{
group: "modal",
type: "notification-permission",
title: "", // unused, only here to satisfy type check
text: "", // unused, only here to satisfy type check
},
-1,
);
}
}

View File

@@ -12,91 +12,45 @@
</h1>
</div>
<p>
To invite someone the easiest way, send them a link that you generate from
this page:
<router-link
:to="{ name: 'invite-one' }"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
<fa icon="envelope-open-text" class="fa-fw text-xl"
/></router-link>
</p>
<p>Then watch that page to see when they accept their invite.</p>
<p>
(That page is also reachable from the Contacts <fa icon="users" /> page
though the invitation <fa icon="envelope-open-text" /> icon.)
</p>
<h1 class="mt-4 font-bold text-xl">Next Steps</h1>
Although not totally necessary, backups are important to understand.
<div class="ml-4">
<h1 class="font-bold text-xl">Without a backup, you can lose data.</h1>
<div>
<p>
Exporting backups (from the Account <fa icon="circle-user" /> screen)
is important for the case where they lose their device. This is
especially true for the Identifier Seed: that is theirs and and theirs
alone, and currently nobody else can recover it if they lose it. The
good thing is that anyone can create a new account and simply inform
their network of their new ID.
</p>
</div>
</div>
<h1 class="mt-4 font-bold text-xl">Advanced</h1>
The following are optional steps for even more functionality.
<!-- eslint-disable prettier/prettier -->
<div class="ml-4">
<h1 class="font-bold text-xl">Add Contact & Register</h1>
<p>
You share even more information such as your picture and name when
you share with your QR code at these links: <fa icon="qrcode" />
</p>
<p>
Scanning
those with your cameras will automatically register people and add them
to each other's contact lists.
</p>
<p>
The following are more detailed manual steps:
</p>
<div>
<p>
1) Have them follow their yellow prompts.
</p>
<p>
2) Scan their QR, or have them tap on it to copy their info and send it to you.
Then you can add them to your Contacts <fa icon="users" />
</p>
<p>
3) You can register them at their info page <fa icon="circle-info" />
and click on the register button <fa icon="person-circle-question" />
</p>
<p>
4) Add yourself to their Contacts <fa icon="users" />
</p>
</div>
<h1 class="font-bold text-xl">Install</h1>
<div>
<p>
Have them visit TimeSafari.app in a browser, preferably Chrome or Safari,
and then look for the "Install" selection which adds this app to their desktop.
This enables other things, like the ability to "share" a photo from their
device directly to Time Safari, and it makes notifications more reliable.
1) Have them visit TimeSafari.app in a browser, preferably Chrome or Safari.
</p>
<p>
2) Have them "Install" the site to their desktop.
</p>
</div>
<h1 class="font-bold text-xl">Add Contact & Register</h1>
<div>
<p>
3) Have them follow their yellow prompts.
</p>
<p>
4) Add them to your contacts <fa icon="users" />
</p>
<p>
5) Register them <fa icon="person-circle-question" />
</p>
<p>
6) Add yourself to their contacts <fa icon="users" />
</p>
</div>
<h1 class="font-bold text-xl">Enable Notifications</h1>
<div>
<p>
Enable notifications from the Account page <fa icon="circle-user" />.
Those notifications might show up on the device depending on your settings.
For the most reliable habits, set an alarm or do some other ritual to record gratitude every day.
7) Enable notifications from <fa icon="circle-user" />
</p>
</div>
<h1 class="font-bold text-xl">Discuss Backups</h1>
<div>
<p>
8) Exporting backups <fa icon="circle-user" /> are important if they lose their phone --- especially for the Identifier Seed!
</p>
</div>

View File

@@ -21,205 +21,52 @@
</h1>
</div>
<!-- eslint-disable prettier/prettier max-len -->
<!-- eslint-disable prettier/prettier -->
<div>
<p>
This app focuses on gifts & gratitude, using them to build cool things together with your network.
</p>
<p class="ml-4">
If you'd like to see the page-by-page help,
<span
@click="unsetFinishedOnboarding()"
class="text-blue-500 cursor-pointer"
>click here</span>.
This app is a window into data that you and your friends own, focused on
gifts and collaboration.
</p>
<h2 class="text-xl font-semibold">What is the idea here?</h2>
<p>
We are building networks of people who want to grow good society from the ground up, using modern
technology that connects people peer-to-peer.
First of all, let's showcase gratitude: see what people have given, and recognize
gifts you've seen. This is done in a way that leaves a permanent record -- one that
came from you, and one that the recipient can prove it was for them. This can be
We are building networks of people who want to grow a giving society.
First of all, you can see what people have given, and also recognize
gifts you've seen, in a way that leaves a permanent record -- one that
came from you, and the recipient can prove it was for them. This is
personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and they can selectively show off their contributions
confirmation of activity, and selectively show off their contributions
and network.
</p>
<p class="mt-2">
With this, you highlight giving and you also offer help --
which could be conditional on others' contributions, too.
<p>
You highlight giving and also offer help to ideas -- which could be
conditional on others' willingness to help, too.
You can record your own ideas and invite others to collaborate.
It's a way to organize & build with the resource that everyone has in equal amounts: time.
</p>
<p class="mt-2">
Note that your personal data is safe: your ID is only shared with those you allow. Neither
your name nor your contacts' names are shared with anyone -- even our servers --
though you can explicitly share it with other individuals if you choose.
<p>
This app uses the power of cryptography to build a reputation, recording
activity that you can share at your discretion. You put some activity
public, but these services don't share your ID with others without explicit consent.
This is in contrast to Meta and Google, who hold
your data and allow you use it while they manage sharing...
those services are useful but they have the control, whereas this app gives you the control.
</p>
<h2 class="text-xl font-semibold">I want to know more because...</h2>
<ul class="list-disc list-outside ml-4">
<li class="p-2">
<div @click="showAlpha = !showAlpha" class="text-blue-500">... I'm a member of Alpha chat.</div>
<div v-if="showAlpha">
<p>
This is a project for public benefit. You are invited to add your gratitude
and propose projects on a distributable ledger.
</p>
<p>
The underlying data is on a merkle tree with each verifiable claim, signature and all.
The chain includes individual IDs for discovery & visibility, so not all data is distributed -- yet.
The goal is to eventually distribute the data on people's devices with their chosen network,
where anyone could host their own chain of provenance if they choose.
The formats follow standard schemas (eg. schema.org) to encourage interoperability.
We're currently at the beginning phase where we're trusting the server to keep IDs private.
It's all open-source, and we expect to have a professional audit someday.
</p>
<p>
A person's network of contacts is similar: the server currently knows some of the links between people
to allow discovery and visibility. However, even that will be manageable on personal devices someday.
</p>
<p>
There are no tokens to maintain the chain: the purpose is to create software that communities
and activists can easily join and use. We're betting that this is a case where network
participants have the motivation to run the software. The protocol is meant to be lightweight enough that
non-technical people can run it on inexpensive devices they already own. There may be cases for
MPC or ZKP in the future when they are more widespread and standard,
but our preference is to engineer as simply as possible with "white-magic" cryptography
over those "black-magic" functions.
</p>
<p>
Let's make real distributed computing and shared data happen, starting with our own small networks.
</p>
<p>
... and exemplify the fun along the way.
</p>
</div>
</li>
<li class="p-2">
<div @click="showGroup = !showGroup" class="text-blue-500">... I want to find a group I'll enjoy working with.</div>
<div v-if="showGroup">
<p>
This app encourages people to offer small bits of time to one another. It's a way to
run experiments with other people... tests of working together, which can start small
and easy but build into cooperation with people who are like-minded and who work well together.
</p>
<p>
Search the projects and place an offer on an interesting one
-- or create your own project and see who offers to help.
After your first experiment, you can give and get confirmation about the work, which you might choose
to show to future contacts.
</p>
</div>
</li>
<li class="p-2">
<div @click="showCommunity = !showCommunity" class="text-blue-500">... I want to participate in community projects.</div>
<div v-if="showCommunity">
<p>
These are mostly at the beginning stages, so any of them will appreciate your offers that show interest.
In fact, your offers can include your preferences, which give the project owners indications of how to proceed.
</p>
<p>
Search through the projects for issues of interest, locally as well as globally.
If you don't see any projects that interest you, create your own and see what kind of offers you get.
</p>
</div>
</li>
<li class="p-2">
<div @click="showVerifiable = !showVerifiable" class="text-blue-500">... I want to build with verifiable, private data.</div>
<div v-if="showVerifiable">
<p>
Make your claims and get others to confirm them. Then you can use the API to pull your copy of all that
data, both claims from you and claims from others about you. These are hard-and-fast credentials that can
be shown to others, along with their verifiable time and signature.
</p>
<p>
Furthermore, you can use your network to verify claims by other people, even if they haven't given you
visibility. First, on the claim screen you can see if the server detects anyone who is a direct link
between you, so you can reach out to those in-between people for more info. If there isn't anyone
who is directly in between then you can reach out with a message to your network.
</p>
<p>
This app generated an identifier, based on public & private keys located on your device.
That ID is only shared with our server and with people you explicitly allow.
The other information -- like gratitude and contributions and projects --
are published to a server that protects your ID. (Someday, your devices
will share directly P2P and not need a server... you can choose your levels
of discovery and privacy.) What this means is that you are in charge of your
network, and we provide tools and reporting to help you connect with your network for
references and reputation.
</p>
</div>
</li>
<li class="p-2">
<div @click="showGovernance = !showGovernance" class="text-blue-500">... I want to build governance organically.</div>
<div v-if="showGovernance">
<p>
This requires motivated, dedicated citizens. The good thing is that dedication the primary ingredient;
add coordination and we can find ways to replace monopolistic systems.
</p>
<p>
Add projects for your main areas of interest, and offer commitments to projects to kick-start some initiatives.
</p>
<p>
One other feature worth emphasizing: you build a history of credentials, ones that are verifiably
yours. But one other good thing is that you get support from those who confirm your activity.
You can share this support in a way that others can validate the data for themselves from people
in their own network. This kind of reputable project and history of performance is good evidence
for your ability to take responsibility for important initiatives.
</p>
</div>
</li>
<li class="p-2">
<div @click="showBasics = !showBasics" class="text-blue-500">... I want to supply life's basics freely.</div>
<div v-if="showBasics">
<p>
This platform is not optimal for balancing needs and resources at this point,
but we continuously seek out and list
those kinds of projects. Watch our blog, and watch the project list for words like
<router-link class="text-blue-500" to="/discover?searchText=sharing">"sharing"</router-link>
or
<router-link class="text-blue-500" to="/discover?searchText=basic">"basic"</router-link>
or
<router-link class="text-blue-500" to="/discover?searchText=free">"free"</router-link>.
</p>
</div>
</li>
</ul>
<h2 class="text-xl font-semibold">How do I get started?</h2>
<p>
Someone -- like the person who told you about this app -- needs to register you
on the Contacts <fa icon="users" class="fa-fw" /> page.
If you heard about this from our outreach, feel free to contact us (below) for a chat.
After someone registers you, you can register others.
You need someone to register you, like the person who told you
about this app, on the Contacts
<fa icon="users" class="fa-fw" /> page. After they register you, you can
select any contact on the home page (or "anonymous") and record your
appreciation for... whatever. The main goal is to record what people
have given you, to grow giving economies. Each claim is recorded on a
custom ledger. The day after being registered, you'll be able to able to
register others; later, you can create projects, too.
</p>
<p>
Then you can record your appreciation for... whatever: select any contact on the home page
(or "Unnamed") and send it. The main goal is to record what people
have given you, to grow giving economies. You can also record your own
ideas for projects. Each claim is recorded on a
custom ledger.
</p>
<p>
The day after being registered, you'll be able to able to register others, too.
Note that there are limits to how many others you can register.
Take your time to bring people on... make it an opportunity to get to
know their projects, and to show off your own.
</p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<a href="/help-onboarding" target="_blank" class="text-blue-500">
Use these instructions.
</a>
To start scanning, go to the
<router-link class="text-blue-500" to="/contact-qr">contact-scanning page.</router-link>
</p>
<p>
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.
Note that there are rate limits to how many others you can register,
so it may take some time to register everyone you want. Take your time...
make it an opportunity to get to know their projects, and show your own.
</p>
<h2 class="text-xl font-semibold">
@@ -229,13 +76,29 @@
<p>
Go
<router-link class="text-blue-500" to="/import-account">import your identifier</router-link>.
If you don't want the old one, click "Advanced" and check the box to erase it.
(The erase option only shows if you have exactly one identifier.
For more in-depth surgery, you'll have to erase data from the browser or reinstall.)
</p>
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
<p>
<a href="/help-onboarding" target="_blank" class="text-blue-500">
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, 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 four sets of data to backup: the identifier secrets;
the private text data that isn't as sensitive such as settings and contacts;
the private image for yourself; and the data that you have sent to the public.
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">
@@ -256,7 +119,7 @@
</ul>
<h2 class="text-xl font-semibold">
How do I backup my other private text data like settings & contacts?
How do I backup my non-secret, non-public text data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
@@ -270,7 +133,7 @@
</ul>
<h2 class="text-xl font-semibold">
How do I backup my profile image?
How do I backup my non-secret, non-public image?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
@@ -280,7 +143,7 @@
</ul>
<h2 class="text-xl font-semibold">
How do I backup other data I've posted?
How do I backup my public data?
</h2>
<ul class="list-disc list-outside ml-4">
<li>
@@ -317,21 +180,21 @@
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li>
</ul>
</div>
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
<p>
Before doing this, beware that it is an advanced feature that affects
functionality (eg. the words "Alt ID" next to results, backup features). You can
Before doing this, note that it is an advanced feature that affects
functionality (eg. the words "Alt ID" next to results, backup features)
so beware. You can
<router-link to="start" class="text-blue-500">
create another identity here.
</router-link>
</p>
<h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
<p>
Before doing this, you may want to back up your data with the instructions above.
</p>
@@ -339,9 +202,6 @@
<li class="list-disc list-outside ml-4">
Mobile
<ul>
<li class="list-disc list-outside ml-4">
Home Screen: hold down on the icon, and choose to delete it
</li>
<li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
</li>
@@ -383,11 +243,11 @@
How do I access even more functionality?
</h2>
<p>
There is an "Advanced" section at the bottom of the Profile
There is an "Advanced" section at the bottom of the Account
<fa icon="circle-user" /> page.
</p>
<p>
There is even more functionality in a mobile app (and more
There is a even more functionality in a mobile app (and more
documentation) at
<a href="https://endorser.ch" target="_blank" class="text-blue-500">
EndorserSearch.com
@@ -422,19 +282,19 @@
</p>
<h2 class="text-xl font-semibold">
This app is misbehaving, like showing me a blank screen or failing to show my personal data.
My app is misbehaving, like showing me a blank screen or failing to show a feed.
What can I do?
</h2>
<p>
First, note that clearing the cache will clear all your identity and contact info,
so we recommend doing other things first -- and only clearing when have your backups ready.
so we recommend doing other things first (unless you know you have your backups ready).
</p>
<ul class="list-disc list-outside ml-4">
<li>
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the latest version.
it sometimes takes multiple tries for the app to refresh to the current version.
You can see the version information at the bottom of this page; the best
way to determine the latest version is to open this page in an incognito/private
way to determine the current version is to open this page in an incognito
browser window and look at the version there.
</li>
<li>
@@ -480,7 +340,7 @@
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center">
This work is public domain. (If you like rules, reference
This work is public domain, governed by
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
@@ -496,54 +356,14 @@
style="display: inline"
/>
</a>
.) This is offered freely, with the hope that it helps but without any warranty or guarantee;
if it helps you then enjoy using it,
but if you may try to forcibly collect damages for things you think it should do (or not do)
then don't use it.
<br />
As for data & privacy:
<ul class="list-disc list-outside ml-4">
<li>
If using notifications, a server stores push token data. That can be revoked at any time
by disabling notifications on the Profile <fa icon="circle-user" class="fa-fw" /> page.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
and deleting them.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request.
</li>
<li>
For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</li>
</ul>
</p>
<h2 class="text-xl font-semibold">How can I contribute?</h2>
<p>
If you have skills, contact us below.
If you have Bitcoin, donate to
<button
@click="
doCopyTwoSecRedo(
'bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma',
() => (showDidCopy = !showDidCopy)
)
"
class="text-blue-500 ml-2"
>
bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma
<fa v-show="!showDidCopy" icon="copy" class="text-sm text-slate-400 fa-fw" />
<fa v-show="showDidCopy" icon="circle-check" class="text-sm text-green-500 fa-fw"/>
</button>
You can donate online via
<a href="https://www.patreon.com/TimeSafari" target="_blank" class="text-blue-500">Patreon here</a>.
For other donations, contact us.
For notifications, this service stores push token data; that can be revoked at any time
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" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</p>
<h2 class="text-xl font-semibold">Where can I read more?</h2>
@@ -559,7 +379,7 @@
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold">
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
For any other questions, including removing all your data from the public ledger:
</h2>
<p>
Contact us at
@@ -574,16 +394,10 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import * as Package from "../../package.json";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import {
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "@/db/index";
@Component({ components: { QuickNav } })
export default class Help extends Vue {
@@ -591,30 +405,5 @@ export default class Help extends Vue {
package = Package;
commitHash = import.meta.env.VITE_GIT_HASH;
showAlpha = false;
showBasics = false;
showCommunity = false;
showGovernance = false;
showGroup = false;
showDidCopy = false;
showVerifiable = false;
// call fn, copy text to the clipboard, then redo fn after 2 seconds
doCopyTwoSecRedo(text: string, fn: () => void) {
fn();
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
async unsetFinishedOnboarding() {
const settings = await retrieveSettingsForActiveAccount();
if (settings.activeDid) {
await updateAccountSettings(settings.activeDid || "", {
finishedOnboarding: false,
});
}
(this.$router as Router).push({ name: "home" });
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -39,43 +39,24 @@
<!-- Other Identity/ies -->
<ul class="mb-4">
<li v-for="ident in otherIdentities" :key="ident.did">
<div class="flex items-center justify-between mb-2">
<div
class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer"
@click="switchAccount(ident.did)"
>
<fa
v-if="ident.did === activeDid"
icon="circle-check"
class="fa-fw text-blue-600 text-xl mr-3"
/>
<fa
v-else
icon="circle"
class="fa-fw text-slate-400 text-xl mr-3"
/>
<span class="flex-grow overflow-hidden">
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ ident.did }}</code>
</div>
</span>
<li
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
v-for="ident in otherIdentities"
:key="ident.did"
@click="switchAccount(ident.did)"
>
<fa
v-if="ident.did === activeDid"
icon="circle-check"
class="fa-fw text-blue-600 text-xl mr-3"
/>
<fa v-else icon="circle" class="fa-fw text-slate-400 text-xl mr-3" />
<span class="overflow-hidden">
<h2 class="text-xl font-semibold mb-0"></h2>
<div class="text-sm text-slate-500 truncate">
<b>ID:</b> <code>{{ ident.did }}</code>
</div>
<div>
<fa
v-if="ident.did === activeDid"
icon="trash-can"
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
@click="notifyCannotDelete()"
/>
<fa
v-else
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteAccount(ident.id)"
/>
</div>
</div>
</span>
</li>
</ul>
@@ -99,41 +80,47 @@
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { AccountsSchema } from "@/db/tables/accounts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { retrieveAllAccountsMetadata } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
Constants = AppString;
public accounts: typeof AccountsSchema;
public activeDid = "";
public activeDidInIdentities = false;
public apiServer = "";
public apiServerInput = "";
public otherIdentities: Array<{ id: string; did: string }> = [];
public otherIdentities: Array<{ did: string }> = [];
public showContactGives = false;
async created() {
try {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.apiServerInput = settings.apiServer || "";
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.apiServerInput = settings?.apiServer || "";
this.showContactGives = !!settings?.showContactGivesInline;
const accounts = await retrieveAllAccountsMetadata();
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) {
const acct = accounts[n];
this.otherIdentities.push({ id: acct.id as string, did: acct.did });
if (acct.did && this.activeDid === acct.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) {
@@ -144,7 +131,7 @@ export default class IdentitySwitcherView extends Vue {
title: "Error Loading Accounts",
text: "Clear your cache and start over (after data backup).",
},
5000,
-1,
);
console.error("Telling user to clear cache at page create because:", err);
}
@@ -159,39 +146,7 @@ export default class IdentitySwitcherView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
(this.$router as Router).push({ name: "account" });
}
async deleteAccount(id: string) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.delete(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
},
},
-1,
);
}
notifyCannotDelete() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Cannot Delete",
text: "You cannot delete the active identity. Set to another identity or 'no identity' first.",
},
3000,
);
this.$router.push({ name: "account" });
}
}
</script>

View File

@@ -53,13 +53,6 @@
<input type="checkbox" class="mr-2" v-model="shouldErase" />
<label>Erase the previous identifier.</label>
</div>
<div v-if="isNotProdServer()" class="mt-4 text-blue-500">
<!-- if they click this, fill in the mnemonic seed-input with the test mnemonic -->
<button @click="mnemonic = TEST_USER_0_MNEMONIC">
Use mnemonic for Test User #0
</button>
</div>
</div>
<div class="mt-8">
@@ -84,57 +77,40 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "@/libs/crypto";
import { retrieveAccountCount } from "@/libs/util";
@Component({
components: {},
})
export default class ImportAccountView extends Vue {
TEST_USER_0_MNEMONIC =
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'"; // for legacy imports, likely never used
AppString = AppString;
$notify!: (notification: NotificationIface, timeout?: number) => void;
apiServer = "";
address = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
mnemonic = "";
address = "";
numAccounts = 0;
privateHex = "";
publicHex = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
showAdvanced = false;
shouldErase = false;
async created() {
this.numAccounts = await retrieveAccountCount();
// get the server, to help with import on the test server
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count();
}
public onCancelClick() {
(this.$router as Router).back();
}
public isNotProdServer() {
return this.apiServer !== AppString.PROD_ENDORSER_API_SERVER;
this.$router.back();
}
public async fromMnemonic() {
@@ -152,7 +128,7 @@ export default class ImportAccountView extends Vue {
this.derivationPath,
);
const accountsDB = await accountsDBPromise;
await accountsDB.open();
if (this.shouldErase) {
await accountsDB.accounts.clear();
}
@@ -167,10 +143,10 @@ export default class ImportAccountView extends Vue {
// record that as the active DID
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error saving mnemonic & updating settings:", err);
@@ -182,7 +158,7 @@ export default class ImportAccountView extends Vue {
title: "Invalid Mnemonic",
text: "Please check your mnemonic and try again.",
},
5000,
-1,
);
} else {
this.$notify(
@@ -192,7 +168,7 @@ export default class ImportAccountView extends Vue {
title: "Error",
text: "Got an error creating that identifier.",
},
5000,
-1,
);
}
}

View File

@@ -17,7 +17,7 @@
<div>
<p class="text-center text-xl mb-4 font-light">
Will increment the maximum known derivation path from the existing seed.
Will increment the maximum derivation path from the existing seed.
</p>
<p v-if="didArrays.length > 1">
@@ -33,7 +33,7 @@
<fa
v-if="dids[0] == selectedArrayFirstDid"
icon="circle"
class="fa-fw text-blue-500 text-xl mr-3"
class="fa-fw text-blue-400 text-xl mr-3"
></fa>
<fa
v-else
@@ -70,17 +70,14 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
nextDerivationPath,
} from "@/libs/crypto";
import { accountsDBPromise, db } from "@/db/index";
} from "../libs/crypto";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { retrieveAllFullyDecryptedAccounts } from "@/libs/util";
@Component({
components: {},
@@ -91,7 +88,8 @@ export default class ImportAccountView extends Vue {
selectedArrayFirstDid = "";
async mounted() {
const accounts = await retrieveAllFullyDecryptedAccounts(); // let's match derived accounts differently so we don't need the private info
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
const seedDids: Record<string, Array<string>> = {};
accounts.forEach((account) => {
const prevDids: Array<string> = seedDids[account.mnemonic] || [];
@@ -102,7 +100,7 @@ export default class ImportAccountView extends Vue {
}
public onCancelClick() {
(this.$router as Router).back();
this.$router.back();
}
public switchAccount(did: string) {
@@ -110,11 +108,11 @@ export default class ImportAccountView extends Vue {
}
public async incrementDerivation() {
await accountsDB.open();
// find the maximum derivation path for the selected DIDs
const selectedArray: Array<string> =
this.didArrays.find((dids) => dids[0] === this.selectedArrayFirstDid) ||
[];
const accountsDB = await accountsDBPromise; // let's match derived accounts differently so we don't need the private info
const allMatchingAccounts = await accountsDB.accounts
.where("did")
.anyOf(...selectedArray)
@@ -126,11 +124,9 @@ export default class ImportAccountView extends Vue {
}
});
// increment the last number in that max derivation path
const newDerivPath = nextDerivationPath(
accountWithMaxDeriv.derivationPath as string,
);
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath);
const mne: string = accountWithMaxDeriv.mnemonic as string;
const mne: string = accountWithMaxDeriv.mnemonic;
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
@@ -148,10 +144,10 @@ export default class ImportAccountView extends Vue {
// record that as the active DID
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
} catch (err) {
console.error("Error saving mnemonic & updating settings:", err);
}

Some files were not shown because too many files have changed in this diff Show More