Compare commits
161 Commits
@ -1,7 +1,3 @@ |
|||
|
|||
# I tried setting values here and using `vue-cli-service build --mode development` |
|||
# but it didn't create some things in "dist": |
|||
# - the "css" directory with the CSS extracted from Vue files |
|||
# - the sw_scripts-combined* files |
|||
# |
|||
# ¯\_(ツ)_/¯ |
|||
# 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. |
|||
|
@ -1,4 +1,4 @@ |
|||
# Only the variables that start with VUE_APP_ are seen in the application process.env in Vue. |
|||
VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H |
|||
VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch |
|||
VUE_APP_DEFAULT_IMAGE_API_SERVER=https://image-api.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 |
|||
|
@ -1,3 +0,0 @@ |
|||
module.exports = { |
|||
presets: ["@vue/cli-plugin-babel/preset"], |
|||
}; |
@ -0,0 +1,76 @@ |
|||
# 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 |
|||
``` |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 463 KiB |
@ -0,0 +1,316 @@ |
|||
--- |
|||
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. |
@ -0,0 +1,17 @@ |
|||
<!DOCTYPE html> |
|||
<html lang=""> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|||
<link rel="icon" href="/favicon.ico"> |
|||
<title>TimeSafari</title> |
|||
</head> |
|||
<body> |
|||
<noscript> |
|||
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|||
</noscript> |
|||
<div id="app"></div> |
|||
<script type="module" src="/src/main.ts"></script> |
|||
</body> |
|||
</html> |
@ -1,95 +1,98 @@ |
|||
{ |
|||
"name": "TimeSafari", |
|||
"version": "0.3.4", |
|||
"private": true, |
|||
"version": "0.3.15-beta", |
|||
"scripts": { |
|||
"serve": "vue-cli-service serve", |
|||
"build": "vue-cli-service build", |
|||
"lint": "vue-cli-service lint" |
|||
"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" |
|||
}, |
|||
"dependencies": { |
|||
"@dicebear/collection": "^5.3.5", |
|||
"@dicebear/core": "^5.3.5", |
|||
"@dicebear/collection": "^5.4.1", |
|||
"@dicebear/core": "^5.4.1", |
|||
"@ethersproject/hdnode": "^5.7.0", |
|||
"@fortawesome/fontawesome-svg-core": "^6.4.2", |
|||
"@fortawesome/free-solid-svg-icons": "^6.4.2", |
|||
"@fortawesome/vue-fontawesome": "^3.0.3", |
|||
"@fortawesome/fontawesome-svg-core": "^6.5.1", |
|||
"@fortawesome/free-solid-svg-icons": "^6.5.1", |
|||
"@fortawesome/vue-fontawesome": "^3.0.6", |
|||
"@peculiar/asn1-ecc": "^2.3.8", |
|||
"@peculiar/asn1-schema": "^2.3.8", |
|||
"@pvermeer/dexie-encrypted-addon": "^3.0.0", |
|||
"@tweenjs/tween.js": "^21.0.0", |
|||
"@types/js-yaml": "^4.0.9", |
|||
"@types/luxon": "^3.4.2", |
|||
"@veramo/core": "^5.4.1", |
|||
"@veramo/credential-w3c": "^5.4.1", |
|||
"@veramo/data-store": "^5.4.1", |
|||
"@veramo/did-manager": "^5.4.1", |
|||
"@veramo/did-provider-ethr": "^5.4.1", |
|||
"@veramo/did-resolver": "^5.4.1", |
|||
"@veramo/key-manager": "^5.4.1", |
|||
"@vueuse/core": "^10.4.1", |
|||
"@simplewebauthn/browser": "^10.0.0", |
|||
"@simplewebauthn/server": "^10.0.0", |
|||
"@tweenjs/tween.js": "^21.1.1", |
|||
"@veramo/core": "^5.6.0", |
|||
"@veramo/credential-w3c": "^5.6.0", |
|||
"@veramo/data-store": "^5.6.0", |
|||
"@veramo/did-manager": "^5.6.0", |
|||
"@veramo/did-provider-ethr": "^5.6.0", |
|||
"@veramo/did-provider-peer": "^6.0.0", |
|||
"@veramo/did-resolver": "^5.6.0", |
|||
"@veramo/key-manager": "^5.6.0", |
|||
"@vueuse/core": "^10.9.0", |
|||
"@zxing/text-encoding": "^0.9.0", |
|||
"axios": "^1.5.0", |
|||
"buffer": "^6.0.3", |
|||
"asn1-ber": "^1.2.2", |
|||
"axios": "^1.6.8", |
|||
"cbor-x": "^1.5.9", |
|||
"class-transformer": "^0.5.1", |
|||
"core-js": "^3.32.1", |
|||
"dexie": "^3.2.4", |
|||
"dexie-export-import": "^4.0.7", |
|||
"did-jwt": "^7.2.7", |
|||
"ethereum-cryptography": "^2.1.2", |
|||
"dexie": "^3.2.7", |
|||
"dexie-export-import": "^4.1.1", |
|||
"did-jwt": "^7.4.7", |
|||
"ethereum-cryptography": "^2.1.3", |
|||
"ethereumjs-util": "^7.1.5", |
|||
"ethr-did-resolver": "^8.1.2", |
|||
"git-describe": "^4.1.1", |
|||
"jdenticon": "^3.2.0", |
|||
"js-generate-password": "^0.1.9", |
|||
"js-yaml": "^4.1.0", |
|||
"localstorage-slim": "^2.5.0", |
|||
"localstorage-slim": "^2.7.0", |
|||
"lru-cache": "^10.2.0", |
|||
"luxon": "^3.4.4", |
|||
"merkletreejs": "^0.3.11", |
|||
"moment": "^2.29.4", |
|||
"notiwind": "^2.0.2", |
|||
"papaparse": "^5.4.1", |
|||
"pina": "^0.20.2204228", |
|||
"pinia-plugin-persistedstate": "^3.2.0", |
|||
"pinia-plugin-persistedstate": "^3.2.1", |
|||
"qr-code-generator-vue3": "^1.4.21", |
|||
"ramda": "^0.29.0", |
|||
"readable-stream": "^4.4.2", |
|||
"reflect-metadata": "^0.1.13", |
|||
"ramda": "^0.29.1", |
|||
"readable-stream": "^4.5.2", |
|||
"reflect-metadata": "^0.1.14", |
|||
"register-service-worker": "^1.7.2", |
|||
"simple-vue-camera": "^1.1.3", |
|||
"three": "^0.156.1", |
|||
"ua-parser-js": "^1.0.37", |
|||
"util": "^0.12.5", |
|||
"vue": "^3.3.4", |
|||
"vue": "^3.4.21", |
|||
"vue-axios": "^3.5.2", |
|||
"vue-facing-decorator": "^3.0.2", |
|||
"vue-qrcode-reader": "^5.4.1", |
|||
"vue-router": "^4.2.4", |
|||
"vue-facing-decorator": "^3.0.4", |
|||
"vue-picture-cropper": "^0.7.0", |
|||
"vue-qrcode-reader": "^5.5.3", |
|||
"vue-router": "^4.3.0", |
|||
"web-did-resolver": "^2.0.27" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/leaflet": "^1.9.4", |
|||
"@types/ramda": "^0.29.3", |
|||
"@types/js-yaml": "^4.0.9", |
|||
"@types/leaflet": "^1.9.8", |
|||
"@types/luxon": "^3.4.2", |
|||
"@types/ramda": "^0.29.11", |
|||
"@types/three": "^0.155.1", |
|||
"@types/ua-parser-js": "^0.7.39", |
|||
"@typescript-eslint/eslint-plugin": "^6.6.0", |
|||
"@typescript-eslint/parser": "^6.6.0", |
|||
"@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/cli-plugin-babel": "~5.0.8", |
|||
"@vue/cli-plugin-eslint": "~5.0.8", |
|||
"@vue/cli-plugin-pwa": "~5.0.8", |
|||
"@vue/cli-plugin-router": "~5.0.8", |
|||
"@vue/cli-plugin-typescript": "~5.0.8", |
|||
"@vue/cli-plugin-vuex": "~5.0.8", |
|||
"@vue/cli-service": "~5.0.8", |
|||
"@vue/eslint-config-typescript": "^11.0.3", |
|||
"autoprefixer": "^10.4.15", |
|||
"eslint": "^8.53.0", |
|||
"eslint-config-prettier": "^9.0.0", |
|||
"eslint-plugin-prettier": "^5.0.0", |
|||
"eslint-plugin-vue": "^9.17.0", |
|||
"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.29", |
|||
"prettier": "^3.1.0", |
|||
"tailwindcss": "^3.3.3", |
|||
"typescript": "~5.2.2" |
|||
"postcss": "^8.4.38", |
|||
"prettier": "^3.2.5", |
|||
"tailwindcss": "^3.4.1", |
|||
"typescript": "~5.2.2", |
|||
"vite": "^5.2.0", |
|||
"vite-plugin-pwa": "^0.19.8" |
|||
} |
|||
} |
|||
|
@ -1,178 +1,4 @@ |
|||
|
|||
tasks : |
|||
|
|||
- bug - landscape doesn't show full camera |
|||
- bug - got blank screen and errors on iPhone with no bottom tabs |
|||
- add to readme - check version, close tabs & restart phone if necessary |
|||
- bug maybe - a new give remembers the previous project |
|||
- alert & stop if give amount < 0 |
|||
- add warning that all data (except ID) is public |
|||
- onboarding video |
|||
- .2 when adding a claim on home screen, push that claim to the top of the list |
|||
|
|||
- 24 allow a person record with interests, including location; purpose? contact methods? enhance other connections the same? (suggestion from Philippines) assignee-group:ui |
|||
|
|||
- .1 on feed, don't show "to someone anonymous" if it's to a project |
|||
- .1 on ideas, put an "x" to close it assignee-group:ui |
|||
- 16 save data backups in Google |
|||
- 16 generate and use passkeys for identities |
|||
|
|||
- .2 fix give dialog from "more contacts" off home page to allow giving to this user |
|||
- .2 fix bottom of project selection map, where the icons are hidden but a tap goes to the icon's page assignee-group:ui |
|||
- .5 stop from seeing an error on the first page when browser doesn't support service workers (which I've seen on iPhone; visible in Firefox private window) |
|||
- .2 don't show a warning on a totally new project when the authorized agent is set |
|||
- .2 anchor hash into BTC |
|||
- .2 list the "show more" contacts alphabetically |
|||
|
|||
- .5 make Time Safari a share_target for images |
|||
|
|||
- 08 add image on profile |
|||
|
|||
- ask to detect location & record it in settings |
|||
- if personal location is set, show potential local affiliations |
|||
|
|||
- 24 compelling UI for credential presentations |
|||
- discover who in my network has activity on a project |
|||
|
|||
- 24 compelling UI for statistics (eg. World?) |
|||
|
|||
- 01 in the feed, group by project or contact or topic or time/$ (via BC); new projects, offers, search area, etc assignee-group:ui |
|||
- 01 separate not-on-platform vs totally anonymous; terminology "unidentified"? |
|||
- .2 add links between projects assignee-group:ui |
|||
- 24 make the contact browsing on the front page something that invites more action |
|||
|
|||
- .5 change server plan & project endpoints to use jwtId as identifier rather than rowid |
|||
- 16 edit offers & gives, or revoke allowing re-creation |
|||
- .1 When available in the server, give message for 'nonAmountGiven' on offers on ProjectsView page. |
|||
- .1 Add help instructions for "Encryption key has changed" error. (It is a problem if localStorage is cleared, but the contacts & settings remain and they have to restore their seeds.) |
|||
- .1 show better error when user with no ID goes to the "My Project" page |
|||
- 01 in front page prompt for ideas for gratitude : |
|||
- randomize (not show in order) |
|||
- checkboxes - show non-person-oriented messages, show only contacts, show only projects |
|||
|
|||
- .5 add a notice on the front page if their notifications are off |
|||
- 08 allow user to add a time when they want their daily notification |
|||
|
|||
- .5 prompt for the name directly when they visit the QR scan page |
|||
- 01 mark a project as inactive |
|||
- 01 add share button for sending a message to confirmers when we can't see the claim (like the "visible" links) |
|||
- .5 add TimeSafari as a shareable app https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target |
|||
- .5 choose a project's alternative agent ("authorized representative") via a contact chooser (not just copy-paste a DID) |
|||
- .5 find out why clicking quickly back-and-forth onto the "my project" page often shows error "You need an identifier to load your projects." (easier to reproduce on desktop?) |
|||
- .5 bug - it didn't show the "fulfills offer" on the claim detail page for a give that had one - https://test.timesafari.app/claim/01HMFWRPA3PD6Q9EYFKX3MC41J |
|||
- 01 replace all "confirm" prompts with nicer modal |
|||
- .1 hide project-create button on project page if not registered |
|||
- .1 hide offer & give buttons on project list page if not registered |
|||
- .1 add cursor-pointer on the icons for giving on the project page, and on the list of projects on the discover page |
|||
- .2 record when InfiniteScroll hits the end of the list and don't trigger any more loads (feed, project list, give & offer lists) |
|||
|
|||
- bug - turning notifications on from the help screen did not stay, though account screen toggle did stay (From Jason on iPhone.) |
|||
- refactor - supply the projectId to the OfferDialog just like we do with the GiftedDialog offerId (in the "open" method, maybe as well as an attribute) |
|||
- the confirm button on each give on the ProjectViewView page doesn't have all the context of the ClaimView page, so it can show sometimes inappropriately; consider consolidation |
|||
- make the "give" on contact screen work like other give (allowing donation vs current blank) |
|||
- .2 on ClaimView, the "ask someone" should refer to "visible" IDs, or to confirmations only if confirmations are visible |
|||
- message "send them to this page" on ClaimView should be a link (for installed app) |
|||
- When we update a version, desktop browser users have seen nothing happen after clicking on the contact page QR and on the account page "Help"; errors show in the console. Reload fixed it. If this happens on mobile, ask the user to reload. |
|||
|
|||
- 01 show my VCs - most interesting, or via search |
|||
- 04 allow user to download & prove chains of VCs, mine + ones I can see about me from others |
|||
|
|||
- revenue to support server operation |
|||
|
|||
- .1 copy button for seed |
|||
- .5 If notifications are not enabled, add message to front page with link/button to enable |
|||
- make server endpoint for full English description of limits |
|||
- create a help-desk document & add screenshots |
|||
|
|||
- .1 update "offer" units to have same functionality as "give" units |
|||
- .5 add a link to any 'give' records that fulfill an offer on ClaimView |
|||
- 01 on home page, prompt for install check in addition to "supports notifications" check (since they won't get notified if Chrome is closed) |
|||
- 01 on Mac (& Windows?) desktop, add a help blurb so that they can find it again (since it doesn't show in Application list) |
|||
- bug (that is hard to reproduce) - got error adding on Firefox user #0 as contact for themselves |
|||
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function" |
|||
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent |
|||
- 04 remove 'rowid' references (that are sqlite-specific); may involve server |
|||
- 04 look at other examples for better onboarding UI, eg friend.tech |
|||
- .5 Add inactive flag / end date, start date to project |
|||
- .3 check that Android shows "back" buttons on screens without bottom tray |
|||
- .1 Make give description text box into something that expands as they type? |
|||
- .2 Show a warning if both giver and recipient are the same (but still allow?) |
|||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui |
|||
- .5 Display a more appealing confirmation on the map when erasing the marker |
|||
- .5 remove references to localStorage for projectId (now that it's pulling from the path) |
|||
- switch some checks for activeDid to check for isRegistered |
|||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show" |
|||
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30) |
|||
- warn if they're using the web (android only?) |
|||
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps |
|||
https://web.dev/articles/get-installed-related-apps |
|||
- .5 fix the "onboarding help" list of instructions so that it always formats right (currently doesn't show numbers aligned on Google Pixel 6a, iPhone 11 Pro, iPhone 12 mini) |
|||
- .5 make the "onboarding help" it so that it doesn't cover the QR icon on the contacts page |
|||
- .5 fix masked icon (because some of the top-right of the binoculars is cut off) |
|||
|
|||
- contacts v+ : |
|||
- 01 Import all the non-sensitive data (ie. contacts & settings). |
|||
- .2 show error to user when adding a duplicate contact |
|||
- 01 parse input more robustly (with CSV lib and not commas) |
|||
|
|||
- stats v1 : |
|||
- 01 show numeric stats |
|||
- 04 show different graphic for projects vs people (gnome?) on world |
|||
- 01 link to world for specific stats |
|||
- .5 don't load another instance of a bush if it already exists |
|||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version") |
|||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie) |
|||
|
|||
- .5 show seed phrase in a QR code for transfer to another device |
|||
- .5 on DiscoverView, switch to a filter UI (eg. just from friend |
|||
- .5 don't show "Offer" on project screen if they aren't registered |
|||
- 01 especially for iOS, check for new version & update, eg. https://stackoverflow.com/questions/52221805/any-way-yet-to-auto-update-or-just-clear-the-cache-on-a-pwa-on-ios |
|||
|
|||
- 24 Move to Vite |
|||
- 32 accept images for projects |
|||
- 32 accept images for contacts |
|||
- import project interactions from GitHub/GitLab and manage signing |
|||
|
|||
- show total time offered to & fulfilled to a project |
|||
- show total time offered by & fulfilled by a contact |
|||
|
|||
- linking between projects or plans : |
|||
- show total time given to & from a project |
|||
- terminology: |
|||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances |
|||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning) |
|||
|
|||
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui |
|||
- .5 Replace Gifted/Give in ContactsView with GiftedDialog |
|||
|
|||
- Stats : |
|||
- 01 point out user's location on the world |
|||
- 01 present a credential selected from the stats |
|||
- 04 show gives spreading to other places |
|||
- badge for most gives/receives/confirms per day/week/month |
|||
- badge for amount given/offered to your project |
|||
- set a goal of given/offers |
|||
|
|||
- automated tests, eg. pup-test or cypress |
|||
|
|||
- Notifications (wake on the phone, push notifications) |
|||
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee |
|||
- pull instead of push, maybe via scheduled runs |
|||
- have a notification pop-up on Mac screen |
|||
|
|||
- 16 Connect with phone contacts - this may be a whole different app, because we want a quick link A) to the same phone contact and B) from the phone contact app |
|||
|
|||
- Support KERI AIDs |
|||
- Support Peer DIDs |
|||
- Support messaging through DIDComm |
|||
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh) |
|||
|
|||
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better. |
|||
|
|||
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections -- and that allows spam |
|||
- .1 When Chrome shows compatibility https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare |
|||
then change the canShare check in this app to check the real canShare() method. |
|||
|
|||
log : |
|||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29 |
|||
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27 |
|||
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d |
|||
|
@ -1,17 +0,0 @@ |
|||
<!DOCTYPE html> |
|||
<html lang=""> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|||
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> |
|||
<title><%= htmlWebpackPlugin.options.title %></title> |
|||
</head> |
|||
<body> |
|||
<noscript> |
|||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|||
</noscript> |
|||
<div id="app"></div> |
|||
<!-- built files will be auto injected --> |
|||
</body> |
|||
</html> |
@ -0,0 +1,219 @@ |
|||
<template> |
|||
<div v-if="visible" id="dialogFeedFilters" class="dialog-overlay"> |
|||
<div class="dialog"> |
|||
<h1 class="text-xl font-bold text-center mb-4">Feed Filters</h1> |
|||
|
|||
<p class="mb-4 font-bold">Show only activities that…</p> |
|||
|
|||
<div class="grid grid-cols-1 gap-2"> |
|||
<div |
|||
class="flex items-center justify-between cursor-pointer" |
|||
@click="toggleHasVisibleDid()" |
|||
> |
|||
<!-- label --> |
|||
<div>Include someone visible to me</div> |
|||
<!-- toggle --> |
|||
<div class="relative ml-2"> |
|||
<!-- input --> |
|||
<input |
|||
type="checkbox" |
|||
v-model="hasVisibleDid" |
|||
name="toggleFilterFromMyContacts" |
|||
class="sr-only" |
|||
/> |
|||
<!-- line --> |
|||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> |
|||
<!-- dot --> |
|||
<div |
|||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" |
|||
></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<em>or</em> |
|||
|
|||
<div |
|||
class="flex items-center justify-between cursor-pointer" |
|||
@click=" |
|||
hasSearchBox |
|||
? toggleNearby() |
|||
: $router.push({ name: 'search-area' }) |
|||
" |
|||
> |
|||
<!-- label --> |
|||
<div>Are nearby</div> |
|||
<!-- toggle --> |
|||
<div v-if="hasSearchBox" class="relative ml-2"> |
|||
<!-- input --> |
|||
<input |
|||
type="checkbox" |
|||
v-model="isNearby" |
|||
name="toggleFilterNearby" |
|||
class="sr-only" |
|||
/> |
|||
<!-- line --> |
|||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> |
|||
<!-- dot --> |
|||
<div |
|||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" |
|||
></div> |
|||
</div> |
|||
<div v-else class="relative ml-2"> |
|||
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"> |
|||
Select Location |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4"> |
|||
<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="setAll()" |
|||
> |
|||
Set All |
|||
</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="clearAll()" |
|||
> |
|||
Clear All |
|||
</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="done()" |
|||
> |
|||
Done |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Vue, Component } from "vue-facing-decorator"; |
|||
import { |
|||
LMap, |
|||
LMarker, |
|||
LRectangle, |
|||
LTileLayer, |
|||
} from "@vue-leaflet/vue-leaflet"; |
|||
|
|||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|||
import { db } from "@/db/index"; |
|||
|
|||
@Component({ |
|||
components: { |
|||
LRectangle, |
|||
LMap, |
|||
LMarker, |
|||
LTileLayer, |
|||
}, |
|||
}) |
|||
export default class FeedFilters extends Vue { |
|||
onCloseIfChanged = () => {}; |
|||
hasSearchBox = false; |
|||
hasVisibleDid = false; |
|||
isNearby = false; |
|||
settingChanged = false; |
|||
visible = false; |
|||
|
|||
async open(onCloseIfChanged: () => void) { |
|||
this.onCloseIfChanged = onCloseIfChanged; |
|||
|
|||
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; |
|||
} |
|||
|
|||
this.settingChanged = false; |
|||
this.visible = true; |
|||
} |
|||
|
|||
toggleHasVisibleDid() { |
|||
this.settingChanged = true; |
|||
this.hasVisibleDid = !this.hasVisibleDid; |
|||
db.settings.update(MASTER_SETTINGS_KEY, { |
|||
filterFeedByVisible: this.hasVisibleDid, |
|||
}); |
|||
} |
|||
|
|||
toggleNearby() { |
|||
this.settingChanged = true; |
|||
this.isNearby = !this.isNearby; |
|||
db.settings.update(MASTER_SETTINGS_KEY, { |
|||
filterFeedByNearby: this.isNearby, |
|||
}); |
|||
} |
|||
|
|||
async clearAll() { |
|||
if (this.hasVisibleDid || this.isNearby) { |
|||
this.settingChanged = true; |
|||
} |
|||
|
|||
db.settings.update(MASTER_SETTINGS_KEY, { |
|||
filterFeedByNearby: false, |
|||
filterFeedByVisible: false, |
|||
}); |
|||
|
|||
this.hasVisibleDid = false; |
|||
this.isNearby = false; |
|||
} |
|||
|
|||
async setAll() { |
|||
if (!this.hasVisibleDid || !this.isNearby) { |
|||
this.settingChanged = true; |
|||
} |
|||
|
|||
db.settings.update(MASTER_SETTINGS_KEY, { |
|||
filterFeedByNearby: true, |
|||
filterFeedByVisible: true, |
|||
}); |
|||
|
|||
this.hasVisibleDid = true; |
|||
this.isNearby = true; |
|||
} |
|||
|
|||
close() { |
|||
if (this.settingChanged) { |
|||
this.onCloseIfChanged(); |
|||
} |
|||
this.visible = false; |
|||
} |
|||
|
|||
done() { |
|||
this.close(); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style> |
|||
.dialog-overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background-color: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
padding: 1.5rem; |
|||
} |
|||
|
|||
#dialogFeedFilters.dialog-overlay { |
|||
z-index: 99999; |
|||
overflow: scroll; |
|||
} |
|||
|
|||
.dialog { |
|||
background-color: white; |
|||
padding: 1rem; |
|||
border-radius: 0.5rem; |
|||
width: 100%; |
|||
max-width: 500px; |
|||
} |
|||
</style> |
@ -0,0 +1,177 @@ |
|||
<template> |
|||
<div v-if="visible" class="dialog-overlay z-[60]"> |
|||
<div class="dialog relative"> |
|||
<div class="text-lg text-center font-light relative z-50"> |
|||
<div |
|||
id="ViewHeading" |
|||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none" |
|||
> |
|||
Camera or Other? |
|||
</div> |
|||
<div |
|||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" |
|||
@click="close()" |
|||
> |
|||
<fa icon="xmark" class="w-[1em]"></fa> |
|||
</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<div class="text-center mt-8"> |
|||
<div class> |
|||
<fa |
|||
icon="camera" |
|||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md" |
|||
@click="openPhotoDialog()" |
|||
/> |
|||
</div> |
|||
<div class="mt-4"> |
|||
<input type="file" @change="uploadImageFile" /> |
|||
</div> |
|||
<div class="mt-4"> |
|||
<span class="mt-2"> |
|||
... or paste a URL: |
|||
<input type="text" v-model="imageUrl" class="border-2" /> |
|||
</span> |
|||
<span class="ml-2"> |
|||
<fa |
|||
v-if="imageUrl" |
|||
icon="check" |
|||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer" |
|||
@click="acceptUrl" |
|||
/> |
|||
<!-- so that there's no shifting when it becomes visible --> |
|||
<fa v-else icon="check" class="text-white bg-white px-2 py-2" /> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<PhotoDialog ref="photoDialog" /> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import axios from "axios"; |
|||
import { ref } from "vue"; |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
|
|||
import PhotoDialog from "@/components/PhotoDialog.vue"; |
|||
import { NotificationIface } from "@/constants/app"; |
|||
|
|||
const inputImageFileNameRef = ref<Blob>(); |
|||
|
|||
@Component({ |
|||
components: { PhotoDialog }, |
|||
}) |
|||
export default class ImageMethodDialog extends Vue { |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
claimType: string; |
|||
crop: boolean = false; |
|||
imageCallback: (imageUrl?: string) => void = () => {}; |
|||
imageUrl?: string; |
|||
visible = false; |
|||
|
|||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { |
|||
this.claimType = claimType; |
|||
this.crop = !!crop; |
|||
this.imageCallback = setImageFn; |
|||
|
|||
this.visible = true; |
|||
} |
|||
|
|||
openPhotoDialog(blob?: Blob, fileName?: string) { |
|||
this.visible = false; |
|||
|
|||
(this.$refs.photoDialog as PhotoDialog).open( |
|||
this.imageCallback, |
|||
this.claimType, |
|||
this.crop, |
|||
blob, |
|||
fileName, |
|||
); |
|||
} |
|||
|
|||
async uploadImageFile(event: Event) { |
|||
this.visible = false; |
|||
|
|||
inputImageFileNameRef.value = event.target.files[0]; |
|||
// https://developer.mozilla.org/en-US/docs/Web/API/File |
|||
// ... plus it has a `type` property from my testing |
|||
const file = inputImageFileNameRef.value; |
|||
if (file != null) { |
|||
const reader = new FileReader(); |
|||
reader.onload = async (e) => { |
|||
const data = e.target?.result as ArrayBuffer; |
|||
if (data) { |
|||
const blob = new Blob([new Uint8Array(data)], { |
|||
type: file.type, |
|||
}); |
|||
this.openPhotoDialog(blob, file.name as string); |
|||
} |
|||
}; |
|||
reader.readAsArrayBuffer(file as Blob); |
|||
} |
|||
} |
|||
|
|||
async acceptUrl() { |
|||
this.visible = false; |
|||
if (this.crop) { |
|||
try { |
|||
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { |
|||
responseType: "blob", // This ensures the data is returned as a Blob |
|||
}); |
|||
const fullUrl = new URL(this.imageUrl as string); |
|||
const fileName = fullUrl.pathname.split("/").pop() as string; |
|||
(this.$refs.photoDialog as PhotoDialog).open( |
|||
this.imageCallback, |
|||
this.claimType, |
|||
this.crop, |
|||
urlBlobResponse.data as Blob, |
|||
fileName, |
|||
); |
|||
} catch (error) { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "There was an error retrieving that image.", |
|||
}, |
|||
5000, |
|||
); |
|||
} |
|||
} else { |
|||
this.imageCallback(this.imageUrl); |
|||
} |
|||
} |
|||
|
|||
close() { |
|||
this.visible = false; |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style> |
|||
.dialog-overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background-color: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
padding: 1.5rem; |
|||
} |
|||
|
|||
.dialog { |
|||
background-color: white; |
|||
padding: 1rem; |
|||
border-radius: 0.5rem; |
|||
width: 100%; |
|||
max-width: 700px; |
|||
} |
|||
</style> |
@ -0,0 +1,13 @@ |
|||
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
|
|||
|
|||
export type Temp = { |
|||
id: string; |
|||
blob?: Blob; |
|||
}; |
|||
|
|||
/** |
|||
* Schema for the Temp table in the database. |
|||
*/ |
|||
export const TempSchema = { |
|||
temp: "id", |
|||
}; |
@ -0,0 +1,96 @@ |
|||
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; |
|||
} |
@ -0,0 +1,112 @@ |
|||
/** |
|||
* 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 * as didJwt from "did-jwt"; |
|||
import { JWTDecoded } from "did-jwt/lib/JWT"; |
|||
import { IIdentifier } from "@veramo/core"; |
|||
import * as u8a from "uint8arrays"; |
|||
|
|||
import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer"; |
|||
|
|||
export const ETHR_DID_PREFIX = "did:ethr:"; |
|||
|
|||
/** |
|||
* 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; |
|||
} |
|||
|
|||
/** |
|||
* 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, |
|||
) { |
|||
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); |
|||
return didJwt.createJWT(payload, { |
|||
issuer: account.did, |
|||
signer: signer, |
|||
}); |
|||
} 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(import.meta.env.PRIVATE_KEY) |
|||
* 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"); |
|||
} |
|||
|
|||
export function decodeEndorserJwt(jwt: string): JWTDecoded { |
|||
return didJwt.decodeJWT(jwt); |
|||
} |
@ -0,0 +1,531 @@ |
|||
import { Buffer } from "buffer/"; |
|||
import { JWTPayload } from "did-jwt"; |
|||
import { DIDResolutionResult } from "did-resolver"; |
|||
import { sha256 } from "ethereum-cryptography/sha256.js"; |
|||
import { |
|||
startAuthentication, |
|||
startRegistration, |
|||
} from "@simplewebauthn/browser"; |
|||
import { |
|||
generateAuthenticationOptions, |
|||
generateRegistrationOptions, |
|||
verifyAuthenticationResponse, |
|||
verifyRegistrationResponse, |
|||
} from "@simplewebauthn/server"; |
|||
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse"; |
|||
import { |
|||
Base64URLString, |
|||
PublicKeyCredentialCreationOptionsJSON, |
|||
PublicKeyCredentialRequestOptionsJSON, |
|||
} from "@simplewebauthn/types"; |
|||
|
|||
import { AppString } from "@/constants/app"; |
|||
import { unwrapEC2Signature } from "@/libs/crypto/vc/passkeyHelpers"; |
|||
import { |
|||
arrayToBase64Url, |
|||
cborToKeys, |
|||
peerDidToPublicKeyBytes, |
|||
verifyPeerSignature, |
|||
} from "@/libs/crypto/vc/didPeer"; |
|||
|
|||
export interface JWK { |
|||
kty: string; |
|||
crv: string; |
|||
x: string; |
|||
y: string; |
|||
} |
|||
|
|||
export async function registerCredential(passkeyName?: string) { |
|||
const options: PublicKeyCredentialCreationOptionsJSON = |
|||
await generateRegistrationOptions({ |
|||
rpName: AppString.APP_NAME, |
|||
rpID: window.location.hostname, |
|||
userName: passkeyName || AppString.APP_NAME + " User", |
|||
// Don't prompt users for additional information about the authenticator
|
|||
// (Recommended for smoother UX)
|
|||
attestationType: "none", |
|||
authenticatorSelection: { |
|||
// Defaults
|
|||
residentKey: "preferred", |
|||
userVerification: "preferred", |
|||
// Optional
|
|||
authenticatorAttachment: "platform", |
|||
}, |
|||
}); |
|||
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
|||
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
|||
const attResp = await startRegistration(options); |
|||
const verification = await verifyRegistrationResponse({ |
|||
response: attResp, |
|||
expectedChallenge: options.challenge, |
|||
expectedOrigin: window.location.origin, |
|||
expectedRPID: window.location.hostname, |
|||
}); |
|||
|
|||
// references for parsing auth data and getting the public key
|
|||
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
|||
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
|||
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
|||
|
|||
const credIdBase64Url = verification.registrationInfo?.credentialID as string; |
|||
if (attResp.rawId !== credIdBase64Url) { |
|||
console.log("Warning! The raw ID does not match the credential ID."); |
|||
} |
|||
const credIdHex = Buffer.from( |
|||
base64URLStringToArrayBuffer(credIdBase64Url), |
|||
).toString("hex"); |
|||
const { publicKeyJwk } = cborToKeys( |
|||
verification.registrationInfo?.credentialPublicKey as Uint8Array, |
|||
); |
|||
|
|||
return { |
|||
authData: verification.registrationInfo?.attestationObject, |
|||
credIdHex: credIdHex, |
|||
publicKeyJwk: publicKeyJwk, |
|||
publicKeyBytes: verification.registrationInfo |
|||
?.credentialPublicKey as Uint8Array, |
|||
}; |
|||
} |
|||
|
|||
export class PeerSetup { |
|||
public authenticatorData?: ArrayBuffer; |
|||
public challenge?: Uint8Array; |
|||
public clientDataJsonBase64Url?: Base64URLString; |
|||
public signature?: Base64URLString; |
|||
|
|||
public async createJwtSimplewebauthn( |
|||
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, |
|||
iss: issuerDid, |
|||
}; |
|||
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); |
|||
// const payloadHash: Uint8Array = sha256(this.challenge);
|
|||
const options: PublicKeyCredentialRequestOptionsJSON = |
|||
await generateAuthenticationOptions({ |
|||
challenge: this.challenge, |
|||
rpID: window.location.hostname, |
|||
allowCredentials: [{ id: credentialId }], |
|||
}); |
|||
// console.log("simple authentication options", options);
|
|||
|
|||
const clientAuth = await startAuthentication(options); |
|||
// console.log("simple credential get", clientAuth);
|
|||
|
|||
const authenticatorDataBase64Url = clientAuth.response.authenticatorData; |
|||
this.authenticatorData = Buffer.from( |
|||
clientAuth.response.authenticatorData, |
|||
"base64", |
|||
).buffer; |
|||
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON; |
|||
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
|||
this.signature = clientAuth.response.signature; |
|||
|
|||
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
|||
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
|||
const headerBase64 = Buffer.from(JSON.stringify(header)) |
|||
.toString("base64") |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_") |
|||
.replace(/=+$/, ""); |
|||
|
|||
const dataInJwt = { |
|||
AuthenticationDataB64URL: authenticatorDataBase64Url, |
|||
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
|||
exp: expiryTime, |
|||
iat: issuedAt, |
|||
iss: issuerDid, |
|||
}; |
|||
const dataInJwtString = JSON.stringify(dataInJwt); |
|||
const payloadBase64 = Buffer.from(dataInJwtString) |
|||
.toString("base64") |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_") |
|||
.replace(/=+$/, ""); |
|||
|
|||
const signature = clientAuth.response.signature; |
|||
|
|||
return headerBase64 + "." + payloadBase64 + "." + signature; |
|||
} |
|||
|
|||
public async createJwtNavigator( |
|||
issuerDid: string, |
|||
payload: object, |
|||
credIdHex: string, |
|||
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, |
|||
iss: issuerDid, |
|||
}; |
|||
const dataToSignString = JSON.stringify(fullPayload); |
|||
const dataToSignBuffer = Buffer.from(dataToSignString); |
|||
const credentialId = Buffer.from(credIdHex, "hex"); |
|||
|
|||
// console.log("lower credentialId", credentialId);
|
|||
this.challenge = new Uint8Array(dataToSignBuffer); |
|||
const options = { |
|||
publicKey: { |
|||
allowCredentials: [ |
|||
{ |
|||
id: credentialId, |
|||
type: "public-key" as const, |
|||
}, |
|||
], |
|||
challenge: this.challenge.buffer, |
|||
rpID: window.location.hostname, |
|||
userVerification: "preferred" as const, |
|||
}, |
|||
}; |
|||
|
|||
const credential = await navigator.credentials.get(options); |
|||
// console.log("nav credential get", credential);
|
|||
|
|||
this.authenticatorData = credential?.response.authenticatorData; |
|||
const authenticatorDataBase64Url = arrayBufferToBase64URLString( |
|||
this.authenticatorData as ArrayBuffer, |
|||
); |
|||
|
|||
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( |
|||
credential?.response.clientDataJSON, |
|||
); |
|||
|
|||
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
|||
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
|||
const headerBase64 = Buffer.from(JSON.stringify(header)) |
|||
.toString("base64") |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_") |
|||
.replace(/=+$/, ""); |
|||
|
|||
const dataInJwt = { |
|||
AuthenticationDataB64URL: authenticatorDataBase64Url, |
|||
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
|||
exp: expiryTime, |
|||
iat: issuedAt, |
|||
iss: issuerDid, |
|||
}; |
|||
const dataInJwtString = JSON.stringify(dataInJwt); |
|||
const payloadBase64 = Buffer.from(dataInJwtString) |
|||
.toString("base64") |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_") |
|||
.replace(/=+$/, ""); |
|||
|
|||
const origSignature = Buffer.from(credential?.response.signature).toString( |
|||
"base64", |
|||
); |
|||
this.signature = origSignature |
|||
.replace(/\+/g, "-") |
|||
.replace(/\//g, "_") |
|||
.replace(/=+$/, ""); |
|||
|
|||
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature; |
|||
return jwt; |
|||
} |
|||
|
|||
// 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) => {
|
|||
// // get signature from WebAuthn
|
|||
// const signature = await this.generateWebAuthnSignature(data);
|
|||
//
|
|||
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
|||
// const signatureBuffer = Buffer.from(signature);
|
|||
// console.log("lower signature inside signer", signature);
|
|||
// console.log("lower buffer signature inside signer", signatureBuffer);
|
|||
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
|||
// // Decode the DER-encoded signature to extract R and S values
|
|||
// const reader = new asn1.BerReader(signatureBuffer);
|
|||
// console.log("lower after reader");
|
|||
// reader.readSequence();
|
|||
// console.log("lower after read sequence");
|
|||
// const r = reader.readString(asn1.Ber.Integer, true);
|
|||
// console.log("lower after r");
|
|||
// const s = reader.readString(asn1.Ber.Integer, true);
|
|||
// console.log("lower after r & s");
|
|||
//
|
|||
// // Ensure R and S are 32 bytes each
|
|||
// const rBuffer = Buffer.from(r);
|
|||
// const sBuffer = Buffer.from(s);
|
|||
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
|||
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
|||
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
|||
// const rPadded =
|
|||
// rWithoutPrefix.length < 32
|
|||
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
|||
// : rWithoutPrefix;
|
|||
// const sPadded =
|
|||
// rWithoutPrefix.length < 32
|
|||
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
|||
// : sWithoutPrefix;
|
|||
//
|
|||
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
|||
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
|||
// console.log(
|
|||
// "lower combinedSignature",
|
|||
// combinedSignature.length,
|
|||
// combinedSignature,
|
|||
// );
|
|||
//
|
|||
// const combSig64 = combinedSignature.toString("base64");
|
|||
// console.log("lower combSig64", combSig64);
|
|||
// const combSig64Url = combSig64
|
|||
// .replace(/\+/g, "-")
|
|||
// .replace(/\//g, "_")
|
|||
// .replace(/=+$/, "");
|
|||
// console.log("lower combSig64Url", combSig64Url);
|
|||
// return combSig64Url;
|
|||
// };
|
|||
// }
|
|||
} |
|||
|
|||
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
|
|||
// ... and this import:
|
|||
// import { p256 } from "@noble/curves/p256";
|
|||
export async function verifyJwtP256( |
|||
credIdHex: string, |
|||
issuerDid: string, |
|||
authenticatorData: ArrayBuffer, |
|||
challenge: Uint8Array, |
|||
clientDataJsonBase64Url: Base64URLString, |
|||
signature: Base64URLString, |
|||
) { |
|||
const authDataFromBase = Buffer.from(authenticatorData); |
|||
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
|||
const sigBuffer = Buffer.from(signature, "base64"); |
|||
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
|||
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
|||
|
|||
// Hash the client data
|
|||
const hash = sha256(clientDataFromBase); |
|||
|
|||
// Construct the preimage
|
|||
const preimage = Buffer.concat([authDataFromBase, hash]); |
|||
|
|||
const isValid = p256.verify( |
|||
finalSigBuffer, |
|||
new Uint8Array(preimage), |
|||
publicKeyBytes, |
|||
); |
|||
return isValid; |
|||
} |
|||
|
|||
export async function verifyJwtSimplewebauthn( |
|||
credIdHex: string, |
|||
issuerDid: string, |
|||
authenticatorData: ArrayBuffer, |
|||
challenge: Uint8Array, |
|||
clientDataJsonBase64Url: Base64URLString, |
|||
signature: Base64URLString, |
|||
) { |
|||
const authData = arrayToBase64Url(Buffer.from(authenticatorData)); |
|||
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
|||
const credId = arrayBufferToBase64URLString( |
|||
Buffer.from(credIdHex, "hex").buffer, |
|||
); |
|||
const authOpts: VerifyAuthenticationResponseOpts = { |
|||
authenticator: { |
|||
credentialID: credId, |
|||
credentialPublicKey: publicKeyBytes, |
|||
counter: 0, |
|||
}, |
|||
expectedChallenge: arrayToBase64Url(challenge), |
|||
expectedOrigin: window.location.origin, |
|||
expectedRPID: window.location.hostname, |
|||
response: { |
|||
authenticatorAttachment: "platform", |
|||
clientExtensionResults: {}, |
|||
id: credId, |
|||
rawId: credId, |
|||
response: { |
|||
authenticatorData: authData, |
|||
clientDataJSON: clientDataJsonBase64Url, |
|||
signature: signature, |
|||
}, |
|||
type: "public-key", |
|||
}, |
|||
}; |
|||
const verification = await verifyAuthenticationResponse(authOpts); |
|||
return verification.verified; |
|||
} |
|||
|
|||
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
|
|||
export async function verifyJwtWebCrypto( |
|||
credId: Base64URLString, |
|||
issuerDid: string, |
|||
authenticatorData: ArrayBuffer, |
|||
challenge: Uint8Array, |
|||
clientDataJsonBase64Url: Base64URLString, |
|||
signature: Base64URLString, |
|||
) { |
|||
const authDataFromBase = Buffer.from(authenticatorData); |
|||
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
|||
const sigBuffer = Buffer.from(signature, "base64"); |
|||
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
|||
|
|||
// Hash the client data
|
|||
const hash = sha256(clientDataFromBase); |
|||
|
|||
// Construct the preimage
|
|||
const preimage = Buffer.concat([authDataFromBase, hash]); |
|||
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer); |
|||
} |
|||
|
|||
// 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( |
|||
"This only verifies a peer DID, method 0, encoded base58btc.", |
|||
); |
|||
} |
|||
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
|
|||
// (another reference is the @aviarytech/did-peer resolver)
|
|||
const id = did.split(":")[2]; |
|||
const multibase = id.slice(1); |
|||
const encnumbasis = multibase.slice(1); |
|||
const didDocument = { |
|||
"@context": [ |
|||
"https://www.w3.org/ns/did/v1", |
|||
"https://w3id.org/security/suites/jws-2020/v1", |
|||
], |
|||
assertionMethod: [did + "#" + encnumbasis], |
|||
authentication: [did + "#" + encnumbasis], |
|||
capabilityDelegation: [did + "#" + encnumbasis], |
|||
capabilityInvocation: [did + "#" + encnumbasis], |
|||
id: did, |
|||
keyAgreement: undefined, |
|||
service: undefined, |
|||
verificationMethod: [ |
|||
{ |
|||
controller: did, |
|||
id: did + "#" + encnumbasis, |
|||
publicKeyMultibase: multibase, |
|||
type: "EcdsaSecp256k1VerificationKey2019", |
|||
}, |
|||
], |
|||
}; |
|||
return { |
|||
didDocument, |
|||
didDocumentMetadata: {}, |
|||
didResolutionMetadata: { contentType: "application/did+ld+json" }, |
|||
}; |
|||
} |
|||
|
|||
// convert COSE public key to PEM format
|
|||
// 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
|
|||
const pem = `-----BEGIN PUBLIC KEY-----
|
|||
${pubKeyBuffer.toString("base64")} |
|||
-----END PUBLIC KEY-----`;
|
|||
|
|||
return pem; |
|||
} |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
function base64urlDecode(input: string) { |
|||
input = input.replace(/-/g, "+").replace(/_/g, "/"); |
|||
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); |
|||
const str = atob(input + pad); |
|||
const bytes = new Uint8Array(str.length); |
|||
for (let i = 0; i < str.length; i++) { |
|||
bytes[i] = str.charCodeAt(i); |
|||
} |
|||
return bytes.buffer; |
|||
} |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
function base64urlEncode(buffer: ArrayBuffer) { |
|||
const str = String.fromCharCode(...new Uint8Array(buffer)); |
|||
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
|||
} |
|||
|
|||
// from @simplewebauthn/browser
|
|||
function arrayBufferToBase64URLString(buffer: ArrayBuffer) { |
|||
const bytes = new Uint8Array(buffer); |
|||
let str = ""; |
|||
for (const charCode of bytes) { |
|||
str += String.fromCharCode(charCode); |
|||
} |
|||
const base64String = btoa(str); |
|||
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); |
|||
} |
|||
|
|||
// from @simplewebauthn/browser
|
|||
function base64URLStringToArrayBuffer(base64URLString: string) { |
|||
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/"); |
|||
const padLength = (4 - (base64.length % 4)) % 4; |
|||
const padded = base64.padEnd(base64.length + padLength, "="); |
|||
const binary = atob(padded); |
|||
const buffer = new ArrayBuffer(binary.length); |
|||
const bytes = new Uint8Array(buffer); |
|||
for (let i = 0; i < binary.length; i++) { |
|||
bytes[i] = binary.charCodeAt(i); |
|||
} |
|||
return buffer; |
|||
} |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
async function pemToCryptoKey(pem: string) { |
|||
const binaryDerString = atob( |
|||
pem |
|||
.split("\n") |
|||
.filter((x) => !x.includes("-----")) |
|||
.join(""), |
|||
); |
|||
const binaryDer = new Uint8Array(binaryDerString.length); |
|||
for (let i = 0; i < binaryDerString.length; i++) { |
|||
binaryDer[i] = binaryDerString.charCodeAt(i); |
|||
} |
|||
// console.log("binaryDer", binaryDer.buffer);
|
|||
return await window.crypto.subtle.importKey( |
|||
"spki", |
|||
binaryDer.buffer, |
|||
{ |
|||
name: "RSASSA-PKCS1-v1_5", |
|||
hash: "SHA-256", |
|||
}, |
|||
true, |
|||
["verify"], |
|||
); |
|||
} |
@ -0,0 +1,105 @@ |
|||
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
|
|||
import { AsnParser } from "@peculiar/asn1-schema"; |
|||
import { ECDSASigValue } from "@peculiar/asn1-ecc"; |
|||
|
|||
/** |
|||
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart. |
|||
* |
|||
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
|
|||
*/ |
|||
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array { |
|||
const parsedSignature = AsnParser.parse(signature, ECDSASigValue); |
|||
let rBytes = new Uint8Array(parsedSignature.r); |
|||
let sBytes = new Uint8Array(parsedSignature.s); |
|||
|
|||
if (shouldRemoveLeadingZero(rBytes)) { |
|||
rBytes = rBytes.slice(1); |
|||
} |
|||
|
|||
if (shouldRemoveLeadingZero(sBytes)) { |
|||
sBytes = sBytes.slice(1); |
|||
} |
|||
|
|||
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]); |
|||
|
|||
return finalSignature; |
|||
} |
|||
|
|||
/** |
|||
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence |
|||
* should be removed based on the following logic: |
|||
* |
|||
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0, |
|||
* then remove the leading 0x0 byte" |
|||
*/ |
|||
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { |
|||
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; |
|||
} |
|||
|
|||
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
|
|||
/** |
|||
* Combine multiple Uint8Arrays into a single Uint8Array |
|||
*/ |
|||
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array { |
|||
let pointer = 0; |
|||
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); |
|||
|
|||
const toReturn = new Uint8Array(totalLength); |
|||
|
|||
arrays.forEach((arr) => { |
|||
toReturn.set(arr, pointer); |
|||
pointer += arr.length; |
|||
}); |
|||
|
|||
return toReturn; |
|||
} |
|||
|
|||
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
|
|||
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined; |
|||
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> { |
|||
/** |
|||
* Hello there! If you came here wondering why this method is asynchronous when use of |
|||
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this |
|||
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()` |
|||
* become synchronous if we make this synchronous (since nothing else in that method is async) |
|||
* which represents a breaking API change in this library's core API. |
|||
* |
|||
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense |
|||
* to keep this method asynchronous. |
|||
*/ |
|||
const toResolve: 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()); |
|||
}, |
|||
); |
|||
return toResolve; |
|||
} |
|||
class MissingWebCrypto extends Error { |
|||
constructor() { |
|||
const message = "An instance of the Crypto API could not be located"; |
|||
super(message); |
|||
this.name = "MissingWebCrypto"; |
|||
} |
|||
} |
|||
// Make it possible to stub return values during testing
|
|||
const _getWebCryptoInternals = { |
|||
stubThisGlobalThisCrypto: () => globalThis.crypto, |
|||
// Make it possible to reset the `webCrypto` at the top of the file
|
|||
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => { |
|||
webCrypto = newCrypto; |
|||
}, |
|||
}; |
@ -0,0 +1,101 @@ |
|||
<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 & Send |
|||
</button> |
|||
</section> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { IIdentifier } from "@veramo/core"; |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
|
|||
import GiftedDialog from "@/components/GiftedDialog.vue"; |
|||
import { NotificationIface } from "@/constants/app"; |
|||
import { accountsDB, db } from "@/db/index"; |
|||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; |
|||
import * as serverUtil from "@/libs/endorserServer"; |
|||
import QuickNav from "@/components/QuickNav.vue"; |
|||
import { Account } from "@/db/tables/accounts"; |
|||
|
|||
@Component({ |
|||
components: { GiftedDialog, QuickNav }, |
|||
}) |
|||
export default class ClaimAddRawView extends Vue { |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
accountIdentityStr: string = "null"; |
|||
activeDid = ""; |
|||
apiServer = ""; |
|||
claimStr = ""; |
|||
|
|||
async mounted() { |
|||
await db.open(); |
|||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; |
|||
this.activeDid = settings?.activeDid || ""; |
|||
this.apiServer = settings?.apiServer || ""; |
|||
|
|||
this.claimStr = this.$route.query.claim; |
|||
try { |
|||
this.veriClaim = JSON.parse(this.claimStr); |
|||
this.claimStr = JSON.stringify(this.veriClaim, null, 2); |
|||
} catch (e) { |
|||
// ignore a parse |
|||
} |
|||
} |
|||
|
|||
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. See logs for more info.", |
|||
}, |
|||
-1, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,859 @@ |
|||
<template> |
|||
<QuickNav /> |
|||
<!-- CONTENT --> |
|||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
|||
<!-- Breadcrumb --> |
|||
<div id="ViewBreadcrumb" class="mb-8"> |
|||
<h1 class="text-lg text-center font-light relative px-7"> |
|||
<!-- Back --> |
|||
<button |
|||
@click="$router.go(-1)" |
|||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
|||
> |
|||
<fa icon="chevron-left" class="fa-fw" /> |
|||
</button> |
|||
<span |
|||
v-if=" |
|||
libsUtil.isGiveRecordTheUserCanConfirm( |
|||
veriClaim, |
|||
activeDid, |
|||
confirmerIdList, |
|||
) |
|||
" |
|||
> |
|||
Do you agree? |
|||
</span> |
|||
<span v-else> Details </span> |
|||
</h1> |
|||
</div> |
|||
|
|||
<div v-if="giveDetails && !isLoading"> |
|||
<div class="flex justify-center"> |
|||
<button |
|||
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" |
|||
v-if=" |
|||
libsUtil.isGiveRecordTheUserCanConfirm( |
|||
veriClaim, |
|||
activeDid, |
|||
confirmerIdList, |
|||
) |
|||
" |
|||
@click="confirmConfirmClaim()" |
|||
> |
|||
Confirm |
|||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> |
|||
</button> |
|||
<button |
|||
v-else |
|||
@click="notifyWhyCannotConfirm()" |
|||
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" |
|||
> |
|||
Confirm |
|||
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> |
|||
</button> |
|||
<a |
|||
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md" |
|||
:href="urlForNewGive" |
|||
> |
|||
Record a Similar One |
|||
</a> |
|||
</div> |
|||
|
|||
<!-- Details --> |
|||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"> |
|||
<div class="block flex gap-4 overflow-hidden"> |
|||
<div class="overflow-hidden"> |
|||
<div class="text-sm"> |
|||
<div> |
|||
<fa icon="arrow-down" class="fa-fw text-slate-400" /> |
|||
{{ giverName }} |
|||
</div> |
|||
<div class="ml-6">gave</div> |
|||
<div v-if="giveDetails.amount"> |
|||
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" /> |
|||
{{ displayAmount(giveDetails.unit, giveDetails.amount) }} |
|||
</div> |
|||
<div v-if="giveDetails.description"> |
|||
<fa icon="message" class="fa-fw text-slate-400" /> |
|||
{{ giveDetails.amount ? "and:" : "" }} |
|||
{{ giveDetails.description }} |
|||
</div> |
|||
<div class="ml-6">to</div> |
|||
<div> |
|||
<fa icon="arrow-up" class="fa-fw text-slate-400" /> |
|||
{{ recipientName }} |
|||
</div> |
|||
<div> |
|||
<fa icon="calendar" class="fa-fw text-slate-400" /> |
|||
on |
|||
{{ giveDetails.issuedAt.substring(0, 10) }} |
|||
</div> |
|||
|
|||
<!-- Fullfills Links --> |
|||
|
|||
<!-- fullfills links for a give --> |
|||
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId"> |
|||
<router-link |
|||
:to=" |
|||
'/project/' + |
|||
encodeURIComponent(giveDetails?.fulfillsPlanHandleId) |
|||
" |
|||
class="text-blue-500 mt-2 cursor-pointer" |
|||
target="_blank" |
|||
> |
|||
This fulfills a bigger plan |
|||
<fa icon="arrow-up-right-from-square" class="fa-fw" /> |
|||
</router-link> |
|||
</div> |
|||
<!-- if there's another, it's probably fulfilling an offer, too --> |
|||
<div |
|||
v-if=" |
|||
giveDetails?.fulfillsType && |
|||
giveDetails?.fulfillsType !== 'PlanAction' && |
|||
giveDetails?.fulfillsHandleId |
|||
" |
|||
> |
|||
<!-- router-link to /claim/ only changes URL path --> |
|||
<router-link |
|||
:to=" |
|||
'/claim/' + |
|||
encodeURIComponent(giveDetails?.fulfillsHandleId) |
|||
" |
|||
class="text-blue-500 mt-2 cursor-pointer" |
|||
target="_blank" |
|||
> |
|||
This fulfills |
|||
{{ |
|||
capitalizeAndInsertSpacesBeforeCapsWithAPrefix( |
|||
giveDetails.fulfillsType, |
|||
) |
|||
}} |
|||
<fa icon="arrow-up-right-from-square" class="fa-fw" /> |
|||
</router-link> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="mt-2"> |
|||
<fa icon="comment" class="text-slate-400" /> |
|||
{{ issuerName }} posted that. |
|||
</div> |
|||
|
|||
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4"> |
|||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2> |
|||
|
|||
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span> |
|||
<span v-else-if="totalConfirmers() === 1"> |
|||
One person has confirmed this. |
|||
</span> |
|||
<span v-else> |
|||
{{ totalConfirmers() }} people have confirmed this. |
|||
</span> |
|||
|
|||
<div v-if="totalConfirmers() > 0"> |
|||
<div |
|||
v-if=" |
|||
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0 |
|||
" |
|||
> |
|||
Nobody that you know confirmed this claim, nor do they have any |
|||
confirmers in their network. |
|||
</div> |
|||
|
|||
<div |
|||
v-if=" |
|||
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0 |
|||
" |
|||
> |
|||
<!-- Only show if this person has links to confirmers (below). --> |
|||
Nobody that you know has issued or confirmed this claim. |
|||
</div> |
|||
<div v-if="confirmerIdList.length > 0"> |
|||
The following people have issued or confirmed this claim. |
|||
<ul class="ml-4"> |
|||
<li |
|||
v-for="confirmerId in confirmerIdList" |
|||
:key="confirmerId" |
|||
class="list-disc ml-4" |
|||
> |
|||
<div class="flex gap-4"> |
|||
<div class="grow overflow-hidden"> |
|||
<div class="text-sm"> |
|||
{{ didInfo(confirmerId) }} |
|||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)"> |
|||
<button |
|||
@click=" |
|||
copyToClipboard( |
|||
'The DID of ' + confirmerId, |
|||
confirmerId, |
|||
) |
|||
" |
|||
> |
|||
<fa icon="copy" class="text-slate-400 fa-fw" /> |
|||
</button> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<!-- |
|||
Never need to show this message: |
|||
"Nobody that you know can see someone who has confirmed this claim." |
|||
|
|||
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message. |
|||
If there is somebody in the confirmerIdList then that's all they need to show. |
|||
--> |
|||
|
|||
<!-- Now show anyone linked to confirmers. --> |
|||
<div v-if="confsVisibleToIdList.length > 0"> |
|||
The following people can connect you with people who have issued or |
|||
confirmed this claim. |
|||
<ul class="ml-4"> |
|||
<li |
|||
v-for="confsVisibleTo in confsVisibleToIdList" |
|||
:key="confsVisibleTo" |
|||
class="list-disc ml-4" |
|||
> |
|||
<div class="flex gap-4"> |
|||
<div class="grow overflow-hidden"> |
|||
<div class="text-sm"> |
|||
{{ didInfo(confsVisibleTo) }} |
|||
<span |
|||
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)" |
|||
> |
|||
<button |
|||
@click=" |
|||
copyToClipboard( |
|||
'The DID of ' + confsVisibleTo, |
|||
confsVisibleTo, |
|||
) |
|||
" |
|||
> |
|||
<fa icon="copy" class="text-slate-400 fa-fw" /> |
|||
</button> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- explain if user cannot confirm --> |
|||
<!-- Note that these conditions are mirrored in userCanConfirm(). --> |
|||
<div v-if="confirmerIdList.includes(activeDid)"> |
|||
You have confirmed this claim. |
|||
</div> |
|||
<div v-else-if="giveDetails.agentDid == activeDid"> |
|||
You cannot confirm this because you issued this claim, so you already |
|||
count as confirming it. |
|||
</div> |
|||
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)"> |
|||
You cannot confirm this because it contains hidden identifiers. |
|||
</div> |
|||
</div> |
|||
|
|||
<h2 |
|||
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer" |
|||
@click="showDetails = !showDetails" |
|||
> |
|||
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details |
|||
<span v-if="!showDetails"><fa icon="chevron-down" /></span> |
|||
<span v-else><fa icon="chevron-up" /></span> |
|||
</h2> |
|||
<div v-if="showDetails"> |
|||
<div |
|||
v-if=" |
|||
serverUtil.containsHiddenDid(veriClaim) && |
|||
R.isEmpty(veriClaimDidsVisible) |
|||
" |
|||
class="mb-2" |
|||
> |
|||
Some of the details are not visible to you; they show as "HIDDEN". |
|||
They are not visible to any of your direct contacts, either. |
|||
<span v-if="canShare"> |
|||
If you'd like to ask any of your contacts to take a look and see if |
|||
their contacts can see more details, |
|||
<a @click="onClickShareClaim()" class="text-blue-500" |
|||
>click to send them this info</a |
|||
> |
|||
and see if they are willing to make an introduction. |
|||
</span> |
|||
<span v-else> |
|||
If you'd like to ask any of your contacts to take a look and see if |
|||
their contacts can see more details, |
|||
<a |
|||
@click="copyToClipboard('Location', windowLocation.href)" |
|||
class="text-blue-500" |
|||
>share this page with them</a |
|||
> |
|||
and see if they are willing to make an introduction. |
|||
</span> |
|||
</div> |
|||
|
|||
<div v-if="!R.isEmpty(veriClaimDidsVisible)"> |
|||
Some of the details are not visible to you but they are visible to |
|||
some of your contacts. |
|||
<span v-if="canShare"> |
|||
If you'd like an introduction, |
|||
<a @click="onClickShareClaim()" class="text-blue-500" |
|||
>click to share the information with them and ask if they'll tell |
|||
you more about the participants.</a |
|||
> |
|||
</span> |
|||
<span v-else> |
|||
If you'd like an introduction, |
|||
<a |
|||
@click="copyToClipboard('Location', windowLocation.href)" |
|||
class="text-blue-500" |
|||
>share this page with them and ask if they'll tell you more about |
|||
about the participants.</a |
|||
> |
|||
</span> |
|||
|
|||
<div |
|||
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)" |
|||
:key="index" |
|||
class="list-disc p-4" |
|||
> |
|||
<div class="text-sm"> |
|||
<fa icon="minus" class="fa-fw" /> |
|||
The {{ visibleDidPath }} is visible to: |
|||
</div> |
|||
<div class="ml-12 p-1"> |
|||
<ul> |
|||
<li |
|||
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]" |
|||
:key="idx2" |
|||
class="list-disc" |
|||
> |
|||
<div class="text-sm mt-2"> |
|||
<span> |
|||
{{ didInfo(visDid) }} |
|||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> |
|||
<button |
|||
@click=" |
|||
copyToClipboard('The DID of ' + visDid, visDid) |
|||
" |
|||
> |
|||
<fa icon="copy" class="text-slate-400 fa-fw" /> |
|||
</button> |
|||
</span> |
|||
<span v-if="veriClaim.publicUrls?.[visDid]" |
|||
>, found at |
|||
<fa icon="globe" class="fa-fw text-slate-400" /> |
|||
<a |
|||
:href="veriClaim.publicUrls?.[visDid]" |
|||
class="text-blue-500" |
|||
>{{ |
|||
veriClaim.publicUrls[visDid].substring( |
|||
veriClaim.publicUrls[visDid].indexOf("//") + 2, |
|||
) |
|||
}} |
|||
</a> |
|||
</span> |
|||
</span> |
|||
</div> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> |
|||
<pre |
|||
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" |
|||
>{{ veriClaimDump }}</pre |
|||
> |
|||
</div> |
|||
</div> |
|||
<div v-else-if="!isLoading">This does not have details to confirm.</div> |
|||
|
|||
<div class="mt-4" v-if="!isLoading"> |
|||
<a |
|||
@click="showClaimPage(veriClaim.id)" |
|||
class="text-blue-500 cursor-pointer" |
|||
> |
|||
<fa icon="file-lines" class="pl-2" /> |
|||
All Generic Info |
|||
</a> |
|||
</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> |
|||
</section> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { AxiosError } from "axios"; |
|||
import * as yaml from "js-yaml"; |
|||
import * as R from "ramda"; |
|||
import { IIdentifier } from "@veramo/core"; |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
import { useClipboard } from "@vueuse/core"; |
|||
|
|||
import GiftedDialog from "@/components/GiftedDialog.vue"; |
|||
import QuickNav from "@/components/QuickNav.vue"; |
|||
import { NotificationIface } from "@/constants/app"; |
|||
import { accountsDB, db } from "@/db/index"; |
|||
import { Account } from "@/db/tables/accounts"; |
|||
import { Contact } from "@/db/tables/contacts"; |
|||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; |
|||
import * as serverUtil from "@/libs/endorserServer"; |
|||
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer"; |
|||
import * as libsUtil from "@/libs/util"; |
|||
import { isGiveAction } from "@/libs/util"; |
|||
|
|||
@Component({ |
|||
methods: { displayAmount }, |
|||
components: { GiftedDialog, QuickNav }, |
|||
}) |
|||
export default class ClaimView extends Vue { |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
activeDid = ""; |
|||
allMyDids: Array<string> = []; |
|||
allContacts: Array<Contact> = []; |
|||
apiServer = ""; |
|||
|
|||
canShare = false; |
|||
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer |
|||
confsVisibleErrorMessage = ""; |
|||
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer |
|||
giveDetails = null; |
|||
giverName = ""; |
|||
issuerName = ""; |
|||
isLoading = false; |
|||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible |
|||
recipientName = ""; |
|||
showDetails = false; |
|||
urlForNewGive = ""; |
|||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; |
|||
veriClaimDump = ""; |
|||
veriClaimDidsVisible = {}; |
|||
windowLocation = window.location; |
|||
|
|||
R = R; |
|||
yaml = yaml; |
|||
libsUtil = libsUtil; |
|||
serverUtil = serverUtil; |
|||
|
|||
resetThisValues() { |
|||
this.confirmerIdList = []; |
|||
this.confsVisibleErrorMessage = ""; |
|||
this.confsVisibleToIdList = []; |
|||
this.giveDetails = null; |
|||
this.numConfsNotVisible = 0; |
|||
this.urlForNewGive = ""; |
|||
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; |
|||
this.veriClaimDump = ""; |
|||
} |
|||
|
|||
async mounted() { |
|||
this.isLoading = true; |
|||
await db.open(); |
|||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; |
|||
this.activeDid = settings?.activeDid || ""; |
|||
this.apiServer = settings?.apiServer || ""; |
|||
this.allContacts = await db.contacts.toArray(); |
|||
|
|||
await accountsDB.open(); |
|||
const accounts = accountsDB.accounts; |
|||
const accountsArr: Array<Account> = await accounts?.toArray(); |
|||
this.allMyDids = accountsArr.map((acc) => acc.did); |
|||
|
|||
const pathParam = window.location.pathname.substring( |
|||
"/confirm-gift/".length, |
|||
); |
|||
let claimId; |
|||
if (pathParam) { |
|||
claimId = decodeURIComponent(pathParam); |
|||
await this.loadClaim(claimId, this.activeDid); |
|||
} else { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "No claim ID was provided.", |
|||
}, |
|||
3000, |
|||
); |
|||
} |
|||
|
|||
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare |
|||
// then use this truer check: navigator.canShare && navigator.canShare() |
|||
this.canShare = !!navigator.share; |
|||
|
|||
this.isLoading = false; |
|||
} |
|||
|
|||
// insert a space before any capital letters except the initial letter |
|||
// (and capitalize initial letter, just in case) |
|||
capitalizeAndInsertSpacesBeforeCaps(text: string) { |
|||
return !text |
|||
? "" |
|||
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); |
|||
} |
|||
|
|||
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) { |
|||
const word = this.capitalizeAndInsertSpacesBeforeCaps(text); |
|||
if (word) { |
|||
// if the word starts with a vowel, use "an" instead of "a" |
|||
const firstLetter = word[0].toLowerCase(); |
|||
const vowels = ["a", "e", "i", "o", "u"]; |
|||
const particle = vowels.includes(firstLetter) ? "an" : "a"; |
|||
return particle + " " + word; |
|||
} else { |
|||
return ""; |
|||
} |
|||
} |
|||
|
|||
totalConfirmers() { |
|||
return ( |
|||
this.numConfsNotVisible + |
|||
this.confirmerIdList.length + |
|||
this.confsVisibleToIdList.length |
|||
); |
|||
} |
|||
|
|||
// Isn't there a better way to make this available to the template? |
|||
didInfo(did: string | undefined) { |
|||
return serverUtil.didInfo( |
|||
did, |
|||
this.activeDid, |
|||
this.allMyDids, |
|||
this.allContacts, |
|||
); |
|||
} |
|||
|
|||
async loadClaim(claimId: string, userDid: string) { |
|||
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 resp = await this.axios.get(url, { headers }); |
|||
// resp.data is: |
|||
// - a Jwt from https://api.endorser.ch/api-docs/ |
|||
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3 |
|||
if (resp.status === 200) { |
|||
this.veriClaim = resp.data; |
|||
this.veriClaimDump = yaml.dump(this.veriClaim); |
|||
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids( |
|||
this.veriClaim, |
|||
true, |
|||
); |
|||
} else { |
|||
// actually, axios typically throws an error so we never get here |
|||
console.error("Error getting claim:", resp); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "There was a problem retrieving that claim.", |
|||
}, |
|||
3000, |
|||
); |
|||
return; |
|||
} |
|||
|
|||
// retrieve more details on Give, Offer, or Plan |
|||
if (this.veriClaim.claimType !== "GiveAction") { |
|||
// no need to go further... this page is for gifts |
|||
return; |
|||
} |
|||
|
|||
this.issuerName = this.didInfo(this.veriClaim.issuer); |
|||
|
|||
// use give record when possible since it may include edits |
|||
const giveUrl = |
|||
this.apiServer + |
|||
"/api/v2/report/gives?handleId=" + |
|||
encodeURIComponent(this.veriClaim.handleId as string); |
|||
const giveHeaders = await serverUtil.getHeaders(userDid); |
|||
const giveResp = await this.axios.get(giveUrl, { |
|||
headers: giveHeaders, |
|||
}); |
|||
// giveResp.data is a Give from https://api.endorser.ch/api-docs/ |
|||
if (giveResp.status === 200) { |
|||
this.giveDetails = giveResp.data.data[0]; |
|||
} else { |
|||
console.error("Error getting detailed give info:", giveResp); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "Something went wrong retrieving gift data.", |
|||
}, |
|||
3000, |
|||
); |
|||
} |
|||
|
|||
this.urlForNewGive = "/gifted-details?"; |
|||
if (this.giveDetails.amount) { |
|||
this.urlForNewGive += |
|||
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount)); |
|||
} |
|||
if (this.giveDetails.unit) { |
|||
this.urlForNewGive += |
|||
"&unitCode=" + encodeURIComponent(this.giveDetails.unit); |
|||
} |
|||
if (this.giveDetails.description) { |
|||
this.urlForNewGive += |
|||
"&description=" + encodeURIComponent(this.giveDetails.description); |
|||
} |
|||
this.giverName = this.didInfo(this.giveDetails.agentDid); |
|||
if (this.giveDetails.agentDid) { |
|||
this.urlForNewGive += |
|||
"&giverDid=" + |
|||
encodeURIComponent(this.giveDetails.agentDid) + |
|||
"&giverName=" + |
|||
encodeURIComponent(this.giverName); |
|||
} |
|||
this.recipientName = this.didInfo(this.giveDetails.recipientDid); |
|||
if (this.giveDetails.recipientDid) { |
|||
this.urlForNewGive += |
|||
"&recipientDid=" + |
|||
encodeURIComponent(this.giveDetails.recipientDid) + |
|||
"&recipientName=" + |
|||
encodeURIComponent(this.recipientName); |
|||
} |
|||
if (this.giveDetails.fullClaim.image) { |
|||
this.urlForNewGive += |
|||
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image); |
|||
} |
|||
if ( |
|||
this.giveDetails.type == "Offer" && |
|||
this.giveDetails.fulfillsHandleId |
|||
) { |
|||
this.urlForNewGive += |
|||
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId); |
|||
} |
|||
if (this.giveDetails.fulfillsPlanHandleId) { |
|||
this.urlForNewGive += |
|||
"&projectId=" + |
|||
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId); |
|||
} |
|||
|
|||
// retrieve the list of confirmers |
|||
const confirmUrl = |
|||
this.apiServer + |
|||
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" + |
|||
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); |
|||
const confirmHeaders = await serverUtil.getHeaders(userDid); |
|||
const response = await this.axios.get(confirmUrl, { |
|||
headers: confirmHeaders, |
|||
}); |
|||
if (response.status === 200) { |
|||
const resultList1 = response.data.result || []; |
|||
//const publicUrls = resultList.publicUrls || []; |
|||
delete resultList1.publicUrls; |
|||
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); |
|||
const resultList3 = R.reject( |
|||
(did: string) => did === this.giveDetails.agentDid, |
|||
resultList2, |
|||
); |
|||
this.confirmerIdList = resultList3; |
|||
this.numConfsNotVisible = resultList1.length - resultList2.length; |
|||
if (resultList3.length === resultList2.length) { |
|||
// the issuer was not in the "visible" list so they must be hidden |
|||
// so subtract them from the non-visible confirmers count |
|||
this.numConfsNotVisible = this.numConfsNotVisible - 1; |
|||
} |
|||
this.confsVisibleToIdList = |
|||
response.data.result.resultVisibleToDids || []; |
|||
} else { |
|||
this.confsVisibleErrorMessage = |
|||
"Had problems retrieving confirmations."; |
|||
} |
|||
} catch (error: unknown) { |
|||
const serverError = error as AxiosError; |
|||
console.error("Error retrieving claim:", serverError); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "Something went wrong retrieving claim data.", |
|||
}, |
|||
3000, |
|||
); |
|||
} |
|||
} |
|||
|
|||
confirmConfirmClaim() { |
|||
this.$notify( |
|||
{ |
|||
group: "modal", |
|||
type: "confirm", |
|||
title: "Confirm", |
|||
text: "Do you personally confirm that this is true?", |
|||
onYes: async () => { |
|||
await this.confirmClaim(); |
|||
}, |
|||
}, |
|||
-1, |
|||
); |
|||
} |
|||
|
|||
// similar code is found in ProjectViewView |
|||
async confirmClaim() { |
|||
// similar logic is found in endorser-mobile |
|||
const goodClaim = serverUtil.removeSchemaContext( |
|||
serverUtil.removeVisibleToDids( |
|||
serverUtil.addLastClaimOrHandleAsIdIfMissing( |
|||
this.veriClaim.claim, |
|||
this.veriClaim.id, |
|||
this.veriClaim.handleId, |
|||
), |
|||
), |
|||
); |
|||
const confirmationClaim: serverUtil.GenericVerifiableCredential = { |
|||
"@context": "https://schema.org", |
|||
"@type": "AgreeAction", |
|||
object: goodClaim, |
|||
}; |
|||
const result = await serverUtil.createAndSubmitClaim( |
|||
confirmationClaim, |
|||
this.activeDid, |
|||
this.apiServer, |
|||
this.axios, |
|||
); |
|||
if (result.type === "success") { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "success", |
|||
title: "Success", |
|||
text: "Confirmation submitted.", |
|||
}, |
|||
3000, |
|||
); |
|||
} else { |
|||
console.error("Got error submitting the confirmation:", result); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "There was a problem submitting the confirmation. See logs for more info.", |
|||
}, |
|||
5000, |
|||
); |
|||
} |
|||
} |
|||
|
|||
showClaimPage(claimId: string) { |
|||
const route = { |
|||
path: "/claim/" + encodeURIComponent(claimId), |
|||
}; |
|||
this.$router.push(route).then(async () => { |
|||
this.resetThisValues(); |
|||
await this.loadClaim(claimId, this.activeDid); |
|||
}); |
|||
} |
|||
|
|||
openFulfillGiftDialog() { |
|||
const giver: GiverReceiverInputInfo = { |
|||
did: libsUtil.offerGiverDid(this.veriClaim), |
|||
}; |
|||
(this.$refs.customGiveDialog as GiftedDialog).open( |
|||
giver, |
|||
undefined, |
|||
this.giveDetails.handleId, |
|||
"Offer fulfilled by " + (giver?.name || "someone not named"), |
|||
); |
|||
} |
|||
|
|||
copyToClipboard(name: string, text: string) { |
|||
useClipboard() |
|||
.copy(text) |
|||
.then(() => { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "toast", |
|||
title: "Copied", |
|||
text: (name || "That") + " was copied to the clipboard.", |
|||
}, |
|||
2000, |
|||
); |
|||
}); |
|||
} |
|||
|
|||
notifyWhyCannotConfirm() { |
|||
if (!isGiveAction(this.veriClaim)) { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Not A Give", |
|||
text: "This is not a giving action to confirm.", |
|||
}, |
|||
3000, |
|||
); |
|||
} else if (this.confirmerIdList.includes(this.activeDid)) { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Already Confirmed", |
|||
text: "You have already confirmed this claim.", |
|||
}, |
|||
3000, |
|||
); |
|||
} else if (this.giveDetails.agentDid == this.activeDid) { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Cannot Confirm", |
|||
text: "You cannot confirm this because you issued this claim.", |
|||
}, |
|||
3000, |
|||
); |
|||
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Cannot Confirm", |
|||
text: "You cannot confirm this because it contains hidden identifiers.", |
|||
}, |
|||
3000, |
|||
); |
|||
} else { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Cannot Confirm", |
|||
text: "You cannot confirm this claim.", |
|||
}, |
|||
3000, |
|||
); |
|||
} |
|||
} |
|||
|
|||
onClickShareClaim() { |
|||
window.navigator.share({ |
|||
title: "Help Connect Me", |
|||
text: "I'm trying to find the full details of this claim. Can you help me?", |
|||
url: this.windowLocation.href, |
|||
}); |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,313 @@ |
|||
<template> |
|||
<QuickNav selected="Contacts" /> |
|||
<TopMessage /> |
|||
|
|||
<!-- CONTENT --> |
|||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
|||
<!-- Breadcrumb --> |
|||
<div id="ViewBreadcrumb" class="mb-8"> |
|||
<h1 class="text-lg text-center font-light relative px-7"> |
|||
<!-- Back --> |
|||
<button |
|||
@click="$router.go(-1)" |
|||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
|||
> |
|||
<fa icon="chevron-left" class="fa-fw"></fa> |
|||
</button> |
|||
Identifier Details |
|||
</h1> |
|||
</div> |
|||
|
|||
<!-- Identity Details --> |
|||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> |
|||
<div> |
|||
<h2 class="text-xl font-semibold"> |
|||
{{ |
|||
didInfoForContact(viewingDid, activeDid, contact, allMyDids) |
|||
.displayName |
|||
}} |
|||
</h2> |
|||
<span class="mt-2 text-xl font-semibold break-words"> |
|||
{{ viewingDid }} |
|||
</span> |
|||
</div> |
|||
<div class="flex justify-center mt-4"> |
|||
<span v-if="contact?.profileImageUrl" class="flex justify-between"> |
|||
<EntityIcon |
|||
:icon-size="96" |
|||
:profileImageUrl="contact?.profileImageUrl" |
|||
class="inline-block align-text-bottom border border-slate-300 rounded" |
|||
@click="showLargeIdenticonUrl = contact?.profileImageUrl" |
|||
/> |
|||
</span> |
|||
</div> |
|||
<div class="mt-4"> |
|||
<div class="flex justify-center">Auto-Generated Icon:</div> |
|||
<div class="flex justify-center"> |
|||
<EntityIcon |
|||
:entityId="viewingDid" |
|||
:iconSize="64" |
|||
class="inline-block align-middle border border-slate-300 rounded-md mr-1" |
|||
@click="showLargeIdenticonId = viewingDid" |
|||
/> |
|||
</div> |
|||
</div> |
|||
<div |
|||
v-if="showLargeIdenticonId || showLargeIdenticonUrl" |
|||
class="fixed z-[100] top-0 inset-x-0 w-full" |
|||
> |
|||
<div |
|||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" |
|||
> |
|||
<EntityIcon |
|||
:entityId="showLargeIdenticonId" |
|||
:iconSize="512" |
|||
:profileImageUrl="showLargeIdenticonUrl" |
|||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" |
|||
@click=" |
|||
showLargeIdenticonId = undefined; |
|||
showLargeIdenticonUrl = undefined; |
|||
" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Loading Animation --> |
|||
<div |
|||
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full" |
|||
v-if="isLoading" |
|||
> |
|||
<fa icon="spinner" class="fa-spin-pulse"></fa> |
|||
</div> |
|||
<!-- Results List --> |
|||
<div v-if="claims.length > 0" class="mt-4"> |
|||
<div class="text-l font-bold text-center">Claims That Involve Them</div> |
|||
</div> |
|||
<InfiniteScroll @reached-bottom="loadMoreData"> |
|||
<ul> |
|||
<li |
|||
class="border-b border-slate-300" |
|||
v-for="claim in claims" |
|||
:key="claim.handleId" |
|||
> |
|||
<div class="grid grid-cols-12 gap-4"> |
|||
<span class="col-span-2"> |
|||
{{ claim.issuedAt.substring(0, 10) }} |
|||
</span> |
|||
<span class="col-span-2"> |
|||
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }} |
|||
</span> |
|||
<span class="col-span-2"> |
|||
{{ claimAmount(claim) }} |
|||
</span> |
|||
<span class="col-span-5"> |
|||
{{ claimDescription(claim) }} |
|||
</span> |
|||
<span class="col-span-1"> |
|||
<a |
|||
@click="onClickLoadClaim(claim.handleId)" |
|||
class="cursor-pointer" |
|||
> |
|||
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> |
|||
</a> |
|||
</span> |
|||
</div> |
|||
</li> |
|||
</ul> |
|||
</InfiniteScroll> |
|||
|
|||
<div |
|||
v-if="!isLoading && claims.length === 0" |
|||
class="flex justify-center mt-4" |
|||
> |
|||
<span>They Are in No Claims Visible to You</span> |
|||
</div> |
|||
</section> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
|
|||
import QuickNav from "@/components/QuickNav.vue"; |
|||
import InfiniteScroll from "@/components/InfiniteScroll.vue"; |
|||
import TopMessage from "@/components/TopMessage.vue"; |
|||
import { NotificationIface } from "@/constants/app"; |
|||
import { accountsDB, db } from "@/db/index"; |
|||
import { Contact } from "@/db/tables/contacts"; |
|||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|||
import { |
|||
capitalizeAndInsertSpacesBeforeCaps, |
|||
didInfoForContact, |
|||
displayAmount, |
|||
getHeaders, |
|||
GenericCredWrapper, |
|||
GenericVerifiableCredential, |
|||
GiveVerifiableCredential, |
|||
OfferVerifiableCredential, |
|||
} from "@/libs/endorserServer"; |
|||
import EntityIcon from "@/components/EntityIcon.vue"; |
|||
|
|||
@Component({ |
|||
components: { |
|||
EntityIcon, |
|||
InfiniteScroll, |
|||
QuickNav, |
|||
TopMessage, |
|||
}, |
|||
}) |
|||
export default class DIDView extends Vue { |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
activeDid = ""; |
|||
allMyDids: Array<string> = []; |
|||
apiServer = ""; |
|||
claims: Array<GenericCredWrapper> = []; |
|||
contact?: Contact; |
|||
hitEnd = false; |
|||
isLoading = false; |
|||
searchBox: { name: string; bbox: BoundingBox } | null = null; |
|||
showLargeIdenticonId?: string; |
|||
showLargeIdenticonUrl?: string; |
|||
viewingDid?: string; |
|||
|
|||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; |
|||
didInfoForContact = didInfoForContact; |
|||
displayAmount = displayAmount; |
|||
|
|||
async mounted() { |
|||
await db.open(); |
|||
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
|||
this.activeDid = (settings?.activeDid as string) || ""; |
|||
this.apiServer = (settings?.apiServer as string) || ""; |
|||
|
|||
const pathParam = window.location.pathname.substring("/did/".length); |
|||
if (pathParam) { |
|||
this.viewingDid = decodeURIComponent(pathParam); |
|||
this.contact = await db.contacts.get(this.viewingDid); |
|||
await this.loadClaimsAbout(); |
|||
} else { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "No claim ID was provided.", |
|||
}, |
|||
-1, |
|||
); |
|||
} |
|||
|
|||
await accountsDB.open(); |
|||
const allAccounts = await accountsDB.accounts.toArray(); |
|||
this.allMyDids = allAccounts.map((acc) => acc.did); |
|||
} |
|||
|
|||
/** |
|||
* Data loader used by infinite scroller |
|||
* @param payload is the flag from the InfiniteScroll indicating if it should load |
|||
**/ |
|||
async loadMoreData(payload: boolean) { |
|||
if (this.claims.length > 0 && !this.hitEnd && payload) { |
|||
this.loadClaimsAbout(); |
|||
} |
|||
} |
|||
|
|||
public async loadClaimsAbout() { |
|||
if (!this.viewingDid) { |
|||
console.error("This should never be called without a DID."); |
|||
return; |
|||
} |
|||
|
|||
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid); |
|||
let postfix = ""; |
|||
if (this.claims.length > 0) { |
|||
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id; |
|||
} |
|||
|
|||
try { |
|||
this.isLoading = true; |
|||
const response = await fetch( |
|||
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix, |
|||
{ |
|||
method: "GET", |
|||
headers: await getHeaders(this.activeDid), |
|||
}, |
|||
); |
|||
|
|||
if (response.status !== 200) { |
|||
const details = await response.text(); |
|||
console.error("Problem with full search:", details); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: `There was a problem accessing the server. Try again later.`, |
|||
}, |
|||
5000, |
|||
); |
|||
return; |
|||
} |
|||
|
|||
const results = await response.json(); |
|||
this.claims = this.claims.concat(results.data); |
|||
this.hitEnd = !results.hitLimit; |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
|||
} catch (e: any) { |
|||
console.error("Error with feed load:", e); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: e.userMessage || "There was a problem retrieving claims.", |
|||
}, |
|||
-1, |
|||
); |
|||
} finally { |
|||
this.isLoading = false; |
|||
} |
|||
} |
|||
|
|||
onClickLoadClaim(jwtId: string) { |
|||
const route = { |
|||
path: "/claim/" + encodeURIComponent(jwtId), |
|||
}; |
|||
this.$router.push(route); |
|||
} |
|||
|
|||
public claimAmount(claim: GenericVerifiableCredential) { |
|||
if (claim.claimType === "GiveAction") { |
|||
const giveClaim = claim.claim as GiveVerifiableCredential; |
|||
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) { |
|||
return displayAmount( |
|||
giveClaim.object.unitCode, |
|||
giveClaim.object.amountOfThisGood, |
|||
); |
|||
} else { |
|||
return ""; |
|||
} |
|||
} else if (claim.claimType === "Offer") { |
|||
const offerClaim = claim.claim as OfferVerifiableCredential; |
|||
if ( |
|||
offerClaim.includesObject?.unitCode && |
|||
offerClaim.includesObject?.amountOfThisGood |
|||
) { |
|||
return displayAmount( |
|||
offerClaim.includesObject.unitCode, |
|||
offerClaim.includesObject.amountOfThisGood, |
|||
); |
|||
} else { |
|||
return ""; |
|||
} |
|||
} |
|||
return ""; |
|||
} |
|||
|
|||
claimDescription(claim: GenericVerifiableCredential) { |
|||
return claim.claim.name || claim.claim.description || ""; |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,69 @@ |
|||
<template> |
|||
<!-- Don't include nav buttons since this is shown in a different window. --> |
|||
|
|||
<!-- CONTENT --> |
|||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
|||
<!-- Breadcrumb --> |
|||
<div class="mb-8"> |
|||
<!-- Don't include 'back' button since this is shown in a different window. --> |
|||
<!-- Heading --> |
|||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
|||
Time Safari Onboarding Instructions |
|||
</h1> |
|||
</div> |
|||
|
|||
<!-- eslint-disable prettier/prettier --> |
|||
<div class="ml-4"> |
|||
<h1 class="font-bold text-xl">Install</h1> |
|||
<div> |
|||
<p> |
|||
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> |
|||
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> |
|||
|
|||
</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 Help extends Vue {} |
|||
</script> |
@ -0,0 +1,207 @@ |
|||
<template> |
|||
<QuickNav /> |
|||
<!-- CONTENT --> |
|||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
|||
<!-- Heading --> |
|||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
|||
Image |
|||
</h1> |
|||
<div v-if="imageBlob"> |
|||
<div v-if="uploading" class="text-center mb-4"> |
|||
<fa icon="spinner" class="fa-spin-pulse" /> |
|||
</div> |
|||
<div v-else> |
|||
<div class="text-center mb-4">Choose how to use this image</div> |
|||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> |
|||
<button |
|||
@click="recordGift" |
|||
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
|||
> |
|||
<fa icon="gift" class="fa-fw" /> |
|||
Record a Gift |
|||
</button> |
|||
<button |
|||
@click="recordProfile" |
|||
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
|||
> |
|||
<fa icon="circle-user" class="fa-fw" /> |
|||
Save as Profile Image |
|||
</button> |
|||
<button |
|||
@click="cancel" |
|||
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
|||
> |
|||
<fa icon="ban" class="fa-fw" /> |
|||
Cancel |
|||
</button> |
|||
</div> |
|||
<PhotoDialog ref="photoDialog" /> |
|||
</div> |
|||
|
|||
<div class="flex justify-center"> |
|||
<img |
|||
:src="URL.createObjectURL(imageBlob)" |
|||
alt="Shared Image" |
|||
class="rounded mt-4" |
|||
/> |
|||
</div> |
|||
</div> |
|||
<div v-else class="text-center mb-4"> |
|||
<p>No image found.</p> |
|||
</div> |
|||
</section> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import axios from "axios"; |
|||
import { Component, Vue } from "vue-facing-decorator"; |
|||
|
|||
import PhotoDialog from "@/components/PhotoDialog.vue"; |
|||
import QuickNav from "@/components/QuickNav.vue"; |
|||
import { |
|||
DEFAULT_IMAGE_API_SERVER, |
|||
IMAGE_TYPE_PROFILE, |
|||
NotificationIface, |
|||
} from "@/constants/app"; |
|||
import { db } from "@/db/index"; |
|||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|||
import { accessToken } from "@/libs/crypto"; |
|||
|
|||
@Component({ components: { PhotoDialog, QuickNav } }) |
|||
export default class SharedPhotoView extends Vue { |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
activeDid: string | undefined = undefined; |
|||
imageBlob: Blob | undefined = undefined; |
|||
imageFileName: string | undefined = undefined; |
|||
uploading = false; |
|||
|
|||
URL = window.URL || window.webkitURL; |
|||
|
|||
// 'created' hook runs when the Vue instance is first created |
|||
async mounted() { |
|||
try { |
|||
await db.open(); |
|||
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
|||
this.activeDid = settings?.activeDid as string; |
|||
|
|||
const temp = await db.temp.get("shared-photo"); |
|||
if (temp) { |
|||
this.imageBlob = temp.blob; |
|||
|
|||
// clear the temp image |
|||
db.temp.delete("shared-photo"); |
|||
|
|||
this.imageFileName = this.$route.query.fileName as string; |
|||
} |
|||
} catch (err: unknown) { |
|||
console.error("Got an error loading an identifier:", err); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "Got an error loading this data.", |
|||
}, |
|||
-1, |
|||
); |
|||
} |
|||
} |
|||
|
|||
async recordGift() { |
|||
await this.sendToImageServer("GiveAction").then((url) => { |
|||
if (url) { |
|||
this.$router.push({ |
|||
name: "gifted-details", |
|||
query: { |
|||
destinationNameAfter: "home", |
|||
hideBackButton: true, |
|||
imageUrl: url, |
|||
recipientDid: this.activeDid, |
|||
}, |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
recordProfile() { |
|||
(this.$refs.photoDialog as PhotoDialog).open( |
|||
async (imgUrl) => { |
|||
db.settings.update(MASTER_SETTINGS_KEY, { |
|||
profileImageUrl: imgUrl, |
|||
}); |
|||
this.$router.push({ name: "account" }); |
|||
}, |
|||
IMAGE_TYPE_PROFILE, |
|||
true, |
|||
this.imageBlob, |
|||
this.imageFileName, |
|||
); |
|||
} |
|||
|
|||
async cancel() { |
|||
this.imageBlob = undefined; |
|||
this.imageFileName = undefined; |
|||
this.$router.push({ name: "home" }); |
|||
} |
|||
|
|||
async sendToImageServer(imageType: string) { |
|||
this.uploading = true; |
|||
|
|||
let result; |
|||
try { |
|||
// send the image to the server |
|||
const token = await accessToken(this.activeDid); |
|||
const headers = { |
|||
Authorization: "Bearer " + token, |
|||
}; |
|||
const formData = new FormData(); |
|||
formData.append( |
|||
"image", |
|||
this.imageBlob as Blob, |
|||
this.imageFileName as string, |
|||
); |
|||
formData.append("claimType", imageType); |
|||
|
|||
const response = await axios.post( |
|||
DEFAULT_IMAGE_API_SERVER + "/image", |
|||
formData, |
|||
{ headers }, |
|||
); |
|||
if (response?.data?.url) { |
|||
this.imageBlob = undefined; |
|||
this.imageFileName = undefined; |
|||
result = response.data.url as string; |
|||
} else { |
|||
console.error("Problem uploading the image", response.data); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: |
|||
"There was a problem saving the picture. " + |
|||
(response?.data?.message || ""), |
|||
}, |
|||
5000, |
|||
); |
|||
} |
|||
|
|||
this.uploading = false; |
|||
} catch (error) { |
|||
console.error("Error uploading the image", error); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error", |
|||
text: "There was an error saving the picture.", |
|||
}, |
|||
5000, |
|||
); |
|||
this.uploading = false; |
|||
} |
|||
return result; |
|||
} |
|||
} |
|||
</script> |
@ -1,47 +1,35 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"allowJs": true, |
|||
"resolveJsonModule": true, |
|||
"target": "esnext", |
|||
"module": "esnext", |
|||
"strict": true, |
|||
"strictPropertyInitialization": false, |
|||
"jsx": "preserve", |
|||
"moduleResolution": "node", |
|||
"experimentalDecorators": true, |
|||
"skipLibCheck": true, |
|||
"esModuleInterop": true, |
|||
"allowSyntheticDefaultImports": true, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"useDefineForClassFields": true, |
|||
"sourceMap": true, |
|||
"baseUrl": "./src", |
|||
"types": [ |
|||
"webpack-env" |
|||
], |
|||
"paths": { |
|||
"@/components/*": ["components/*"], |
|||
"@/views/*": ["views/*"], |
|||
"@/db/*": ["db/*"], |
|||
"@/libs/*": ["libs/*"], |
|||
"@/constants/*": ["constants/*"], |
|||
"@/store/*": ["store/*"], |
|||
"compilerOptions": { |
|||
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers |
|||
"module": "ESNext", // Use ES modules |
|||
"strict": true, // Enable all strict type checking options |
|||
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler |
|||
"moduleResolution": "node", // Use Node.js style module resolution |
|||
"experimentalDecorators": true, |
|||
"esModuleInterop": true, // Enables compatibility with CommonJS modules for default imports |
|||
"allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export |
|||
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file |
|||
"useDefineForClassFields": true, |
|||
"sourceMap": true, |
|||
"baseUrl": "./src", // Base directory to resolve non-relative module names |
|||
"paths": { |
|||
"@/components/*": ["components/*"], |
|||
"@/views/*": ["views/*"], |
|||
"@/db/*": ["db/*"], |
|||
"@/libs/*": ["libs/*"], |
|||
"@/constants/*": ["constants/*"], |
|||
"@/store/*": ["store/*"] |
|||
}, |
|||
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs |
|||
}, |
|||
"lib": [ |
|||
"esnext", |
|||
"dom", |
|||
"dom.iterable", |
|||
"scripthost" |
|||
"include": [ |
|||
"src/**/*.ts", |
|||
"src/**/*.tsx", |
|||
"src/**/*.vue", |
|||
"tests/**/*.ts", |
|||
"tests/**/*.tsx" |
|||
], |
|||
"exclude": [ |
|||
"node_modules" |
|||
] |
|||
}, |
|||
"include": [ |
|||
"src/**/*.ts", |
|||
"src/**/*.tsx", |
|||
"src/**/*.vue", |
|||
"tests/**/*.ts", |
|||
"tests/**/*.tsx" |
|||
], |
|||
"exclude": [ |
|||
"node_modules" |
|||
] |
|||
} |
|||
|
@ -0,0 +1,53 @@ |
|||
import * as path from 'path'; |
|||
import { defineConfig } from 'vite'; |
|||
import { VitePWA } from 'vite-plugin-pwa'; |
|||
import vue from '@vitejs/plugin-vue'; |
|||
|
|||
// https://vitejs.dev/config/
|
|||
export default defineConfig({ |
|||
server: { |
|||
port: 8080 |
|||
}, |
|||
plugins: [ |
|||
vue(), |
|||
VitePWA({ |
|||
registerType: 'autoUpdate', |
|||
strategies: 'injectManifest', |
|||
srcDir: '.', |
|||
filename: 'sw_scripts-combined.js', |
|||
manifest: { |
|||
// This is used for the app name. It doesn't include a space, because iOS complains if i recall correctly.
|
|||
// There is a name with spaces in the constants/app.js file for use internally.
|
|||
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name, |
|||
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name, |
|||
// 192x192 and 512x512 are important for Chrome to show that it's installable
|
|||
"icons":[ |
|||
{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"}, |
|||
{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}, |
|||
{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"}, |
|||
{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"} |
|||
], |
|||
share_target: { |
|||
action: '/share-target', |
|||
method: 'POST', |
|||
enctype: 'multipart/form-data', |
|||
params: { |
|||
files: [ |
|||
{ |
|||
name: 'photo', |
|||
accept: ['image/*'], |
|||
}, |
|||
], |
|||
}, |
|||
}, |
|||
}, |
|||
}), |
|||
], |
|||
resolve: { |
|||
alias: { |
|||
'@': path.resolve(__dirname, './src'), |
|||
buffer: path.resolve(__dirname, 'node_modules', 'buffer'), |
|||
'dexie-export-import/dist/import': 'dexie-export-import/dist/import/index.js', |
|||
}, |
|||
}, |
|||
}); |
@ -1,45 +0,0 @@ |
|||
const { defineConfig } = require("@vue/cli-service"); |
|||
const { gitDescribeSync } = require("git-describe"); |
|||
const { exec } = require("child_process"); |
|||
|
|||
process.env.VUE_APP_GIT_HASH = gitDescribeSync().hash; |
|||
const TIME_SAFARI_APP_TITLE = |
|||
process.env.TIME_SAFARI_APP_TITLE || require("./package.json").name; |
|||
|
|||
module.exports = defineConfig({ |
|||
transpileDependencies: true, |
|||
configureWebpack: { |
|||
devtool: "source-map", |
|||
experiments: { |
|||
topLevelAwait: true, |
|||
}, |
|||
plugins: [ |
|||
{ |
|||
// Still don't know why this runs three times.
|
|||
apply: (compiler) => { |
|||
compiler.hooks.beforeCompile.tap("BeforeCompile", () => { |
|||
// Execute combine-sw.js script
|
|||
exec("node sw_combine.js", (error, stdout, stderr) => { |
|||
if (error || stderr) { |
|||
console.error("Service worker files error:", error || stderr); |
|||
} else { |
|||
console.log("Finished combining service worker files.", stdout); |
|||
} |
|||
}); |
|||
}); |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
pwa: { |
|||
name: TIME_SAFARI_APP_TITLE, |
|||
iconPaths: { |
|||
faviconSVG: "img/icons/safari-pinned-tab.svg", |
|||
}, |
|||
workboxPluginMode: "InjectManifest", |
|||
workboxOptions: { |
|||
// this script will be checked for linting (sw_scripts/* files generate about 1000 linting errors)
|
|||
swSrc: "./sw_scripts-combined.js", |
|||
}, |
|||
}, |
|||
}); |