Compare commits
137 Commits
master
...
kb/add-usa
@ -1,7 +1,3 @@ |
|||||
|
|
||||
# I tried setting values here and using `vue-cli-service build --mode development` |
# I tried and failed to set things here with vue-cli-service but |
||||
# but it didn't create some things in "dist": |
# things may be more reliable with vite so let's try again. |
||||
# - the "css" directory with the CSS extracted from Vue files |
|
||||
# - the sw_scripts-combined* files |
|
||||
# |
|
||||
# ¯\_(ツ)_/¯ |
|
||||
|
@ -1,4 +1,4 @@ |
|||||
# Only the variables that start with VUE_APP_ are seen in the application process.env in Vue. |
# Only the variables that start with VITE_ are seen in the application process.env in Vue. |
||||
VUE_APP_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H |
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H |
||||
VUE_APP_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch |
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch |
||||
VUE_APP_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app |
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,66 @@ |
|||||
|
# TimeSafari Docs |
||||
|
|
||||
|
## Generating PDF from Markdown on OSx |
||||
|
|
||||
|
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew. |
||||
|
|
||||
|
### Set Up |
||||
|
|
||||
|
```bash |
||||
|
# See https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x |
||||
|
brew install pandoc |
||||
|
|
||||
|
brew install basictex |
||||
|
|
||||
|
pandoc keystore-migration.md -o keystore-migration.pdf |
||||
|
|
||||
|
# Setting up LaTex packages |
||||
|
|
||||
|
# First update tlmgr |
||||
|
sudo tlmgr update --self |
||||
|
|
||||
|
# Then install LaTex packages |
||||
|
sudo tlmgr install titlesec |
||||
|
sudo tlmgr install framed |
||||
|
sudo tlmgr install threeparttable |
||||
|
sudo tlmgr install wrapfig |
||||
|
sudo tlmgr install multirow |
||||
|
sudo tlmgr install enumitem |
||||
|
sudo tlmgr install bbding |
||||
|
sudo tlmgr install titling # Required for the fancy headers used |
||||
|
sudo tlmgr install tabu |
||||
|
sudo tlmgr install mdframed |
||||
|
sudo tlmgr install tcolorbox |
||||
|
sudo tlmgr install textpos |
||||
|
sudo tlmgr install import |
||||
|
sudo tlmgr install varwidth |
||||
|
sudo tlmgr install needspace |
||||
|
sudo tlmgr install tocloft # Required for \tableofcontents generation |
||||
|
sudo tlmgr install ntheorem |
||||
|
sudo tlmgr install environ |
||||
|
sudo tlmgr install trimspaces |
||||
|
sudo tlmgr install lastpage # Enables Page X of Y |
||||
|
sudo tlmgr install collection-fontsrecommended # And set up fonts |
||||
|
sudo tlmgr install libertine # The main font the doc uses |
||||
|
|
||||
|
|
||||
|
``` |
||||
|
|
||||
|
### 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 |
||||
|
``` |
After Width: | Height: | Size: 61 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 77 KiB |
After Width: | Height: | Size: 140 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 34 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", |
"name": "TimeSafari", |
||||
"version": "0.3.4", |
"version": "0.3.15-beta", |
||||
"private": true, |
|
||||
"scripts": { |
"scripts": { |
||||
"serve": "vue-cli-service serve", |
"dev": "vite", |
||||
"build": "vue-cli-service build", |
"serve": "vite preview", |
||||
"lint": "vue-cli-service lint" |
"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": { |
"dependencies": { |
||||
"@dicebear/collection": "^5.3.5", |
"@dicebear/collection": "^5.4.1", |
||||
"@dicebear/core": "^5.3.5", |
"@dicebear/core": "^5.4.1", |
||||
"@ethersproject/hdnode": "^5.7.0", |
"@ethersproject/hdnode": "^5.7.0", |
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2", |
"@fortawesome/fontawesome-svg-core": "^6.5.1", |
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2", |
"@fortawesome/free-solid-svg-icons": "^6.5.1", |
||||
"@fortawesome/vue-fontawesome": "^3.0.3", |
"@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", |
"@pvermeer/dexie-encrypted-addon": "^3.0.0", |
||||
"@tweenjs/tween.js": "^21.0.0", |
"@simplewebauthn/browser": "^10.0.0", |
||||
"@types/js-yaml": "^4.0.9", |
"@simplewebauthn/server": "^10.0.0", |
||||
"@types/luxon": "^3.4.2", |
"@tweenjs/tween.js": "^21.1.1", |
||||
"@veramo/core": "^5.4.1", |
"@veramo/core": "^5.6.0", |
||||
"@veramo/credential-w3c": "^5.4.1", |
"@veramo/credential-w3c": "^5.6.0", |
||||
"@veramo/data-store": "^5.4.1", |
"@veramo/data-store": "^5.6.0", |
||||
"@veramo/did-manager": "^5.4.1", |
"@veramo/did-manager": "^5.6.0", |
||||
"@veramo/did-provider-ethr": "^5.4.1", |
"@veramo/did-provider-ethr": "^5.6.0", |
||||
"@veramo/did-resolver": "^5.4.1", |
"@veramo/did-provider-peer": "^6.0.0", |
||||
"@veramo/key-manager": "^5.4.1", |
"@veramo/did-resolver": "^5.6.0", |
||||
"@vueuse/core": "^10.4.1", |
"@veramo/key-manager": "^5.6.0", |
||||
|
"@vueuse/core": "^10.9.0", |
||||
"@zxing/text-encoding": "^0.9.0", |
"@zxing/text-encoding": "^0.9.0", |
||||
"axios": "^1.5.0", |
"asn1-ber": "^1.2.2", |
||||
"buffer": "^6.0.3", |
"axios": "^1.6.8", |
||||
|
"cbor-x": "^1.5.9", |
||||
"class-transformer": "^0.5.1", |
"class-transformer": "^0.5.1", |
||||
"core-js": "^3.32.1", |
"dexie": "^3.2.7", |
||||
"dexie": "^3.2.4", |
"dexie-export-import": "^4.1.1", |
||||
"dexie-export-import": "^4.0.7", |
"did-jwt": "^7.4.7", |
||||
"did-jwt": "^7.2.7", |
"ethereum-cryptography": "^2.1.3", |
||||
"ethereum-cryptography": "^2.1.2", |
|
||||
"ethereumjs-util": "^7.1.5", |
"ethereumjs-util": "^7.1.5", |
||||
"ethr-did-resolver": "^8.1.2", |
"ethr-did-resolver": "^8.1.2", |
||||
"git-describe": "^4.1.1", |
|
||||
"jdenticon": "^3.2.0", |
"jdenticon": "^3.2.0", |
||||
"js-generate-password": "^0.1.9", |
"js-generate-password": "^0.1.9", |
||||
"js-yaml": "^4.1.0", |
"js-yaml": "^4.1.0", |
||||
"localstorage-slim": "^2.5.0", |
"localstorage-slim": "^2.7.0", |
||||
|
"lru-cache": "^10.2.0", |
||||
"luxon": "^3.4.4", |
"luxon": "^3.4.4", |
||||
"merkletreejs": "^0.3.11", |
"merkletreejs": "^0.3.11", |
||||
"moment": "^2.29.4", |
|
||||
"notiwind": "^2.0.2", |
"notiwind": "^2.0.2", |
||||
"papaparse": "^5.4.1", |
"papaparse": "^5.4.1", |
||||
"pina": "^0.20.2204228", |
"pina": "^0.20.2204228", |
||||
"pinia-plugin-persistedstate": "^3.2.0", |
"pinia-plugin-persistedstate": "^3.2.1", |
||||
"qr-code-generator-vue3": "^1.4.21", |
"qr-code-generator-vue3": "^1.4.21", |
||||
"ramda": "^0.29.0", |
"ramda": "^0.29.1", |
||||
"readable-stream": "^4.4.2", |
"readable-stream": "^4.5.2", |
||||
"reflect-metadata": "^0.1.13", |
"reflect-metadata": "^0.1.14", |
||||
"register-service-worker": "^1.7.2", |
"register-service-worker": "^1.7.2", |
||||
"simple-vue-camera": "^1.1.3", |
"simple-vue-camera": "^1.1.3", |
||||
"three": "^0.156.1", |
"three": "^0.156.1", |
||||
"ua-parser-js": "^1.0.37", |
"ua-parser-js": "^1.0.37", |
||||
"util": "^0.12.5", |
"util": "^0.12.5", |
||||
"vue": "^3.3.4", |
"vue": "^3.4.21", |
||||
"vue-axios": "^3.5.2", |
"vue-axios": "^3.5.2", |
||||
"vue-facing-decorator": "^3.0.2", |
"vue-facing-decorator": "^3.0.4", |
||||
"vue-qrcode-reader": "^5.4.1", |
"vue-picture-cropper": "^0.7.0", |
||||
"vue-router": "^4.2.4", |
"vue-qrcode-reader": "^5.5.3", |
||||
|
"vue-router": "^4.3.0", |
||||
"web-did-resolver": "^2.0.27" |
"web-did-resolver": "^2.0.27" |
||||
}, |
}, |
||||
"devDependencies": { |
"devDependencies": { |
||||
"@types/leaflet": "^1.9.4", |
"@types/js-yaml": "^4.0.9", |
||||
"@types/ramda": "^0.29.3", |
"@types/leaflet": "^1.9.8", |
||||
|
"@types/luxon": "^3.4.2", |
||||
|
"@types/ramda": "^0.29.11", |
||||
"@types/three": "^0.155.1", |
"@types/three": "^0.155.1", |
||||
"@types/ua-parser-js": "^0.7.39", |
"@types/ua-parser-js": "^0.7.39", |
||||
"@typescript-eslint/eslint-plugin": "^6.6.0", |
"@typescript-eslint/eslint-plugin": "^6.21.0", |
||||
"@typescript-eslint/parser": "^6.6.0", |
"@typescript-eslint/parser": "^6.21.0", |
||||
|
"@vitejs/plugin-vue": "^5.0.4", |
||||
"@vue-leaflet/vue-leaflet": "^0.10.1", |
"@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", |
"@vue/eslint-config-typescript": "^11.0.3", |
||||
"autoprefixer": "^10.4.15", |
"autoprefixer": "^10.4.19", |
||||
"eslint": "^8.53.0", |
"eslint": "^8.57.0", |
||||
"eslint-config-prettier": "^9.0.0", |
"eslint-config-prettier": "^9.1.0", |
||||
"eslint-plugin-prettier": "^5.0.0", |
"eslint-plugin-prettier": "^5.1.3", |
||||
"eslint-plugin-vue": "^9.17.0", |
"eslint-plugin-vue": "^9.23.0", |
||||
"leaflet": "^1.9.4", |
"leaflet": "^1.9.4", |
||||
"postcss": "^8.4.29", |
"postcss": "^8.4.38", |
||||
"prettier": "^3.1.0", |
"prettier": "^3.2.5", |
||||
"tailwindcss": "^3.3.3", |
"tailwindcss": "^3.4.1", |
||||
"typescript": "~5.2.2" |
"typescript": "~5.2.2", |
||||
|
"vite": "^5.2.0", |
||||
|
"vite-plugin-pwa": "^0.19.8" |
||||
} |
} |
||||
} |
} |
||||
|
@ -1,178 +1,4 @@ |
|||||
|
|
||||
tasks : |
tasks : |
||||
|
|
||||
- bug - landscape doesn't show full camera |
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d |
||||
- 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 |
|
||||
|
@ -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,102 @@ |
|||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
|
||||
|
import { AsnParser } from "@peculiar/asn1-schema"; |
||||
|
import { ECDSASigValue } from "@peculiar/asn1-ecc"; |
||||
|
|
||||
|
/** |
||||
|
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart. |
||||
|
* |
||||
|
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
|
||||
|
*/ |
||||
|
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array { |
||||
|
const parsedSignature = AsnParser.parse(signature, ECDSASigValue); |
||||
|
let rBytes = new Uint8Array(parsedSignature.r); |
||||
|
let sBytes = new Uint8Array(parsedSignature.s); |
||||
|
|
||||
|
if (shouldRemoveLeadingZero(rBytes)) { |
||||
|
rBytes = rBytes.slice(1); |
||||
|
} |
||||
|
|
||||
|
if (shouldRemoveLeadingZero(sBytes)) { |
||||
|
sBytes = sBytes.slice(1); |
||||
|
} |
||||
|
|
||||
|
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]); |
||||
|
|
||||
|
return finalSignature; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence |
||||
|
* should be removed based on the following logic: |
||||
|
* |
||||
|
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0, |
||||
|
* then remove the leading 0x0 byte" |
||||
|
*/ |
||||
|
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { |
||||
|
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; |
||||
|
} |
||||
|
|
||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
|
||||
|
/** |
||||
|
* Combine multiple Uint8Arrays into a single Uint8Array |
||||
|
*/ |
||||
|
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array { |
||||
|
let pointer = 0; |
||||
|
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); |
||||
|
|
||||
|
const toReturn = new Uint8Array(totalLength); |
||||
|
|
||||
|
arrays.forEach((arr) => { |
||||
|
toReturn.set(arr, pointer); |
||||
|
pointer += arr.length; |
||||
|
}); |
||||
|
|
||||
|
return toReturn; |
||||
|
} |
||||
|
|
||||
|
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
|
||||
|
let webCrypto: unknown = undefined; |
||||
|
export function getWebCrypto() { |
||||
|
/** |
||||
|
* Hello there! If you came here wondering why this method is asynchronous when use of |
||||
|
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this |
||||
|
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()` |
||||
|
* become synchronous if we make this synchronous (since nothing else in that method is async) |
||||
|
* which represents a breaking API change in this library's core API. |
||||
|
* |
||||
|
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense |
||||
|
* to keep this method asynchronous. |
||||
|
*/ |
||||
|
const toResolve = new Promise((resolve, reject) => { |
||||
|
if (webCrypto) { |
||||
|
return resolve(webCrypto); |
||||
|
} |
||||
|
/** |
||||
|
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times |
||||
|
* support (and Node v20+) |
||||
|
*/ |
||||
|
const _globalThisCrypto = _getWebCryptoInternals.stubThisGlobalThisCrypto(); |
||||
|
if (_globalThisCrypto) { |
||||
|
webCrypto = _globalThisCrypto; |
||||
|
return resolve(webCrypto); |
||||
|
} |
||||
|
// We tried to access it both in Node and globally, so bail out
|
||||
|
return reject(new MissingWebCrypto()); |
||||
|
}); |
||||
|
return toResolve; |
||||
|
} |
||||
|
export class MissingWebCrypto extends Error { |
||||
|
constructor() { |
||||
|
const message = "An instance of the Crypto API could not be located"; |
||||
|
super(message); |
||||
|
this.name = "MissingWebCrypto"; |
||||
|
} |
||||
|
} |
||||
|
// Make it possible to stub return values during testing
|
||||
|
export const _getWebCryptoInternals = { |
||||
|
stubThisGlobalThisCrypto: () => globalThis.crypto, |
||||
|
// Make it possible to reset the `webCrypto` at the top of the file
|
||||
|
setCachedCrypto: (newCrypto: unknown) => { |
||||
|
webCrypto = newCrypto; |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,570 @@ |
|||||
|
import asn1 from "asn1-ber"; |
||||
|
import { Buffer } from "buffer/"; |
||||
|
import { decode as cborDecode } from "cbor-x"; |
||||
|
import { bytesToMultibase, JWTPayload, multibaseToBytes } from "did-jwt"; |
||||
|
import { DIDResolutionResult } from "did-resolver"; |
||||
|
import { sha256 } from "ethereum-cryptography/sha256.js"; |
||||
|
import { |
||||
|
startAuthentication, |
||||
|
startRegistration, |
||||
|
} from "@simplewebauthn/browser"; |
||||
|
import { |
||||
|
generateAuthenticationOptions, |
||||
|
generateRegistrationOptions, |
||||
|
verifyAuthenticationResponse, |
||||
|
verifyRegistrationResponse, |
||||
|
} from "@simplewebauthn/server"; |
||||
|
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse"; |
||||
|
import { |
||||
|
Base64URLString, |
||||
|
PublicKeyCredentialCreationOptionsJSON, |
||||
|
PublicKeyCredentialRequestOptionsJSON, |
||||
|
} from "@simplewebauthn/types"; |
||||
|
|
||||
|
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers"; |
||||
|
|
||||
|
const PEER_DID_PREFIX = "did:peer:"; |
||||
|
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0"; |
||||
|
export interface JWK { |
||||
|
kty: string; |
||||
|
crv: string; |
||||
|
x: string; |
||||
|
y: string; |
||||
|
} |
||||
|
|
||||
|
function toBase64Url(anythingB64: string) { |
||||
|
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
||||
|
} |
||||
|
|
||||
|
function arrayToBase64Url(anything: Uint8Array) { |
||||
|
return toBase64Url(Buffer.from(anything).toString("base64")); |
||||
|
} |
||||
|
|
||||
|
export async function registerCredential(passkeyName?: string) { |
||||
|
const options: PublicKeyCredentialCreationOptionsJSON = |
||||
|
await generateRegistrationOptions({ |
||||
|
rpName: "Time Safari", |
||||
|
rpID: window.location.hostname, |
||||
|
userName: passkeyName || "Time Safari User", |
||||
|
// Don't prompt users for additional information about the authenticator
|
||||
|
// (Recommended for smoother UX)
|
||||
|
attestationType: "none", |
||||
|
authenticatorSelection: { |
||||
|
// Defaults
|
||||
|
residentKey: "preferred", |
||||
|
userVerification: "preferred", |
||||
|
// Optional
|
||||
|
authenticatorAttachment: "platform", |
||||
|
}, |
||||
|
}); |
||||
|
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
||||
|
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
||||
|
const attResp = await startRegistration(options); |
||||
|
const verification = await verifyRegistrationResponse({ |
||||
|
response: attResp, |
||||
|
expectedChallenge: options.challenge, |
||||
|
expectedOrigin: window.location.origin, |
||||
|
expectedRPID: window.location.hostname, |
||||
|
}); |
||||
|
|
||||
|
// references for parsing auth data and getting the public key
|
||||
|
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
||||
|
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
||||
|
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
||||
|
|
||||
|
const credIdBase64Url = verification.registrationInfo?.credentialID as string; |
||||
|
if (attResp.rawId !== credIdBase64Url) { |
||||
|
console.log("Warning! The raw ID does not match the credential ID.") |
||||
|
} |
||||
|
const credIdHex = Buffer.from( |
||||
|
base64URLStringToArrayBuffer(credIdBase64Url), |
||||
|
).toString("hex"); |
||||
|
const { publicKeyJwk } = cborToKeys( |
||||
|
verification.registrationInfo?.credentialPublicKey as Uint8Array, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
authData: verification.registrationInfo?.attestationObject, |
||||
|
credIdHex: credIdHex, |
||||
|
publicKeyJwk: publicKeyJwk, |
||||
|
publicKeyBytes: verification.registrationInfo |
||||
|
?.credentialPublicKey as Uint8Array, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export function createPeerDid(publicKeyBytes: Uint8Array) { |
||||
|
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
|
||||
|
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
|
||||
|
const methodSpecificId = bytesToMultibase( |
||||
|
publicKeyBytes, |
||||
|
"base58btc", |
||||
|
"p256-pub", |
||||
|
); |
||||
|
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId; |
||||
|
} |
||||
|
|
||||
|
function peerDidToPublicKeyBytes(did: string) { |
||||
|
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length)); |
||||
|
} |
||||
|
|
||||
|
export class PeerSetup { |
||||
|
public authenticatorData?: ArrayBuffer; |
||||
|
public challenge?: Uint8Array; |
||||
|
public clientDataJsonBase64Url?: Base64URLString; |
||||
|
public signature?: Base64URLString; |
||||
|
|
||||
|
public async createJwtSimplewebauthn( |
||||
|
issuerDid: string, |
||||
|
payload: object, |
||||
|
credIdHex: string, |
||||
|
) { |
||||
|
const credentialId = arrayBufferToBase64URLString( |
||||
|
Buffer.from(credIdHex, "hex").buffer, |
||||
|
); |
||||
|
const fullPayload = { |
||||
|
...payload, |
||||
|
iat: Math.floor(Date.now() / 1000), |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); |
||||
|
// const payloadHash: Uint8Array = sha256(this.challenge);
|
||||
|
const options: PublicKeyCredentialRequestOptionsJSON = |
||||
|
await generateAuthenticationOptions({ |
||||
|
challenge: this.challenge, |
||||
|
rpID: window.location.hostname, |
||||
|
allowCredentials: [{ id: credentialId }], |
||||
|
}); |
||||
|
// console.log("simple authentication options", options);
|
||||
|
|
||||
|
const clientAuth = await startAuthentication(options); |
||||
|
// console.log("simple credential get", clientAuth);
|
||||
|
|
||||
|
const authenticatorDataBase64Url = clientAuth.response.authenticatorData; |
||||
|
this.authenticatorData = Buffer.from( |
||||
|
clientAuth.response.authenticatorData, |
||||
|
"base64", |
||||
|
).buffer; |
||||
|
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON; |
||||
|
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
||||
|
this.signature = clientAuth.response.signature; |
||||
|
|
||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
||||
|
const headerBase64 = Buffer.from(JSON.stringify(header)) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const dataInJwt = { |
||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url, |
||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
||||
|
iat: Math.floor(Date.now() / 1000), |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataInJwtString = JSON.stringify(dataInJwt); |
||||
|
const payloadBase64 = Buffer.from(dataInJwtString) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const signature = clientAuth.response.signature; |
||||
|
|
||||
|
return headerBase64 + "." + payloadBase64 + "." + signature; |
||||
|
} |
||||
|
|
||||
|
public async createJwtNavigator( |
||||
|
issuerDid: string, |
||||
|
payload: object, |
||||
|
credIdHex: string, |
||||
|
) { |
||||
|
const fullPayload = { |
||||
|
...payload, |
||||
|
iat: Math.floor(Date.now() / 1000), |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataToSignString = JSON.stringify(fullPayload); |
||||
|
const dataToSignBuffer = Buffer.from(dataToSignString); |
||||
|
const credentialId = Buffer.from(credIdHex, "hex"); |
||||
|
|
||||
|
// console.log("lower credentialId", credentialId);
|
||||
|
this.challenge = new Uint8Array(dataToSignBuffer); |
||||
|
const options = { |
||||
|
publicKey: { |
||||
|
allowCredentials: [ |
||||
|
{ |
||||
|
id: credentialId, |
||||
|
type: "public-key", |
||||
|
}, |
||||
|
], |
||||
|
challenge: this.challenge.buffer, |
||||
|
rpID: window.location.hostname, |
||||
|
userVerification: "preferred", |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const credential = await navigator.credentials.get(options); |
||||
|
// console.log("nav credential get", credential);
|
||||
|
|
||||
|
this.authenticatorData = credential?.response.authenticatorData; |
||||
|
const authenticatorDataBase64Url = arrayBufferToBase64URLString( |
||||
|
this.authenticatorData, |
||||
|
); |
||||
|
|
||||
|
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( |
||||
|
credential?.response.clientDataJSON, |
||||
|
); |
||||
|
|
||||
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
||||
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; |
||||
|
const headerBase64 = Buffer.from(JSON.stringify(header)) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const dataInJwt = { |
||||
|
AuthenticationDataB64URL: authenticatorDataBase64Url, |
||||
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url, |
||||
|
iat: Math.floor(Date.now() / 1000), |
||||
|
iss: issuerDid, |
||||
|
}; |
||||
|
const dataInJwtString = JSON.stringify(dataInJwt); |
||||
|
const payloadBase64 = Buffer.from(dataInJwtString) |
||||
|
.toString("base64") |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const origSignature = Buffer.from(credential?.response.signature) |
||||
|
.toString("base64") |
||||
|
this.signature = origSignature |
||||
|
.replace(/\+/g, "-") |
||||
|
.replace(/\//g, "_") |
||||
|
.replace(/=+$/, ""); |
||||
|
|
||||
|
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature; |
||||
|
return jwt; |
||||
|
} |
||||
|
|
||||
|
// return a low-level signing function, similar to createJWS approach
|
||||
|
// async webAuthnES256KSigner(credentialID: string) {
|
||||
|
// return async (data: string | Uint8Array) => {
|
||||
|
// // get signature from WebAuthn
|
||||
|
// const signature = await this.generateWebAuthnSignature(data);
|
||||
|
//
|
||||
|
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
||||
|
// const signatureBuffer = Buffer.from(signature);
|
||||
|
// console.log("lower signature inside signer", signature);
|
||||
|
// console.log("lower buffer signature inside signer", signatureBuffer);
|
||||
|
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
||||
|
// // Decode the DER-encoded signature to extract R and S values
|
||||
|
// const reader = new asn1.BerReader(signatureBuffer);
|
||||
|
// console.log("lower after reader");
|
||||
|
// reader.readSequence();
|
||||
|
// console.log("lower after read sequence");
|
||||
|
// const r = reader.readString(asn1.Ber.Integer, true);
|
||||
|
// console.log("lower after r");
|
||||
|
// const s = reader.readString(asn1.Ber.Integer, true);
|
||||
|
// console.log("lower after r & s");
|
||||
|
//
|
||||
|
// // Ensure R and S are 32 bytes each
|
||||
|
// const rBuffer = Buffer.from(r);
|
||||
|
// const sBuffer = Buffer.from(s);
|
||||
|
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
||||
|
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
||||
|
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
||||
|
// const rPadded =
|
||||
|
// rWithoutPrefix.length < 32
|
||||
|
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
||||
|
// : rWithoutPrefix;
|
||||
|
// const sPadded =
|
||||
|
// rWithoutPrefix.length < 32
|
||||
|
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
||||
|
// : sWithoutPrefix;
|
||||
|
//
|
||||
|
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
||||
|
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
||||
|
// console.log(
|
||||
|
// "lower combinedSignature",
|
||||
|
// combinedSignature.length,
|
||||
|
// combinedSignature,
|
||||
|
// );
|
||||
|
//
|
||||
|
// const combSig64 = combinedSignature.toString("base64");
|
||||
|
// console.log("lower combSig64", combSig64);
|
||||
|
// const combSig64Url = combSig64
|
||||
|
// .replace(/\+/g, "-")
|
||||
|
// .replace(/\//g, "_")
|
||||
|
// .replace(/=+$/, "");
|
||||
|
// console.log("lower combSig64Url", combSig64Url);
|
||||
|
// return combSig64Url;
|
||||
|
// };
|
||||
|
// }
|
||||
|
} |
||||
|
|
||||
|
// I'd love to use this but it doesn't verify.
|
||||
|
// Requires:
|
||||
|
// npm install @noble/curves
|
||||
|
// ... and this import:
|
||||
|
// import { p256 } from "@noble/curves/p256";
|
||||
|
export async function verifyJwtP256( |
||||
|
credIdHex: string, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authDataFromBase = Buffer.from(authenticatorData); |
||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
||||
|
const sigBuffer = Buffer.from(signature, "base64"); |
||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
|
||||
|
// Hash the client data
|
||||
|
const hash = sha256(clientDataFromBase); |
||||
|
|
||||
|
// Construct the preimage
|
||||
|
const preimage = Buffer.concat([authDataFromBase, hash]); |
||||
|
|
||||
|
const isValid = p256.verify( |
||||
|
finalSigBuffer, |
||||
|
new Uint8Array(preimage), |
||||
|
publicKeyBytes, |
||||
|
); |
||||
|
return isValid; |
||||
|
} |
||||
|
|
||||
|
export async function verifyJwtSimplewebauthn( |
||||
|
credIdHex: string, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authData = arrayToBase64Url(Buffer.from(authenticatorData)); |
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
const credId = arrayBufferToBase64URLString( |
||||
|
Buffer.from(credIdHex, "hex").buffer, |
||||
|
); |
||||
|
const authOpts: VerifyAuthenticationResponseOpts = { |
||||
|
authenticator: { |
||||
|
credentialID: credId, |
||||
|
credentialPublicKey: publicKeyBytes, |
||||
|
counter: 0, |
||||
|
}, |
||||
|
expectedChallenge: arrayToBase64Url(challenge), |
||||
|
expectedOrigin: window.location.origin, |
||||
|
expectedRPID: window.location.hostname, |
||||
|
response: { |
||||
|
authenticatorAttachment: "platform", |
||||
|
clientExtensionResults: {}, |
||||
|
id: credId, |
||||
|
rawId: credId, |
||||
|
response: { |
||||
|
authenticatorData: authData, |
||||
|
clientDataJSON: clientDataJsonBase64Url, |
||||
|
signature: signature, |
||||
|
}, |
||||
|
type: "public-key", |
||||
|
}, |
||||
|
}; |
||||
|
const verification = await verifyAuthenticationResponse(authOpts); |
||||
|
return verification.verified; |
||||
|
} |
||||
|
|
||||
|
export async function verifyJwtWebCrypto( |
||||
|
credId: Base64URLString, |
||||
|
issuerDid: string, |
||||
|
authenticatorData: ArrayBuffer, |
||||
|
challenge: Uint8Array, |
||||
|
clientDataJsonBase64Url: Base64URLString, |
||||
|
signature: Base64URLString, |
||||
|
) { |
||||
|
const authDataFromBase = Buffer.from(authenticatorData); |
||||
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); |
||||
|
const sigBuffer = Buffer.from(signature, "base64"); |
||||
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer); |
||||
|
|
||||
|
// Hash the client data
|
||||
|
const hash = sha256(clientDataFromBase); |
||||
|
|
||||
|
// Construct the preimage
|
||||
|
const preimage = Buffer.concat([authDataFromBase, hash]); |
||||
|
|
||||
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); |
||||
|
|
||||
|
const WebCrypto = await getWebCrypto(); |
||||
|
const verifyAlgorithm = { |
||||
|
name: "ECDSA", |
||||
|
hash: { name: "SHA-256" }, |
||||
|
}; |
||||
|
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk; |
||||
|
const keyAlgorithm = { |
||||
|
name: "ECDSA", |
||||
|
namedCurve: publicKeyJwk.crv, |
||||
|
}; |
||||
|
const publicKeyCryptoKey = await WebCrypto.subtle.importKey( |
||||
|
"jwk", |
||||
|
publicKeyJwk, |
||||
|
keyAlgorithm, |
||||
|
false, |
||||
|
["verify"], |
||||
|
); |
||||
|
const verified = await WebCrypto.subtle.verify( |
||||
|
verifyAlgorithm, |
||||
|
publicKeyCryptoKey, |
||||
|
finalSigBuffer, |
||||
|
preimage, |
||||
|
); |
||||
|
return verified; |
||||
|
} |
||||
|
|
||||
|
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { |
||||
|
if (!did.startsWith("did:peer:0z")) { |
||||
|
throw new Error( |
||||
|
"This only verifies a peer DID, method 0, encoded base58btc.", |
||||
|
); |
||||
|
} |
||||
|
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
|
||||
|
// (another reference is the @aviarytech/did-peer resolver)
|
||||
|
const id = did.split(":")[2]; |
||||
|
const multibase = id.slice(1); |
||||
|
const encnumbasis = multibase.slice(1); |
||||
|
const didDocument = { |
||||
|
"@context": [ |
||||
|
"https://www.w3.org/ns/did/v1", |
||||
|
"https://w3id.org/security/suites/jws-2020/v1", |
||||
|
], |
||||
|
assertionMethod: [did + "#" + encnumbasis], |
||||
|
authentication: [did + "#" + encnumbasis], |
||||
|
capabilityDelegation: [did + "#" + encnumbasis], |
||||
|
capabilityInvocation: [did + "#" + encnumbasis], |
||||
|
id: did, |
||||
|
keyAgreement: undefined, |
||||
|
service: undefined, |
||||
|
verificationMethod: [ |
||||
|
{ |
||||
|
controller: did, |
||||
|
id: did + "#" + encnumbasis, |
||||
|
publicKeyMultibase: multibase, |
||||
|
type: "EcdsaSecp256k1VerificationKey2019", |
||||
|
}, |
||||
|
], |
||||
|
}; |
||||
|
return { |
||||
|
didDocument, |
||||
|
didDocumentMetadata: {}, |
||||
|
didResolutionMetadata: { contentType: "application/did+ld+json" }, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// convert COSE public key to PEM format
|
||||
|
function COSEtoPEM(cose: Buffer) { |
||||
|
// const alg = cose.get(3); // Algorithm
|
||||
|
const x = cose[-2]; // x-coordinate
|
||||
|
const y = cose[-3]; // y-coordinate
|
||||
|
|
||||
|
// Ensure the coordinates are in the correct format
|
||||
|
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]); |
||||
|
|
||||
|
// Convert to PEM format
|
||||
|
const pem = `-----BEGIN PUBLIC KEY-----
|
||||
|
${pubKeyBuffer.toString("base64")} |
||||
|
-----END PUBLIC KEY-----`;
|
||||
|
|
||||
|
return pem; |
||||
|
} |
||||
|
|
||||
|
function base64urlDecode(input: string) { |
||||
|
input = input.replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); |
||||
|
const str = atob(input + pad); |
||||
|
const bytes = new Uint8Array(str.length); |
||||
|
for (let i = 0; i < str.length; i++) { |
||||
|
bytes[i] = str.charCodeAt(i); |
||||
|
} |
||||
|
return bytes.buffer; |
||||
|
} |
||||
|
|
||||
|
function base64urlEncode(buffer: ArrayBuffer) { |
||||
|
const str = String.fromCharCode(...new Uint8Array(buffer)); |
||||
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
||||
|
} |
||||
|
|
||||
|
// from @simplewebauthn/browser
|
||||
|
function arrayBufferToBase64URLString(buffer) { |
||||
|
const bytes = new Uint8Array(buffer); |
||||
|
let str = ""; |
||||
|
for (const charCode of bytes) { |
||||
|
str += String.fromCharCode(charCode); |
||||
|
} |
||||
|
const base64String = btoa(str); |
||||
|
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); |
||||
|
} |
||||
|
|
||||
|
// from @simplewebauthn/browser
|
||||
|
function base64URLStringToArrayBuffer(base64URLString: string) { |
||||
|
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/"); |
||||
|
const padLength = (4 - (base64.length % 4)) % 4; |
||||
|
const padded = base64.padEnd(base64.length + padLength, "="); |
||||
|
const binary = atob(padded); |
||||
|
const buffer = new ArrayBuffer(binary.length); |
||||
|
const bytes = new Uint8Array(buffer); |
||||
|
for (let i = 0; i < binary.length; i++) { |
||||
|
bytes[i] = binary.charCodeAt(i); |
||||
|
} |
||||
|
return buffer; |
||||
|
} |
||||
|
|
||||
|
function cborToKeys(publicKeyBytes: Uint8Array) { |
||||
|
const jwkObj = cborDecode(publicKeyBytes); |
||||
|
if ( |
||||
|
jwkObj[1] != 2 || // kty "EC"
|
||||
|
jwkObj[3] != -7 || // alg "ES256"
|
||||
|
jwkObj[-1] != 1 || // crv "P-256"
|
||||
|
jwkObj[-2].length != 32 || // x
|
||||
|
jwkObj[-3].length != 32 // y
|
||||
|
) { |
||||
|
throw new Error("Unable to extract key."); |
||||
|
} |
||||
|
const publicKeyJwk = { |
||||
|
alg: "ES256", |
||||
|
crv: "P-256", |
||||
|
kty: "EC", |
||||
|
x: arrayToBase64Url(jwkObj[-2]), |
||||
|
y: arrayToBase64Url(jwkObj[-3]), |
||||
|
}; |
||||
|
const publicKeyBuffer = Buffer.concat([ |
||||
|
Buffer.from(jwkObj[-2]), |
||||
|
Buffer.from(jwkObj[-3]), |
||||
|
]); |
||||
|
return { publicKeyJwk, publicKeyBuffer }; |
||||
|
} |
||||
|
|
||||
|
async function pemToCryptoKey(pem: string) { |
||||
|
const binaryDerString = atob( |
||||
|
pem |
||||
|
.split("\n") |
||||
|
.filter((x) => !x.includes("-----")) |
||||
|
.join(""), |
||||
|
); |
||||
|
const binaryDer = new Uint8Array(binaryDerString.length); |
||||
|
for (let i = 0; i < binaryDerString.length; i++) { |
||||
|
binaryDer[i] = binaryDerString.charCodeAt(i); |
||||
|
} |
||||
|
// console.log("binaryDer", binaryDer.buffer);
|
||||
|
return await window.crypto.subtle.importKey( |
||||
|
"spki", |
||||
|
binaryDer.buffer, |
||||
|
{ |
||||
|
name: "RSASSA-PKCS1-v1_5", |
||||
|
hash: "SHA-256", |
||||
|
}, |
||||
|
true, |
||||
|
["verify"], |
||||
|
); |
||||
|
} |
@ -0,0 +1,878 @@ |
|||||
|
<template> |
||||
|
<QuickNav /> |
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Breadcrumb --> |
||||
|
<div id="ViewBreadcrumb" class="mb-8"> |
||||
|
<h1 class="text-lg text-center font-light relative px-7"> |
||||
|
<!-- Back --> |
||||
|
<button |
||||
|
@click="$router.go(-1)" |
||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="fa-fw" /> |
||||
|
</button> |
||||
|
<span |
||||
|
v-if=" |
||||
|
libsUtil.isGiveRecordTheUserCanConfirm( |
||||
|
veriClaim, |
||||
|
activeDid, |
||||
|
confirmerIdList, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
Do you agree? |
||||
|
</span> |
||||
|
<span v-else> Details </span> |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="giveDetails"> |
||||
|
<div class="flex justify-center"> |
||||
|
<button |
||||
|
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" |
||||
|
v-if=" |
||||
|
libsUtil.isGiveRecordTheUserCanConfirm( |
||||
|
veriClaim, |
||||
|
activeDid, |
||||
|
confirmerIdList, |
||||
|
) |
||||
|
" |
||||
|
@click="confirmConfirmClaim()" |
||||
|
> |
||||
|
Confirm |
||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> |
||||
|
</button> |
||||
|
<button |
||||
|
v-else |
||||
|
@click="notifyWhyCannotConfirm()" |
||||
|
class="col-span-1 bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" |
||||
|
> |
||||
|
Confirm |
||||
|
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> |
||||
|
</button> |
||||
|
<a |
||||
|
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md" |
||||
|
:href="urlForNewGive" |
||||
|
> |
||||
|
Record a Similar One |
||||
|
</a> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Details --> |
||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"> |
||||
|
<div class="block flex gap-4 overflow-hidden"> |
||||
|
<div class="overflow-hidden"> |
||||
|
<div class="text-sm"> |
||||
|
<div> |
||||
|
<fa icon="arrow-down" class="fa-fw text-slate-400" /> |
||||
|
{{ giverName }} |
||||
|
</div> |
||||
|
<div class="ml-6">gave</div> |
||||
|
<div v-if="giveDetails.amount"> |
||||
|
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" /> |
||||
|
{{ displayAmount(giveDetails.unit, giveDetails.amount) }} |
||||
|
</div> |
||||
|
<div v-if="giveDetails.description"> |
||||
|
<fa icon="message" class="fa-fw text-slate-400" /> |
||||
|
{{ giveDetails.amount ? "and:" : "" }} |
||||
|
{{ giveDetails.description }} |
||||
|
</div> |
||||
|
<div class="ml-6">to</div> |
||||
|
<div> |
||||
|
<fa icon="arrow-up" class="fa-fw text-slate-400" /> |
||||
|
{{ recipientName }} |
||||
|
</div> |
||||
|
<div> |
||||
|
<fa icon="calendar" class="fa-fw text-slate-400" /> |
||||
|
on |
||||
|
{{ giveDetails.issuedAt.substring(0, 10) }} |
||||
|
</div> |
||||
|
|
||||
|
<!-- Fullfills Links --> |
||||
|
|
||||
|
<!-- fullfills links for a give --> |
||||
|
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId"> |
||||
|
<router-link |
||||
|
:to=" |
||||
|
'/project/' + |
||||
|
encodeURIComponent(giveDetails?.fulfillsPlanHandleId) |
||||
|
" |
||||
|
class="text-blue-500 mt-2 cursor-pointer" |
||||
|
target="_blank" |
||||
|
> |
||||
|
This fulfills a bigger plan |
||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" /> |
||||
|
</router-link> |
||||
|
</div> |
||||
|
<!-- if there's another, it's probably fulfilling an offer, too --> |
||||
|
<div |
||||
|
v-if=" |
||||
|
giveDetails?.fulfillsType && |
||||
|
giveDetails?.fulfillsType !== 'PlanAction' && |
||||
|
giveDetails?.fulfillsHandleId |
||||
|
" |
||||
|
> |
||||
|
<!-- router-link to /claim/ only changes URL path --> |
||||
|
<router-link |
||||
|
:to=" |
||||
|
'/claim/' + |
||||
|
encodeURIComponent(giveDetails?.fulfillsHandleId) |
||||
|
" |
||||
|
class="text-blue-500 mt-2 cursor-pointer" |
||||
|
target="_blank" |
||||
|
> |
||||
|
This fulfills |
||||
|
{{ |
||||
|
capitalizeAndInsertSpacesBeforeCapsWithAPrefix( |
||||
|
giveDetails.fulfillsType, |
||||
|
) |
||||
|
}} |
||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" /> |
||||
|
</router-link> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="mt-2"> |
||||
|
<fa icon="comment" class="text-slate-400" /> |
||||
|
{{ issuerName }} posted that. |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="libsUtil.isGiveAction(veriClaim)" class="mt-4"> |
||||
|
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2> |
||||
|
|
||||
|
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span> |
||||
|
<span v-else-if="totalConfirmers() === 1"> |
||||
|
One person has confirmed this. |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
{{ totalConfirmers() }} people have confirmed this. |
||||
|
</span> |
||||
|
|
||||
|
<div v-if="totalConfirmers() > 0"> |
||||
|
<div |
||||
|
v-if=" |
||||
|
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0 |
||||
|
" |
||||
|
> |
||||
|
Nobody that you know confirmed this claim, nor do they have any |
||||
|
confirmers in their network. |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
v-if=" |
||||
|
confirmerIdList.length === 0 && confsVisibleToIdList.length > 0 |
||||
|
" |
||||
|
> |
||||
|
<!-- Only show if this person has links to confirmers (below). --> |
||||
|
Nobody that you know has issued or confirmed this claim. |
||||
|
</div> |
||||
|
<div v-if="confirmerIdList.length > 0"> |
||||
|
The following people have issued or confirmed this claim. |
||||
|
<ul class="ml-4"> |
||||
|
<li |
||||
|
v-for="confirmerId in confirmerIdList" |
||||
|
:key="confirmerId" |
||||
|
class="list-disc ml-4" |
||||
|
> |
||||
|
<div class="flex gap-4"> |
||||
|
<div class="grow overflow-hidden"> |
||||
|
<div class="text-sm"> |
||||
|
{{ didInfo(confirmerId) }} |
||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)"> |
||||
|
<button |
||||
|
@click=" |
||||
|
copyToClipboard( |
||||
|
'The DID of ' + confirmerId, |
||||
|
confirmerId, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
<fa icon="copy" class="text-slate-400 fa-fw" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
|
||||
|
<!-- |
||||
|
Never need to show this message: |
||||
|
"Nobody that you know can see someone who has confirmed this claim." |
||||
|
|
||||
|
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message. |
||||
|
If there is somebody in the confirmerIdList then that's all they need to show. |
||||
|
--> |
||||
|
|
||||
|
<!-- Now show anyone linked to confirmers. --> |
||||
|
<div v-if="confsVisibleToIdList.length > 0"> |
||||
|
The following people can connect you with people who have issued or |
||||
|
confirmed this claim. |
||||
|
<ul class="ml-4"> |
||||
|
<li |
||||
|
v-for="confsVisibleTo in confsVisibleToIdList" |
||||
|
:key="confsVisibleTo" |
||||
|
class="list-disc ml-4" |
||||
|
> |
||||
|
<div class="flex gap-4"> |
||||
|
<div class="grow overflow-hidden"> |
||||
|
<div class="text-sm"> |
||||
|
{{ didInfo(confsVisibleTo) }} |
||||
|
<span |
||||
|
v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)" |
||||
|
> |
||||
|
<button |
||||
|
@click=" |
||||
|
copyToClipboard( |
||||
|
'The DID of ' + confsVisibleTo, |
||||
|
confsVisibleTo, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
<fa icon="copy" class="text-slate-400 fa-fw" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- explain if user cannot confirm --> |
||||
|
<!-- Note that these conditions are mirrored in userCanConfirm(). --> |
||||
|
<div v-if="confirmerIdList.includes(activeDid)"> |
||||
|
You have confirmed this claim. |
||||
|
</div> |
||||
|
<div v-else-if="giveDetails.agentDid == activeDid"> |
||||
|
You cannot confirm this because you issued this claim, so you already |
||||
|
count as confirming it. |
||||
|
</div> |
||||
|
<div v-else-if="serverUtil.containsHiddenDid(veriClaim.claim)"> |
||||
|
You cannot confirm this because it contains hidden identifiers. |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<h2 |
||||
|
class="font-bold uppercase text-xl text-blue-500 mt-8 mb-2 cursor-pointer" |
||||
|
@click="showDetails = !showDetails" |
||||
|
> |
||||
|
{{ serverUtil.containsHiddenDid(veriClaim) ? "Visible " : "" }}Details |
||||
|
<span v-if="!showDetails"><fa icon="chevron-down" /></span> |
||||
|
<span v-else><fa icon="chevron-up" /></span> |
||||
|
</h2> |
||||
|
<div v-if="showDetails"> |
||||
|
<div |
||||
|
v-if=" |
||||
|
serverUtil.containsHiddenDid(veriClaim) && |
||||
|
R.isEmpty(veriClaimDidsVisible) |
||||
|
" |
||||
|
class="mb-2" |
||||
|
> |
||||
|
Some of the details are not visible to you; they show as "HIDDEN". |
||||
|
They are not visible to any of your direct contacts, either. |
||||
|
<span v-if="canShare"> |
||||
|
If you'd like to ask any of your contacts to take a look and see if |
||||
|
their contacts can see more details, |
||||
|
<a @click="onClickShareClaim()" class="text-blue-500" |
||||
|
>click to send them this info</a |
||||
|
> |
||||
|
and see if they are willing to make an introduction. |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
If you'd like to ask any of your contacts to take a look and see if |
||||
|
their contacts can see more details, |
||||
|
<a |
||||
|
@click="copyToClipboard('Location', windowLocation.href)" |
||||
|
class="text-blue-500" |
||||
|
>share this page with them</a |
||||
|
> |
||||
|
and see if they are willing to make an introduction. |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="!R.isEmpty(veriClaimDidsVisible)"> |
||||
|
Some of the details are not visible to you but they are visible to |
||||
|
some of your contacts. |
||||
|
<span v-if="canShare"> |
||||
|
If you'd like an introduction, |
||||
|
<a @click="onClickShareClaim()" class="text-blue-500" |
||||
|
>click to share the information with them and ask if they'll tell |
||||
|
you more about the participants.</a |
||||
|
> |
||||
|
</span> |
||||
|
<span v-else> |
||||
|
If you'd like an introduction, |
||||
|
<a |
||||
|
@click="copyToClipboard('Location', windowLocation.href)" |
||||
|
class="text-blue-500" |
||||
|
>share this page with them and ask if they'll tell you more about |
||||
|
about the participants.</a |
||||
|
> |
||||
|
</span> |
||||
|
|
||||
|
<div |
||||
|
v-for="(visibleDidPath, index) of Object.keys(veriClaimDidsVisible)" |
||||
|
:key="index" |
||||
|
class="list-disc p-4" |
||||
|
> |
||||
|
<div class="text-sm"> |
||||
|
<fa icon="minus" class="fa-fw" /> |
||||
|
The {{ visibleDidPath }} is visible to: |
||||
|
</div> |
||||
|
<div class="ml-12 p-1"> |
||||
|
<ul> |
||||
|
<li |
||||
|
v-for="(visDid, idx2) of veriClaimDidsVisible[visibleDidPath]" |
||||
|
:key="idx2" |
||||
|
class="list-disc" |
||||
|
> |
||||
|
<div class="text-sm mt-2"> |
||||
|
<span> |
||||
|
{{ didInfo(visDid) }} |
||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> |
||||
|
<button |
||||
|
@click=" |
||||
|
copyToClipboard('The DID of ' + visDid, visDid) |
||||
|
" |
||||
|
> |
||||
|
<fa icon="copy" class="text-slate-400 fa-fw" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
<span v-if="veriClaim.publicUrls?.[visDid]" |
||||
|
>, found at |
||||
|
<fa icon="globe" class="fa-fw text-slate-400" /> |
||||
|
<a |
||||
|
:href="veriClaim.publicUrls?.[visDid]" |
||||
|
class="text-blue-500" |
||||
|
>{{ |
||||
|
veriClaim.publicUrls[visDid].substring( |
||||
|
veriClaim.publicUrls[visDid].indexOf("//") + 2, |
||||
|
) |
||||
|
}} |
||||
|
</a> |
||||
|
</span> |
||||
|
</span> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> |
||||
|
<pre |
||||
|
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" |
||||
|
>{{ veriClaimDump }}</pre |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div v-else>This does not have details to confirm.</div> |
||||
|
|
||||
|
<div class="mt-4"> |
||||
|
<a |
||||
|
@click="showClaimPage(veriClaim.id)" |
||||
|
class="text-blue-500 cursor-pointer" |
||||
|
> |
||||
|
<fa icon="file-lines" class="pl-2" /> |
||||
|
All Generic Info |
||||
|
</a> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { AxiosError, RawAxiosRequestHeaders } from "axios"; |
||||
|
import * as yaml from "js-yaml"; |
||||
|
import * as R from "ramda"; |
||||
|
import { IIdentifier } from "@veramo/core"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { useClipboard } from "@vueuse/core"; |
||||
|
|
||||
|
import GiftedDialog from "@/components/GiftedDialog.vue"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { accountsDB, db } from "@/db/index"; |
||||
|
import { Account } from "@/db/tables/accounts"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; |
||||
|
import { accessToken } from "@/libs/crypto"; |
||||
|
import * as serverUtil from "@/libs/endorserServer"; |
||||
|
import { displayAmount, GiverReceiverInputInfo } from "@/libs/endorserServer"; |
||||
|
import * as libsUtil from "@/libs/util"; |
||||
|
import { isGiveAction } from "@/libs/util"; |
||||
|
|
||||
|
@Component({ |
||||
|
methods: { displayAmount }, |
||||
|
components: { GiftedDialog, QuickNav }, |
||||
|
}) |
||||
|
export default class ClaimView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
accountIdentityStr: string = "null"; |
||||
|
activeDid = ""; |
||||
|
allMyDids: Array<string> = []; |
||||
|
allContacts: Array<Contact> = []; |
||||
|
apiServer = ""; |
||||
|
|
||||
|
canShare = false; |
||||
|
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer |
||||
|
confsVisibleErrorMessage = ""; |
||||
|
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer |
||||
|
giveDetails = null; |
||||
|
giverName = ""; |
||||
|
issuerName = ""; |
||||
|
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible |
||||
|
recipientName = ""; |
||||
|
showDetails = false; |
||||
|
urlForNewGive = ""; |
||||
|
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; |
||||
|
veriClaimDump = ""; |
||||
|
veriClaimDidsVisible = {}; |
||||
|
windowLocation = window.location; |
||||
|
|
||||
|
R = R; |
||||
|
yaml = yaml; |
||||
|
libsUtil = libsUtil; |
||||
|
serverUtil = serverUtil; |
||||
|
|
||||
|
resetThisValues() { |
||||
|
this.confirmerIdList = []; |
||||
|
this.confsVisibleErrorMessage = ""; |
||||
|
this.confsVisibleToIdList = []; |
||||
|
this.giveDetails = null; |
||||
|
this.numConfsNotVisible = 0; |
||||
|
this.urlForNewGive = ""; |
||||
|
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; |
||||
|
this.veriClaimDump = ""; |
||||
|
} |
||||
|
|
||||
|
async mounted() { |
||||
|
await db.open(); |
||||
|
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; |
||||
|
this.activeDid = settings?.activeDid || ""; |
||||
|
this.apiServer = settings?.apiServer || ""; |
||||
|
this.allContacts = await db.contacts.toArray(); |
||||
|
|
||||
|
await accountsDB.open(); |
||||
|
const accounts = accountsDB.accounts; |
||||
|
const accountsArr: Array<Account> = await accounts?.toArray(); |
||||
|
this.allMyDids = accountsArr.map((acc) => acc.did); |
||||
|
const account = accountsArr.find((acc) => acc.did === this.activeDid); |
||||
|
this.accountIdentityStr = (account?.identity as string) || "null"; |
||||
|
const identity = JSON.parse(this.accountIdentityStr); |
||||
|
|
||||
|
const pathParam = window.location.pathname.substring( |
||||
|
"/confirm-gift/".length, |
||||
|
); |
||||
|
let claimId; |
||||
|
if (pathParam) { |
||||
|
claimId = decodeURIComponent(pathParam); |
||||
|
await this.loadClaim(claimId, identity); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "No claim ID was provided.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare |
||||
|
// then use this truer check: navigator.canShare && navigator.canShare() |
||||
|
this.canShare = !!navigator.share; |
||||
|
} |
||||
|
|
||||
|
// insert a space before any capital letters except the initial letter |
||||
|
// (and capitalize initial letter, just in case) |
||||
|
capitalizeAndInsertSpacesBeforeCaps(text: string) { |
||||
|
return !text |
||||
|
? "" |
||||
|
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); |
||||
|
} |
||||
|
|
||||
|
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) { |
||||
|
const word = this.capitalizeAndInsertSpacesBeforeCaps(text); |
||||
|
if (word) { |
||||
|
// if the word starts with a vowel, use "an" instead of "a" |
||||
|
const firstLetter = word[0].toLowerCase(); |
||||
|
const vowels = ["a", "e", "i", "o", "u"]; |
||||
|
const particle = vowels.includes(firstLetter) ? "an" : "a"; |
||||
|
return particle + " " + word; |
||||
|
} else { |
||||
|
return ""; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
totalConfirmers() { |
||||
|
return ( |
||||
|
this.numConfsNotVisible + |
||||
|
this.confirmerIdList.length + |
||||
|
this.confsVisibleToIdList.length |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public async getIdentity(activeDid: string): Promise<IIdentifier> { |
||||
|
await accountsDB.open(); |
||||
|
const account = (await accountsDB.accounts |
||||
|
.where("did") |
||||
|
.equals(activeDid) |
||||
|
.first()) as Account; |
||||
|
const identity = JSON.parse(account?.identity || "null"); |
||||
|
|
||||
|
if (!identity) { |
||||
|
throw new Error( |
||||
|
"Attempted to load project records with no identifier available.", |
||||
|
); |
||||
|
} |
||||
|
return identity; |
||||
|
} |
||||
|
|
||||
|
public async getHeaders(identity: IIdentifier) { |
||||
|
const headers: RawAxiosRequestHeaders = { |
||||
|
"Content-Type": "application/json", |
||||
|
}; |
||||
|
if (identity) { |
||||
|
const token = await accessToken(identity); |
||||
|
headers["Authorization"] = "Bearer " + token; |
||||
|
} |
||||
|
return headers; |
||||
|
} |
||||
|
|
||||
|
// Isn't there a better way to make this available to the template? |
||||
|
didInfo(did: string | undefined) { |
||||
|
return serverUtil.didInfo( |
||||
|
did, |
||||
|
this.activeDid, |
||||
|
this.allMyDids, |
||||
|
this.allContacts, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async loadClaim(claimId: string, identity: IIdentifier) { |
||||
|
const urlPath = libsUtil.isGlobalUri(claimId) |
||||
|
? "/api/claim/byHandle/" |
||||
|
: "/api/claim/"; |
||||
|
const url = this.apiServer + urlPath + encodeURIComponent(claimId); |
||||
|
|
||||
|
try { |
||||
|
const headers = await this.getHeaders(identity); |
||||
|
const resp = await this.axios.get(url, { headers }); |
||||
|
// resp.data is: |
||||
|
// - a Jwt from https://api.endorser.ch/api-docs/ |
||||
|
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3 |
||||
|
if (resp.status === 200) { |
||||
|
this.veriClaim = resp.data; |
||||
|
this.veriClaimDump = yaml.dump(this.veriClaim); |
||||
|
this.veriClaimDidsVisible = libsUtil.findAllVisibleToDids( |
||||
|
this.veriClaim, |
||||
|
true, |
||||
|
); |
||||
|
} else { |
||||
|
// actually, axios typically throws an error so we never get here |
||||
|
console.error("Error getting claim:", resp); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem retrieving that claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// retrieve more details on Give, Offer, or Plan |
||||
|
if (this.veriClaim.claimType !== "GiveAction") { |
||||
|
// no need to go further... this page is for gifts |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.issuerName = this.didInfo(this.veriClaim.issuer); |
||||
|
|
||||
|
// use give record when possible since it may include edits |
||||
|
const giveUrl = |
||||
|
this.apiServer + |
||||
|
"/api/v2/report/gives?handleId=" + |
||||
|
encodeURIComponent(this.veriClaim.handleId as string); |
||||
|
const giveHeaders = await this.getHeaders(identity); |
||||
|
const giveResp = await this.axios.get(giveUrl, { |
||||
|
headers: giveHeaders, |
||||
|
}); |
||||
|
// giveResp.data is a Give from https://api.endorser.ch/api-docs/ |
||||
|
if (giveResp.status === 200) { |
||||
|
this.giveDetails = giveResp.data.data[0]; |
||||
|
} else { |
||||
|
console.error("Error getting detailed give info:", giveResp); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Something went wrong retrieving gift data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
this.urlForNewGive = "/gifted-details?"; |
||||
|
if (this.giveDetails.amount) { |
||||
|
this.urlForNewGive += |
||||
|
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount)); |
||||
|
} |
||||
|
if (this.giveDetails.unit) { |
||||
|
this.urlForNewGive += |
||||
|
"&unitCode=" + encodeURIComponent(this.giveDetails.unit); |
||||
|
} |
||||
|
if (this.giveDetails.description) { |
||||
|
this.urlForNewGive += |
||||
|
"&description=" + encodeURIComponent(this.giveDetails.description); |
||||
|
} |
||||
|
this.giverName = this.didInfo(this.giveDetails.agentDid); |
||||
|
if (this.giveDetails.agentDid) { |
||||
|
this.urlForNewGive += |
||||
|
"&giverDid=" + |
||||
|
encodeURIComponent(this.giveDetails.agentDid) + |
||||
|
"&giverName=" + |
||||
|
encodeURIComponent(this.giverName); |
||||
|
} |
||||
|
this.recipientName = this.didInfo(this.giveDetails.recipientDid); |
||||
|
if (this.giveDetails.recipientDid) { |
||||
|
this.urlForNewGive += |
||||
|
"&recipientDid=" + |
||||
|
encodeURIComponent(this.giveDetails.recipientDid) + |
||||
|
"&recipientName=" + |
||||
|
encodeURIComponent(this.recipientName); |
||||
|
} |
||||
|
if (this.giveDetails.fullClaim.image) { |
||||
|
this.urlForNewGive += |
||||
|
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image); |
||||
|
} |
||||
|
if ( |
||||
|
this.giveDetails.type == "Offer" && |
||||
|
this.giveDetails.fulfillsHandleId |
||||
|
) { |
||||
|
this.urlForNewGive += |
||||
|
"&offerId=" + encodeURIComponent(this.giveDetails.fulfillsHandleId); |
||||
|
} |
||||
|
if (this.giveDetails.fulfillsPlanHandleId) { |
||||
|
this.urlForNewGive += |
||||
|
"&projectId=" + |
||||
|
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId); |
||||
|
} |
||||
|
|
||||
|
// retrieve the list of confirmers |
||||
|
const confirmUrl = |
||||
|
this.apiServer + |
||||
|
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" + |
||||
|
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); |
||||
|
const confirmHeaders = await this.getHeaders(identity); |
||||
|
const response = await this.axios.get(confirmUrl, { |
||||
|
headers: confirmHeaders, |
||||
|
}); |
||||
|
if (response.status === 200) { |
||||
|
const resultList1 = response.data.result || []; |
||||
|
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); |
||||
|
const resultList3 = R.reject( |
||||
|
(did: string) => did === this.giveDetails.agentDid, |
||||
|
resultList2, |
||||
|
); |
||||
|
this.confirmerIdList = resultList3; |
||||
|
this.numConfsNotVisible = resultList1.length - resultList2.length; |
||||
|
if (resultList3.length === resultList2.length) { |
||||
|
// the issuer was not in the "visible" list so they must be hidden |
||||
|
// so subtract them from the non-visible confirmers count |
||||
|
this.numConfsNotVisible = this.numConfsNotVisible - 1; |
||||
|
} |
||||
|
this.confsVisibleToIdList = |
||||
|
response.data.result.resultVisibleToDids || []; |
||||
|
} else { |
||||
|
this.confsVisibleErrorMessage = |
||||
|
"Had problems retrieving confirmations."; |
||||
|
} |
||||
|
} catch (error: unknown) { |
||||
|
const serverError = error as AxiosError; |
||||
|
console.error("Error retrieving claim:", serverError); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Something went wrong retrieving claim data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
confirmConfirmClaim() { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "modal", |
||||
|
type: "confirm", |
||||
|
title: "Confirm", |
||||
|
text: "Do you personally confirm that this is true?", |
||||
|
onYes: async () => { |
||||
|
await this.confirmClaim(); |
||||
|
}, |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// similar code is found in ProjectViewView |
||||
|
async confirmClaim() { |
||||
|
// similar logic is found in endorser-mobile |
||||
|
const goodClaim = serverUtil.removeSchemaContext( |
||||
|
serverUtil.removeVisibleToDids( |
||||
|
serverUtil.addLastClaimOrHandleAsIdIfMissing( |
||||
|
this.veriClaim.claim, |
||||
|
this.veriClaim.id, |
||||
|
this.veriClaim.handleId, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
const confirmationClaim: serverUtil.GenericVerifiableCredential = { |
||||
|
"@context": "https://schema.org", |
||||
|
"@type": "AgreeAction", |
||||
|
object: goodClaim, |
||||
|
}; |
||||
|
const result = await serverUtil.createAndSubmitClaim( |
||||
|
confirmationClaim, |
||||
|
await this.getIdentity(this.activeDid), |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
); |
||||
|
if (result.type === "success") { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Success", |
||||
|
text: "Confirmation submitted.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
console.error("Got error submitting the confirmation:", result); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem submitting the confirmation. See logs for more info.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
showClaimPage(claimId: string) { |
||||
|
const route = { |
||||
|
path: "/claim/" + encodeURIComponent(claimId), |
||||
|
}; |
||||
|
this.$router.push(route).then(async () => { |
||||
|
this.resetThisValues(); |
||||
|
await this.loadClaim(claimId, JSON.parse(this.accountIdentityStr)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
openFulfillGiftDialog() { |
||||
|
const giver: GiverReceiverInputInfo = { |
||||
|
did: libsUtil.offerGiverDid(this.veriClaim), |
||||
|
}; |
||||
|
(this.$refs.customGiveDialog as GiftedDialog).open( |
||||
|
giver, |
||||
|
undefined, |
||||
|
this.giveDetails.handleId, |
||||
|
"Offer fulfilled by " + (giver?.name || "someone not named"), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
copyToClipboard(name: string, text: string) { |
||||
|
useClipboard() |
||||
|
.copy(text) |
||||
|
.then(() => { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "toast", |
||||
|
title: "Copied", |
||||
|
text: (name || "That") + " was copied to the clipboard.", |
||||
|
}, |
||||
|
2000, |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
notifyWhyCannotConfirm() { |
||||
|
if (!isGiveAction(this.veriClaim)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Not A Give", |
||||
|
text: "This is not a giving action to confirm.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (this.confirmerIdList.includes(this.activeDid)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Already Confirmed", |
||||
|
text: "You have already confirmed this claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (this.giveDetails.agentDid == this.activeDid) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Cannot Confirm", |
||||
|
text: "You cannot confirm this because you issued this claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else if (serverUtil.containsHiddenDid(this.giveDetails.fullClaim)) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Cannot Confirm", |
||||
|
text: "You cannot confirm this because it contains hidden identifiers.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "info", |
||||
|
title: "Cannot Confirm", |
||||
|
text: "You cannot confirm this claim.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onClickShareClaim() { |
||||
|
window.navigator.share({ |
||||
|
title: "Help Connect Me", |
||||
|
text: "I'm trying to find the full details of this claim. Can you help me?", |
||||
|
url: this.windowLocation.href, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,337 @@ |
|||||
|
<template> |
||||
|
<QuickNav selected="Contacts" /> |
||||
|
<TopMessage /> |
||||
|
|
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Breadcrumb --> |
||||
|
<div id="ViewBreadcrumb" class="mb-8"> |
||||
|
<h1 class="text-lg text-center font-light relative px-7"> |
||||
|
<!-- Back --> |
||||
|
<button |
||||
|
@click="$router.go(-1)" |
||||
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" |
||||
|
> |
||||
|
<fa icon="chevron-left" class="fa-fw"></fa> |
||||
|
</button> |
||||
|
Identifier Details |
||||
|
</h1> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Identity Details --> |
||||
|
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> |
||||
|
<div> |
||||
|
<h2 class="text-xl font-semibold"> |
||||
|
{{ |
||||
|
didInfoForContact(viewingDid, activeDid, contact, allMyDids) |
||||
|
.displayName |
||||
|
}} |
||||
|
</h2> |
||||
|
<span class="mt-2 text-xl font-semibold break-words"> |
||||
|
{{ viewingDid }} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="flex justify-center mt-4"> |
||||
|
<span v-if="contact?.profileImageUrl" class="flex justify-between"> |
||||
|
<EntityIcon |
||||
|
:icon-size="96" |
||||
|
:profileImageUrl="contact?.profileImageUrl" |
||||
|
class="inline-block align-text-bottom border border-slate-300 rounded" |
||||
|
@click="showLargeIdenticonUrl = contact?.profileImageUrl" |
||||
|
/> |
||||
|
</span> |
||||
|
</div> |
||||
|
<div class="mt-4"> |
||||
|
<div class="flex justify-center">Auto-Generated Icon:</div> |
||||
|
<div class="flex justify-center"> |
||||
|
<EntityIcon |
||||
|
:entityId="viewingDid" |
||||
|
:iconSize="64" |
||||
|
class="inline-block align-middle border border-slate-300 rounded-md mr-1" |
||||
|
@click="showLargeIdenticonId = viewingDid" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div |
||||
|
v-if="showLargeIdenticonId || showLargeIdenticonUrl" |
||||
|
class="fixed z-[100] top-0 inset-x-0 w-full" |
||||
|
> |
||||
|
<div |
||||
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" |
||||
|
> |
||||
|
<EntityIcon |
||||
|
:entityId="showLargeIdenticonId" |
||||
|
:iconSize="512" |
||||
|
:profileImageUrl="showLargeIdenticonUrl" |
||||
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" |
||||
|
@click=" |
||||
|
showLargeIdenticonId = undefined; |
||||
|
showLargeIdenticonUrl = undefined; |
||||
|
" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Loading Animation --> |
||||
|
<div |
||||
|
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full" |
||||
|
v-if="isLoading" |
||||
|
> |
||||
|
<fa icon="spinner" class="fa-spin-pulse"></fa> |
||||
|
</div> |
||||
|
<!-- Results List --> |
||||
|
<div v-if="claims.length > 0" class="mt-4"> |
||||
|
<div class="text-l font-bold text-center">Claims That Involve Them</div> |
||||
|
</div> |
||||
|
<InfiniteScroll @reached-bottom="loadMoreData"> |
||||
|
<ul> |
||||
|
<li |
||||
|
class="border-b border-slate-300" |
||||
|
v-for="claim in claims" |
||||
|
:key="claim.handleId" |
||||
|
> |
||||
|
<div class="grid grid-cols-12 gap-4"> |
||||
|
<span class="col-span-2"> |
||||
|
{{ claim.issuedAt.substring(0, 10) }} |
||||
|
</span> |
||||
|
<span class="col-span-2"> |
||||
|
{{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }} |
||||
|
</span> |
||||
|
<span class="col-span-2"> |
||||
|
{{ claimAmount(claim) }} |
||||
|
</span> |
||||
|
<span class="col-span-5"> |
||||
|
{{ claimDescription(claim) }} |
||||
|
</span> |
||||
|
<span class="col-span-1"> |
||||
|
<a |
||||
|
@click="onClickLoadClaim(claim.handleId)" |
||||
|
class="cursor-pointer" |
||||
|
> |
||||
|
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> |
||||
|
</a> |
||||
|
</span> |
||||
|
</div> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</InfiniteScroll> |
||||
|
|
||||
|
<div |
||||
|
v-if="!isLoading && claims.length === 0" |
||||
|
class="flex justify-center mt-4" |
||||
|
> |
||||
|
<span>They Are in No Claims Visible to You</span> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import InfiniteScroll from "@/components/InfiniteScroll.vue"; |
||||
|
import TopMessage from "@/components/TopMessage.vue"; |
||||
|
import { NotificationIface } from "@/constants/app"; |
||||
|
import { accountsDB, db } from "@/db/index"; |
||||
|
import { Contact } from "@/db/tables/contacts"; |
||||
|
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
||||
|
import { accessToken } from "@/libs/crypto"; |
||||
|
import { |
||||
|
capitalizeAndInsertSpacesBeforeCaps, |
||||
|
didInfoForContact, |
||||
|
displayAmount, |
||||
|
GenericCredWrapper, |
||||
|
GenericVerifiableCredential, |
||||
|
GiveVerifiableCredential, |
||||
|
OfferVerifiableCredential, |
||||
|
} from "@/libs/endorserServer"; |
||||
|
import EntityIcon from "@/components/EntityIcon.vue"; |
||||
|
|
||||
|
@Component({ |
||||
|
components: { |
||||
|
EntityIcon, |
||||
|
InfiniteScroll, |
||||
|
QuickNav, |
||||
|
TopMessage, |
||||
|
}, |
||||
|
}) |
||||
|
export default class DIDView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
allMyDids: Array<string> = []; |
||||
|
apiServer = ""; |
||||
|
claims: Array<GenericCredWrapper> = []; |
||||
|
contact?: Contact; |
||||
|
hitEnd = false; |
||||
|
isLoading = false; |
||||
|
searchBox: { name: string; bbox: BoundingBox } | null = null; |
||||
|
showLargeIdenticonId?: string; |
||||
|
showLargeIdenticonUrl?: string; |
||||
|
viewingDid?: string; |
||||
|
|
||||
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; |
||||
|
didInfoForContact = didInfoForContact; |
||||
|
displayAmount = displayAmount; |
||||
|
|
||||
|
async mounted() { |
||||
|
await db.open(); |
||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
||||
|
this.activeDid = (settings?.activeDid as string) || ""; |
||||
|
this.apiServer = (settings?.apiServer as string) || ""; |
||||
|
|
||||
|
const pathParam = window.location.pathname.substring("/did/".length); |
||||
|
if (pathParam) { |
||||
|
this.viewingDid = decodeURIComponent(pathParam); |
||||
|
this.contact = await db.contacts.get(this.viewingDid); |
||||
|
await this.loadClaimsAbout(); |
||||
|
} else { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "No claim ID was provided.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await accountsDB.open(); |
||||
|
const allAccounts = await accountsDB.accounts.toArray(); |
||||
|
this.allMyDids = allAccounts.map((acc) => acc.did); |
||||
|
} |
||||
|
|
||||
|
public async buildHeaders(): Promise<HeadersInit> { |
||||
|
const headers: HeadersInit = { |
||||
|
"Content-Type": "application/json", |
||||
|
}; |
||||
|
|
||||
|
if (this.activeDid) { |
||||
|
await accountsDB.open(); |
||||
|
const allAccounts = await accountsDB.accounts.toArray(); |
||||
|
const account = allAccounts.find((acc) => acc.did === this.activeDid); |
||||
|
const identity = JSON.parse((account?.identity as string) || "null"); |
||||
|
|
||||
|
if (!identity) { |
||||
|
throw new Error( |
||||
|
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service. Switch your ID.", |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
headers["Authorization"] = "Bearer " + (await accessToken(identity)); |
||||
|
} else { |
||||
|
// it's OK without auth... we just won't get any identifiers |
||||
|
} |
||||
|
return headers; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Data loader used by infinite scroller |
||||
|
* @param payload is the flag from the InfiniteScroll indicating if it should load |
||||
|
**/ |
||||
|
async loadMoreData(payload: boolean) { |
||||
|
if (this.claims.length > 0 && !this.hitEnd && payload) { |
||||
|
this.loadClaimsAbout(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async loadClaimsAbout() { |
||||
|
if (!this.viewingDid) { |
||||
|
console.error("This should never be called without a DID."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid); |
||||
|
let postfix = ""; |
||||
|
if (this.claims.length > 0) { |
||||
|
postfix = "&beforeId=" + this.claims[this.claims.length - 1].id; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.isLoading = true; |
||||
|
const response = await fetch( |
||||
|
this.apiServer + "/api/v2/report/claims?" + queryParams + postfix, |
||||
|
{ |
||||
|
method: "GET", |
||||
|
headers: await this.buildHeaders(), |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (response.status !== 200) { |
||||
|
const details = await response.text(); |
||||
|
console.error("Problem with full search:", details); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: `There was a problem accessing the server. Try again later.`, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const results = await response.json(); |
||||
|
this.claims = this.claims.concat(results.data); |
||||
|
this.hitEnd = !results.hitLimit; |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
||||
|
} catch (e: any) { |
||||
|
console.error("Error with feed load:", e); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: e.userMessage || "There was a problem retrieving claims.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} finally { |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onClickLoadClaim(jwtId: string) { |
||||
|
const route = { |
||||
|
path: "/claim/" + encodeURIComponent(jwtId), |
||||
|
}; |
||||
|
this.$router.push(route); |
||||
|
} |
||||
|
|
||||
|
public claimAmount(claim: GenericVerifiableCredential) { |
||||
|
if (claim.claimType === "GiveAction") { |
||||
|
const giveClaim = claim.claim as GiveVerifiableCredential; |
||||
|
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) { |
||||
|
return displayAmount( |
||||
|
giveClaim.object.unitCode, |
||||
|
giveClaim.object.amountOfThisGood, |
||||
|
); |
||||
|
} else { |
||||
|
return ""; |
||||
|
} |
||||
|
} else if (claim.claimType === "Offer") { |
||||
|
const offerClaim = claim.claim as OfferVerifiableCredential; |
||||
|
if ( |
||||
|
offerClaim.includesObject?.unitCode && |
||||
|
offerClaim.includesObject?.amountOfThisGood |
||||
|
) { |
||||
|
return displayAmount( |
||||
|
offerClaim.includesObject.unitCode, |
||||
|
offerClaim.includesObject.amountOfThisGood, |
||||
|
); |
||||
|
} else { |
||||
|
return ""; |
||||
|
} |
||||
|
} |
||||
|
return ""; |
||||
|
} |
||||
|
|
||||
|
claimDescription(claim: GenericVerifiableCredential) { |
||||
|
return claim.claim.name || claim.claim.description || ""; |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -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,209 @@ |
|||||
|
<template> |
||||
|
<QuickNav /> |
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Heading --> |
||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> |
||||
|
Image |
||||
|
</h1> |
||||
|
<div v-if="imageBlob"> |
||||
|
<div v-if="uploading" class="text-center mb-4"> |
||||
|
<fa icon="spinner" class="fa-spin-pulse" /> |
||||
|
</div> |
||||
|
<div v-else> |
||||
|
<div class="text-center mb-4">Choose how to use this image</div> |
||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> |
||||
|
<button |
||||
|
@click="recordGift" |
||||
|
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
||||
|
> |
||||
|
<fa icon="gift" class="fa-fw" /> |
||||
|
Record a Gift |
||||
|
</button> |
||||
|
<button |
||||
|
@click="recordProfile" |
||||
|
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
||||
|
> |
||||
|
<fa icon="circle-user" class="fa-fw" /> |
||||
|
Save as Profile Image |
||||
|
</button> |
||||
|
<button |
||||
|
@click="cancel" |
||||
|
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" |
||||
|
> |
||||
|
<fa icon="ban" class="fa-fw" /> |
||||
|
Cancel |
||||
|
</button> |
||||
|
</div> |
||||
|
<PhotoDialog ref="photoDialog" /> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex justify-center"> |
||||
|
<img |
||||
|
:src="URL.createObjectURL(imageBlob)" |
||||
|
alt="Shared Image" |
||||
|
class="rounded mt-4" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div v-else class="text-center mb-4"> |
||||
|
<p>No image found.</p> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import axios from "axios"; |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
import PhotoDialog from "@/components/PhotoDialog.vue"; |
||||
|
import QuickNav from "@/components/QuickNav.vue"; |
||||
|
import { |
||||
|
DEFAULT_IMAGE_API_SERVER, |
||||
|
IMAGE_TYPE_PROFILE, |
||||
|
NotificationIface, |
||||
|
} from "@/constants/app"; |
||||
|
import { db } from "@/db/index"; |
||||
|
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
||||
|
import { getIdentity } from "@/libs/util"; |
||||
|
import { accessToken } from "@/libs/crypto"; |
||||
|
|
||||
|
@Component({ components: { PhotoDialog, QuickNav } }) |
||||
|
export default class SharedPhotoView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid: string | undefined = undefined; |
||||
|
imageBlob: Blob | undefined = undefined; |
||||
|
imageFileName: string | undefined = undefined; |
||||
|
uploading = false; |
||||
|
|
||||
|
URL = window.URL || window.webkitURL; |
||||
|
|
||||
|
// 'created' hook runs when the Vue instance is first created |
||||
|
async mounted() { |
||||
|
try { |
||||
|
await db.open(); |
||||
|
const settings = await db.settings.get(MASTER_SETTINGS_KEY); |
||||
|
this.activeDid = settings?.activeDid as string; |
||||
|
|
||||
|
const temp = await db.temp.get("shared-photo"); |
||||
|
if (temp) { |
||||
|
this.imageBlob = temp.blob; |
||||
|
|
||||
|
// clear the temp image |
||||
|
db.temp.delete("shared-photo"); |
||||
|
|
||||
|
this.imageFileName = this.$route.query.fileName as string; |
||||
|
} |
||||
|
} catch (err: unknown) { |
||||
|
console.error("Got an error loading an identifier:", err); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "Got an error loading this data.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async recordGift() { |
||||
|
await this.sendToImageServer("GiveAction").then((url) => { |
||||
|
if (url) { |
||||
|
this.$router.push({ |
||||
|
name: "gifted-details", |
||||
|
query: { |
||||
|
destinationNameAfter: "home", |
||||
|
hideBackButton: true, |
||||
|
imageUrl: url, |
||||
|
recipientDid: this.activeDid, |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
recordProfile() { |
||||
|
(this.$refs.photoDialog as PhotoDialog).open( |
||||
|
async (imgUrl) => { |
||||
|
db.settings.update(MASTER_SETTINGS_KEY, { |
||||
|
profileImageUrl: imgUrl, |
||||
|
}); |
||||
|
this.$router.push({ name: "account" }); |
||||
|
}, |
||||
|
IMAGE_TYPE_PROFILE, |
||||
|
true, |
||||
|
this.imageBlob, |
||||
|
this.imageFileName, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async cancel() { |
||||
|
this.imageBlob = undefined; |
||||
|
this.imageFileName = undefined; |
||||
|
this.$router.push({ name: "home" }); |
||||
|
} |
||||
|
|
||||
|
async sendToImageServer(imageType: string) { |
||||
|
this.uploading = true; |
||||
|
|
||||
|
let result; |
||||
|
try { |
||||
|
// send the image to the server |
||||
|
const identifier = await getIdentity(this.activeDid as string); |
||||
|
const token = await accessToken(identifier); |
||||
|
const headers = { |
||||
|
Authorization: "Bearer " + token, |
||||
|
}; |
||||
|
const formData = new FormData(); |
||||
|
formData.append( |
||||
|
"image", |
||||
|
this.imageBlob as Blob, |
||||
|
this.imageFileName as string, |
||||
|
); |
||||
|
formData.append("claimType", imageType); |
||||
|
|
||||
|
const response = await axios.post( |
||||
|
DEFAULT_IMAGE_API_SERVER + "/image", |
||||
|
formData, |
||||
|
{ headers }, |
||||
|
); |
||||
|
if (response?.data?.url) { |
||||
|
this.imageBlob = undefined; |
||||
|
this.imageFileName = undefined; |
||||
|
result = response.data.url as string; |
||||
|
} else { |
||||
|
console.error("Problem uploading the image", response.data); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: |
||||
|
"There was a problem saving the picture. " + |
||||
|
(response?.data?.message || ""), |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
this.uploading = false; |
||||
|
} catch (error) { |
||||
|
console.error("Error uploading the image", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was an error saving the picture.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
this.uploading = false; |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -1,47 +1,35 @@ |
|||||
{ |
{ |
||||
"compilerOptions": { |
"compilerOptions": { |
||||
"allowJs": true, |
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers |
||||
"resolveJsonModule": true, |
"module": "ESNext", // Use ES modules |
||||
"target": "esnext", |
"strict": true, // Enable all strict type checking options |
||||
"module": "esnext", |
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler |
||||
"strict": true, |
"moduleResolution": "node", // Use Node.js style module resolution |
||||
"strictPropertyInitialization": false, |
"experimentalDecorators": true, |
||||
"jsx": "preserve", |
"esModuleInterop": true, // Enables compatibility with CommonJS modules for default imports |
||||
"moduleResolution": "node", |
"allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export |
||||
"experimentalDecorators": true, |
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file |
||||
"skipLibCheck": true, |
"useDefineForClassFields": true, |
||||
"esModuleInterop": true, |
"sourceMap": true, |
||||
"allowSyntheticDefaultImports": true, |
"baseUrl": "./src", // Base directory to resolve non-relative module names |
||||
"forceConsistentCasingInFileNames": true, |
"paths": { |
||||
"useDefineForClassFields": true, |
"@/components/*": ["components/*"], |
||||
"sourceMap": true, |
"@/views/*": ["views/*"], |
||||
"baseUrl": "./src", |
"@/db/*": ["db/*"], |
||||
"types": [ |
"@/libs/*": ["libs/*"], |
||||
"webpack-env" |
"@/constants/*": ["constants/*"], |
||||
], |
"@/store/*": ["store/*"] |
||||
"paths": { |
}, |
||||
"@/components/*": ["components/*"], |
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs |
||||
"@/views/*": ["views/*"], |
|
||||
"@/db/*": ["db/*"], |
|
||||
"@/libs/*": ["libs/*"], |
|
||||
"@/constants/*": ["constants/*"], |
|
||||
"@/store/*": ["store/*"], |
|
||||
}, |
}, |
||||
"lib": [ |
"include": [ |
||||
"esnext", |
"src/**/*.ts", |
||||
"dom", |
"src/**/*.tsx", |
||||
"dom.iterable", |
"src/**/*.vue", |
||||
"scripthost" |
"tests/**/*.ts", |
||||
|
"tests/**/*.tsx" |
||||
|
], |
||||
|
"exclude": [ |
||||
|
"node_modules" |
||||
] |
] |
||||
}, |
|
||||
"include": [ |
|
||||
"src/**/*.ts", |
|
||||
"src/**/*.tsx", |
|
||||
"src/**/*.vue", |
|
||||
"tests/**/*.ts", |
|
||||
"tests/**/*.tsx" |
|
||||
], |
|
||||
"exclude": [ |
|
||||
"node_modules" |
|
||||
] |
|
||||
} |
} |
||||
|
@ -0,0 +1,51 @@ |
|||||
|
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: { |
||||
|
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", |
|
||||
}, |
|
||||
}, |
|
||||
}); |
|