diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..45e98fc --- /dev/null +++ b/doc/README.md @@ -0,0 +1,76 @@ +# TimeSafari Docs + +## Generating PDF from Markdown on OSx + +This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew. + +### Set Up + +```bash +brew install pandoc + +brew install basictex + +# Setting up LaTex packages + +# First update tlmgr +sudo tlmgr update --self + +# Then install LaTex packages +sudo tlmgr install bbding +sudo tlmgr install enumitem +sudo tlmgr install environ +sudo tlmgr install fancyhdr +sudo tlmgr install framed +sudo tlmgr install import +sudo tlmgr install lastpage # Enables Page X of Y +sudo tlmgr install mdframed +sudo tlmgr install multirow +sudo tlmgr install needspace +sudo tlmgr install ntheorem +sudo tlmgr install tabu +sudo tlmgr install tcolorbox +sudo tlmgr install textpos +sudo tlmgr install titlesec +sudo tlmgr install titling # Required for the fancy headers used +sudo tlmgr install threeparttable +sudo tlmgr install trimspaces +sudo tlmgr install tocloft # Required for \tableofcontents generation +sudo tlmgr install varwidth +sudo tlmgr install wrapfig + +# Install fonts +sudo tlmgr install cmbright +sudo tlmgr install collection-fontsrecommended # And set up fonts +sudo tlmgr install fira +sudo tlmgr install fontaxes +sudo tlmgr install libertine # The main font the doc uses +sudo tlmgr install opensans +sudo tlmgr install sourceserifpro + +``` + +#### References + +The following guide was adapted to this project except that we install with Brew and have a few more packages. + +Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x + +### Usage + +Use the `pandoc` command to generate a PDF. + +```bash +pandoc usage-guide.md -o usage-guide.pdf +``` + +And you can open the PDF with the `open` command. + +```bash +open usage-guide.pdf +``` + +Or use this one-liner +```bash +pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf +``` diff --git a/doc/images/01_infura-api-keys.png b/doc/images/01_infura-api-keys.png new file mode 100644 index 0000000..2ea7519 Binary files /dev/null and b/doc/images/01_infura-api-keys.png differ diff --git a/doc/images/02-infura-key-detail.png b/doc/images/02-infura-key-detail.png new file mode 100644 index 0000000..fa29d5c Binary files /dev/null and b/doc/images/02-infura-key-detail.png differ diff --git a/doc/images/03-infura-api-key-id.png b/doc/images/03-infura-api-key-id.png new file mode 100644 index 0000000..ea242fc Binary files /dev/null and b/doc/images/03-infura-api-key-id.png differ diff --git a/doc/images/04-pwa-chrome-devtools.png b/doc/images/04-pwa-chrome-devtools.png new file mode 100644 index 0000000..f339762 Binary files /dev/null and b/doc/images/04-pwa-chrome-devtools.png differ diff --git a/doc/images/05-pwa-account-button.png b/doc/images/05-pwa-account-button.png new file mode 100644 index 0000000..e96850e Binary files /dev/null and b/doc/images/05-pwa-account-button.png differ diff --git a/doc/images/06-pwa-account-page.png b/doc/images/06-pwa-account-page.png new file mode 100644 index 0000000..5f73f62 Binary files /dev/null and b/doc/images/06-pwa-account-page.png differ diff --git a/doc/images/07-pwa-did-copied.png b/doc/images/07-pwa-did-copied.png new file mode 100644 index 0000000..eeff266 Binary files /dev/null and b/doc/images/07-pwa-did-copied.png differ diff --git a/doc/images/08-endorser-sqlite-row-added.png b/doc/images/08-endorser-sqlite-row-added.png new file mode 100644 index 0000000..392a605 Binary files /dev/null and b/doc/images/08-endorser-sqlite-row-added.png differ diff --git a/doc/images/09-pwa-second-profile-first-open.png b/doc/images/09-pwa-second-profile-first-open.png new file mode 100644 index 0000000..9814318 Binary files /dev/null and b/doc/images/09-pwa-second-profile-first-open.png differ diff --git a/doc/images/10-pwa-second-user-did.png b/doc/images/10-pwa-second-user-did.png new file mode 100644 index 0000000..d03de1b Binary files /dev/null and b/doc/images/10-pwa-second-user-did.png differ diff --git a/doc/images/11-pwa-first-user-add-contact.png b/doc/images/11-pwa-first-user-add-contact.png new file mode 100644 index 0000000..d107ace Binary files /dev/null and b/doc/images/11-pwa-first-user-add-contact.png differ diff --git a/doc/images/12-pwa-first-user-contact-added.png b/doc/images/12-pwa-first-user-contact-added.png new file mode 100644 index 0000000..fe280cb Binary files /dev/null and b/doc/images/12-pwa-first-user-contact-added.png differ diff --git a/doc/images/13-pwa-first-user-register-second-user-btn.png b/doc/images/13-pwa-first-user-register-second-user-btn.png new file mode 100644 index 0000000..b1ead61 Binary files /dev/null and b/doc/images/13-pwa-first-user-register-second-user-btn.png differ diff --git a/doc/images/14-pwa-first-user-register-yes.png b/doc/images/14-pwa-first-user-register-yes.png new file mode 100644 index 0000000..fd402ba Binary files /dev/null and b/doc/images/14-pwa-first-user-register-yes.png differ diff --git a/doc/images/timesafari-logo-binoculars.png b/doc/images/timesafari-logo-binoculars.png new file mode 100644 index 0000000..172fd13 Binary files /dev/null and b/doc/images/timesafari-logo-binoculars.png differ diff --git a/doc/images/timesafari-logo.png b/doc/images/timesafari-logo.png new file mode 100644 index 0000000..ec8cb09 Binary files /dev/null and b/doc/images/timesafari-logo.png differ diff --git a/doc/usage-guide.md b/doc/usage-guide.md new file mode 100644 index 0000000..214ebf8 --- /dev/null +++ b/doc/usage-guide.md @@ -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. diff --git a/src/components/FeedFilters.vue b/src/components/FeedFilters.vue index e9cbac1..e9581fc 100644 --- a/src/components/FeedFilters.vue +++ b/src/components/FeedFilters.vue @@ -133,18 +133,18 @@ export default class FeedFilters extends Vue { this.visible = true; } - toggleHasVisibleDid() { + async toggleHasVisibleDid() { this.settingChanged = true; this.hasVisibleDid = !this.hasVisibleDid; - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { filterFeedByVisible: this.hasVisibleDid, }); } - toggleNearby() { + async toggleNearby() { this.settingChanged = true; this.isNearby = !this.isNearby; - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { filterFeedByNearby: this.isNearby, }); } @@ -154,7 +154,7 @@ export default class FeedFilters extends Vue { this.settingChanged = true; } - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { filterFeedByNearby: false, filterFeedByVisible: false, }); @@ -168,7 +168,7 @@ export default class FeedFilters extends Vue { this.settingChanged = true; } - db.settings.update(MASTER_SETTINGS_KEY, { + await db.settings.update(MASTER_SETTINGS_KEY, { filterFeedByNearby: true, filterFeedByVisible: true, }); diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 30274be..90cf77a 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -291,8 +291,8 @@ export default class GiftedDialog extends Vue { this.axios, this.apiServer, this.activeDid, - giverDid, - this.receiver?.did as string, + giverDid as string, + recipientDid as string, description, amount, unitCode, diff --git a/src/db/index.ts b/src/db/index.ts index 6bb59f2..7d60ebf 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -51,8 +51,8 @@ db.version(2).stores({ db.version(3).stores(TempSchema); // Event handler to initialize the non-sensitive database with default settings -db.on("populate", () => { - db.settings.add({ +db.on("populate", async () => { + await db.settings.add({ id: MASTER_SETTINGS_KEY, apiServer: DEFAULT_ENDORSER_API_SERVER, }); diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index ac5cde9..35c428f 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -26,6 +26,7 @@ export type Settings = { lastName?: string; // deprecated - put all names in firstName lastNotifiedClaimId?: string; lastViewedClaimId?: string; + passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes profileImageUrl?: string; reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders reminderOn?: boolean; // Toggle to enable or disable reminders @@ -46,7 +47,7 @@ export type Settings = { }; export function isAnyFeedFilterOn(settings: Settings): boolean { - return !!(settings.filterFeedByNearby || settings.filterFeedByVisible); + return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible); } /** @@ -60,3 +61,5 @@ export const SettingsSchema = { * Constants. */ export const MASTER_SETTINGS_KEY = 1; + +export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15; diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index 862ed54..2ac8aef 100644 --- a/src/libs/crypto/index.ts +++ b/src/libs/crypto/index.ts @@ -85,7 +85,7 @@ export const generateSeed = (): string => { }; /** - * Retreive an access token + * Retrieve an access token, or "" if no DID is provided. * * @return {*} */ diff --git a/src/libs/crypto/vc/index.ts b/src/libs/crypto/vc/index.ts index 308f71c..f18949f 100644 --- a/src/libs/crypto/vc/index.ts +++ b/src/libs/crypto/vc/index.ts @@ -67,7 +67,7 @@ export async function createEndorserJwtForKey( * The SimpleSigner returns a configured function for signing data. * * @example - * const signer = SimpleSigner(import.meta.env.PRIVATE_KEY) + * const signer = SimpleSigner(privateKeyHexString) * signer(data, (err, signature) => { * ... * }) diff --git a/src/libs/crypto/vc/passkeyDidPeer.ts b/src/libs/crypto/vc/passkeyDidPeer.ts index 920d751..5efc372 100644 --- a/src/libs/crypto/vc/passkeyDidPeer.ts +++ b/src/libs/crypto/vc/passkeyDidPeer.ts @@ -411,13 +411,21 @@ async function peerDidToDidDocument(did: string): Promise { } // this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types // (another reference is the @aviarytech/did-peer resolver) + + /** + * Looks like JsonWebKey2020 isn't too difficult: + * - change context security/suites link to jws-2020/v1 + * - change publicKeyMultibase to publicKeyJwk generated with cborToKeys + * - change type to JsonWebKey2020 + */ + const id = did.split(":")[2]; const multibase = id.slice(1); const encnumbasis = multibase.slice(1); const didDocument = { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/jws-2020/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1", ], assertionMethod: [did + "#" + encnumbasis], authentication: [did + "#" + encnumbasis], diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 26954cd..ce2b575 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -6,7 +6,7 @@ import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; import { Contact } from "@/db/tables/contacts"; import { accessToken } from "@/libs/crypto"; import { NonsensitiveDexie } from "@/db/index"; -import { getAccount } from "@/libs/util"; +import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util"; import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; @@ -48,29 +48,31 @@ export interface ClaimResult { } export interface GenericVerifiableCredential { - "@context": string; + "@context"?: string; "@type": string; [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } -export interface GenericCredWrapper extends GenericVerifiableCredential { +export interface GenericCredWrapper { + "@context": string; + "@type": string; + claim: T; + claimType?: string; handleId: string; id: string; issuedAt: string; issuer: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - claim: Record; - claimType?: string; } -export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = { - "@context": SCHEMA_ORG_CONTEXT, - "@type": "", - claim: {}, - handleId: "", - id: "", - issuedAt: "", - issuer: "", -}; +export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = + { + "@context": SCHEMA_ORG_CONTEXT, + "@type": "", + claim: { "@type": "" }, + handleId: "", + id: "", + issuedAt: "", + issuer: "", + }; // a summary record; the VC is found the fullClaim field export interface GiveSummaryRecord { @@ -123,7 +125,7 @@ export interface PlanSummaryRecord { // Note that previous VCs may have additional fields. // https://endorser.ch/doc/html/transactions.html#id4 -export interface GiveVerifiableCredential { +export interface GiveVerifiableCredential extends GenericVerifiableCredential { "@context"?: string; // optional when embedded, eg. in an Agree "@type": "GiveAction"; agent?: { identifier: string }; @@ -191,7 +193,7 @@ export interface PlanData { */ issuerDid: string; /** - * The Identier of the project -- different from jwtId, needs to be fixed + * The identifier of the project -- different from jwtId, needs to be fixed **/ rowid?: string; } @@ -447,12 +449,57 @@ export function didInfo( return didInfoForContact(did, activeDid, contact, allMyDids).displayName; } +let passkeyAccessToken: string = ""; +let passkeyTokenExpirationEpochSeconds: number = 0; + +export function clearPasskeyToken() { + passkeyAccessToken = ""; + passkeyTokenExpirationEpochSeconds = 0; +} + +export function tokenExpiryTimeDescription() { + if ( + !passkeyAccessToken || + passkeyTokenExpirationEpochSeconds < new Date().getTime() / 1000 + ) { + return "Token has expired"; + } else { + return ( + "Token expires at " + + new Date(passkeyTokenExpirationEpochSeconds * 1000).toLocaleString() + ); + } +} + +/** + * Get the headers for a request, potentially including Authorization + */ export async function getHeaders(did?: string) { const headers: { "Content-Type": string; Authorization?: string } = { "Content-Type": "application/json", }; if (did) { - const token = await accessToken(did); + let token; + const account = await getAccount(did); + if (account?.passkeyCredIdHex) { + if ( + passkeyAccessToken && + passkeyTokenExpirationEpochSeconds > Date.now() / 1000 + ) { + // there's an active current passkey token + token = passkeyAccessToken; + } else { + // there's no current passkey token or it's expired + token = await accessToken(did); + + passkeyAccessToken = token; + const passkeyExpirationSeconds = await getPasskeyExpirationSeconds(); + passkeyTokenExpirationEpochSeconds = + Date.now() / 1000 + passkeyExpirationSeconds; + } + } else { + token = await accessToken(did); + } headers["Authorization"] = "Bearer " + token; } else { // it's often OK to request without auth; we assume necessary checks are done earlier @@ -517,8 +564,9 @@ export async function setPlanInCache( /** * Construct GiveAction VC for submission to server */ -export function constructGive( - fromDid?: string | null, +export function hydrateGive( + vcClaimOrig?: GiveVerifiableCredential, + fromDid?: string, toDid?: string, description?: string, amount?: number, @@ -527,42 +575,68 @@ export function constructGive( fulfillsOfferHandleId?: string, isTrade: boolean = false, imageUrl?: string, + lastClaimId?: string, ): GiveVerifiableCredential { - const vcClaim: GiveVerifiableCredential = { - "@context": SCHEMA_ORG_CONTEXT, - "@type": "GiveAction", - recipient: toDid ? { identifier: toDid } : undefined, - agent: fromDid ? { identifier: fromDid } : undefined, - description: description || undefined, - object: amount - ? { amountOfThisGood: amount, unitCode: unitCode || "HUR" } - : undefined, - fulfills: [{ "@type": isTrade ? "TradeAction" : "DonateAction" }], - }; + // Remember: replace values or erase if it's null + + const vcClaim: GiveVerifiableCredential = vcClaimOrig + ? R.clone(vcClaimOrig) + : { + "@context": SCHEMA_ORG_CONTEXT, + "@type": "GiveAction", + }; + + if (lastClaimId) { + vcClaim.lastClaimId = lastClaimId; + delete vcClaim.identifier; + } + + vcClaim.agent = fromDid ? { identifier: fromDid } : undefined; + vcClaim.recipient = toDid ? { identifier: toDid } : undefined; + vcClaim.description = description || undefined; + vcClaim.object = amount + ? { amountOfThisGood: amount, unitCode: unitCode || "HUR" } + : undefined; + + // ensure fulfills is an array + if (!Array.isArray(vcClaim.fulfills)) { + vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : []; + } + // ... and replace or add each element, ending with Trade or Donate + // I realize this doesn't change any elements that are not PlanAction or Offer or Trade/Action. if (fulfillsProjectHandleId) { - vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this + vcClaim.fulfills = vcClaim.fulfills.filter( + (elem) => elem["@type"] !== "PlanAction", + ); vcClaim.fulfills.push({ "@type": "PlanAction", identifier: fulfillsProjectHandleId, }); } if (fulfillsOfferHandleId) { - vcClaim.fulfills = vcClaim.fulfills || []; // weird that it won't typecheck without this + vcClaim.fulfills = vcClaim.fulfills.filter( + (elem) => elem["@type"] !== "Offer", + ); vcClaim.fulfills.push({ "@type": "Offer", identifier: fulfillsOfferHandleId, }); } - if (imageUrl) { - vcClaim.image = imageUrl; - } + // do Trade/Donate last because current endorser.ch only looks at the first for plans & offers + vcClaim.fulfills = vcClaim.fulfills.filter( + (elem) => + elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction", + ); + vcClaim.fulfills.push({ "@type": isTrade ? "TradeAction" : "DonateAction" }); + + vcClaim.image = imageUrl || undefined; + return vcClaim; } /** - * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim + * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * - * @param identity * @param fromDid may be null * @param toDid * @param description may be null; should have this or amount @@ -572,7 +646,7 @@ export async function createAndSubmitGive( axios: Axios, apiServer: string, issuerDid: string, - fromDid?: string | null, + fromDid?: string, toDid?: string, description?: string, amount?: number, @@ -582,7 +656,8 @@ export async function createAndSubmitGive( isTrade: boolean = false, imageUrl?: string, ): Promise { - const vcClaim = constructGive( + const vcClaim = hydrateGive( + undefined, fromDid, toDid, description, @@ -594,7 +669,51 @@ export async function createAndSubmitGive( imageUrl, ); return createAndSubmitClaim( - vcClaim as GenericCredWrapper, + vcClaim as GenericVerifiableCredential, + issuerDid, + apiServer, + axios, + ); +} + +/** + * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim + * + * @param fromDid may be null + * @param toDid + * @param description may be null; should have this or amount + * @param amount may be null; should have this or description + */ +export async function editAndSubmitGive( + axios: Axios, + apiServer: string, + fullClaim: GenericCredWrapper, + issuerDid: string, + fromDid?: string, + toDid?: string, + description?: string, + amount?: number, + unitCode?: string, + fulfillsProjectHandleId?: string, + fulfillsOfferHandleId?: string, + isTrade: boolean = false, + imageUrl?: string, +): Promise { + const vcClaim = hydrateGive( + fullClaim.claim, + fromDid, + toDid, + description, + amount, + unitCode, + fulfillsProjectHandleId, + fulfillsOfferHandleId, + isTrade, + imageUrl, + fullClaim.id, + ); + return createAndSubmitClaim( + vcClaim as GenericVerifiableCredential, issuerDid, apiServer, axios, @@ -647,7 +766,7 @@ export async function createAndSubmitOffer( }; } return createAndSubmitClaim( - vcClaim as GenericCredWrapper, + vcClaim as OfferVerifiableCredential, issuerDid, apiServer, axios, @@ -706,7 +825,7 @@ export async function createAndSubmitClaim( return { type: "success", response }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { - console.error("Error creating claim:", error); + console.error("Error submitting claim:", error); const errorMessage: string = error.response?.data?.error?.message || error.message || @@ -775,24 +894,29 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => { similar code is also contained in endorser-mobile **/ // eslint-disable-next-line @typescript-eslint/no-explicit-any -const claimSummary = (claim: Record) => { +const claimSummary = ( + claim: GenericCredWrapper, +) => { if (!claim) { // to differentiate from "something" above return "something"; } + let specificClaim: + | GenericVerifiableCredential + | GenericCredWrapper = claim; if (claim.claim) { // probably a Verified Credential // eslint-disable-next-line @typescript-eslint/no-explicit-any - claim = claim.claim as Record; + specificClaim = claim.claim; } - if (Array.isArray(claim)) { - if (claim.length === 1) { - claim = claim[0]; + if (Array.isArray(specificClaim)) { + if (specificClaim.length === 1) { + specificClaim = specificClaim[0]; } else { return "multiple claims"; } } - const type = claim["@type"]; + const type = specificClaim["@type"]; if (!type) { return "a claim"; } else { @@ -813,7 +937,7 @@ const claimSummary = (claim: Record) => { similar code is also contained in endorser-mobile **/ export const claimSpecialDescription = ( - record: GenericCredWrapper, + record: GenericCredWrapper, activeDid: string, identifiers: Array, contacts: Array, @@ -907,7 +1031,11 @@ export const claimSpecialDescription = ( "...]" ); } else { - return issuer + " declared " + claimSummary(claim as GenericCredWrapper); + return ( + issuer + + " declared " + + claimSummary(claim as GenericCredWrapper) + ); } }; diff --git a/src/libs/util.ts b/src/libs/util.ts index c0292ac..286d2a6 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -1,21 +1,28 @@ // many of these are also found in endorser-mobile utility.ts import axios, { AxiosResponse } from "axios"; -import { IIdentifier } from "@veramo/core"; import { useClipboard } from "@vueuse/core"; import { DEFAULT_PUSH_SERVER } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; import { Account } from "@/db/tables/accounts"; -import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; +import { + DEFAULT_PASSKEY_EXPIRATION_MINUTES, + MASTER_SETTINGS_KEY, +} from "@/db/tables/settings"; import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; -import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer"; +import { + containsHiddenDid, + GenericCredWrapper, + GenericVerifiableCredential, + OfferVerifiableCredential, +} from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer"; import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer"; import { Buffer } from "buffer"; -import {KeyMeta} from "@/libs/crypto/vc"; -import {createPeerDid} from "@/libs/crypto/vc/didPeer"; +import { KeyMeta } from "@/libs/crypto/vc"; +import { createPeerDid } from "@/libs/crypto/vc/didPeer"; export const PRIVACY_MESSAGE = "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; @@ -77,7 +84,9 @@ export const isGlobalUri = (uri: string) => { return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); }; -export const isGiveAction = (veriClaim: GenericCredWrapper) => { +export const isGiveAction = ( + veriClaim: GenericCredWrapper, +) => { return veriClaim.claimType === "GiveAction"; }; @@ -93,7 +102,7 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => { * @param veriClaim is expected to have fields: claim, claimType, and issuer */ export const isGiveRecordTheUserCanConfirm = ( - veriClaim: GenericCredWrapper, + veriClaim: GenericCredWrapper, activeDid: string, confirmerIdList: string[] = [], ) => { @@ -109,9 +118,9 @@ export const isGiveRecordTheUserCanConfirm = ( * @returns the DID of the person who offered, or undefined if hidden * @param veriClaim is expected to have fields: claim and issuer */ -export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = ( - veriClaim, -) => { +export const offerGiverDid: ( + arg0: GenericCredWrapper, +) => string | undefined = (veriClaim) => { let giver; if ( veriClaim.claim.offeredBy?.identifier && @@ -128,8 +137,13 @@ export const offerGiverDid: (arg0: GenericCredWrapper) => string | undefined = ( * @returns true if the user can fulfill the offer * @param veriClaim is expected to have fields: claim, claimType, and issuer */ -export const canFulfillOffer = (veriClaim: GenericCredWrapper) => { - return !!(veriClaim.claimType === "Offer" && offerGiverDid(veriClaim)); +export const canFulfillOffer = ( + veriClaim: GenericCredWrapper, +) => { + return !!( + veriClaim.claimType === "Offer" && + offerGiverDid(veriClaim as GenericCredWrapper) + ); }; // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid" @@ -273,6 +287,15 @@ export const registerSaveAndActivatePasskey = async ( return account; }; +export const getPasskeyExpirationSeconds = async (): Promise => { + await db.open(); + const settings = await db.settings.get(MASTER_SETTINGS_KEY); + const passkeyExpirationSeconds = + (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * + 60; + return passkeyExpirationSeconds; +}; + export const sendTestThroughPushServer = async ( subscriptionJSON: PushSubscriptionJSON, skipFilter: boolean, diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index d32267e..2d27100 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -152,7 +152,7 @@
- Activity + Your Activity
@@ -216,7 +216,6 @@
Location
Set Search Area… @@ -304,6 +303,22 @@ > If no download happened yet, click again here to download now. +
+

+ After the download, you can save the file in your preferred storage + location. +

+
    +
  • + On iOS: Choose "More..." and select anyplace in iCloud, or go "Back" + and save to another location. +
  • +
  • + On Android: Choose "Open" and then share to your prefered place. + +
  • +
+
@@ -622,6 +637,26 @@ +
+ + + Passkey Expiration Minutes + +
+ + {{ passkeyExpirationDescription }} + +
+
+ +
+
+