diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 026d1f5e2..c48ce3ccc 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -1,17 +1,41 @@ @@ -23,6 +47,7 @@ export default { return { contact: null, description: "", + hours: "0", visible: false, }; }, @@ -34,11 +59,18 @@ export default { close() { this.visible = false; }, + increment() { + this.hours = `${(parseFloat(this.hours) || 0) + 1}`; + }, + decrement() { + this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`; + }, confirm() { this.close(); this.$emit("dialog-result", { action: "confirm", contact: this.contact, + hours: parseFloat(this.hours), description: this.description, }); }, diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 1bfca842d..6766ecd0a 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1,23 +1,32 @@ import * as R from "ramda"; +import { IIdentifier } from "@veramo/core"; +import { accessToken, SimpleSigner } from "@/libs/crypto"; +import * as didJwt from "did-jwt"; +import { Axios, AxiosResponse } from "axios"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; export const SERVICE_ID = "endorser.ch"; -export interface GenericClaim { +export interface AgreeVerifiableCredential { "@context": string; "@type": string; - issuedAt: string; // "any" because arbitrary objects can be subject of agreement // eslint-disable-next-line @typescript-eslint/no-explicit-any - claim: Record; + object: Record; } -export interface AgreeVerifiableCredential { +export interface ClaimResult { + success: { claimId: string; handleId: string }; + error: { code: string; message: string }; +} + +export interface GenericClaim { "@context": string; "@type": string; + issuedAt: string; // "any" because arbitrary objects can be subject of agreement // eslint-disable-next-line @typescript-eslint/no-explicit-any - object: Record; + claim: Record; } export interface GiveServerRecord { @@ -35,10 +44,10 @@ export interface GiveServerRecord { export interface GiveVerifiableCredential { "@context"?: string; // optional when embedded, eg. in an Agree "@type": string; - agent: { identifier: string }; + agent?: { identifier: string }; description?: string; identifier?: string; - object: { amountOfThisGood: number; unitCode: string }; + object?: { amountOfThisGood: number; unitCode: string }; recipient: { identifier: string }; } @@ -50,6 +59,11 @@ export interface RegisterVerifiableCredential { recipient: { identifier: string }; } +export interface InternalError { + error: string; // for system logging + userMessage?: string; // for user display +} + // This is used to check for hidden info. // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 const HIDDEN_DID = "did:none:HIDDEN"; @@ -69,6 +83,8 @@ export function didInfo(did, identifiers, contacts) { const contact = R.find((c) => c.did === did, contacts); if (contact) { return contact.name || "Someone Unnamed in Contacts"; + } else if (!did) { + return "Unpecified Person"; } else if (isHiddenDid(did)) { return "Someone Not In Network"; } else { @@ -76,3 +92,91 @@ export function didInfo(did, identifiers, contacts) { } } } + +/** + * For result, see https://endorser.ch:3000/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 hours + * @param hours may be null; should have this or description + */ +export async function createAndSubmitGive( + axios: Axios, + apiServer: string, + identity: IIdentifier, + fromDid: string, + toDid: string, + description: string, + hours: number +): Promise | InternalError> { + // Make a claim + const vcClaim: GiveVerifiableCredential = { + "@context": "https://schema.org", + "@type": "GiveAction", + recipient: { identifier: toDid }, + }; + if (fromDid) { + vcClaim.agent = { identifier: fromDid }; + } + if (description) { + vcClaim.description = description; + } + if (hours) { + vcClaim.object = { amountOfThisGood: hours, unitCode: "HUR" }; + } + // Make a payload for the claim + const vcPayload = { + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + credentialSubject: vcClaim, + }, + }; + // Create a signature using private key of identity + if (identity.keys[0].privateKeyHex == null) { + return new Promise((resolve, reject) => { + reject({ + error: "No private key", + message: + "Your identifier " + + identity.did + + " is not configured correctly. Use a different identifier.", + }); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const privateKeyHex: string = identity.keys[0].privateKeyHex!; + const signer = await SimpleSigner(privateKeyHex); + const alg = undefined; + // Create a JWT for the request + const vcJwt: string = await didJwt.createJWT(vcPayload, { + alg: alg, + issuer: identity.did, + signer: signer, + }); + + // Make the xhr request payload + + const payload = JSON.stringify({ jwtEncoded: vcJwt }); + const url = apiServer + "/api/v2/claim"; + const token = await accessToken(identity); + const headers = { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + }; + + return axios.post(url, payload, { headers }); +} + +// from https://stackoverflow.com/a/175787/845494 +// +export function isNumeric(str: string): boolean { + return !isNaN(+str); +} + +export function numberOrZero(str: string): number { + return isNumeric(str) ? +str : 0; +} diff --git a/src/main.ts b/src/main.ts index c3bec57ce..c73eeff52 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,8 @@ import { faRotate, faShareNodes, faSpinner, + faSquareCaretDown, + faSquareCaretUp, faTrashCan, faUser, faUsers, @@ -69,6 +71,8 @@ library.add( faRotate, faShareNodes, faSpinner, + faSquareCaretDown, + faSquareCaretUp, faTrashCan, faUser, faUsers, diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index e4b50dcd4..a2c593abb 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -57,10 +57,14 @@ v-for="contact in allContacts" :key="contact.did" @click="openDialog(contact)" + style="color: blue" > - {{ contact.name }},  +  {{ contact.name }}, + + or + - @@ -71,7 +75,7 @@ > -
+

Latest Activity

@@ -110,7 +114,7 @@ import GiftedDialog from "@/components/GiftedDialog.vue"; import { db, accountsDB } from "@/db"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { accessToken } from "@/libs/crypto"; -import { didInfo } from "@/libs/endorserServer"; +import { createAndSubmitGive, didInfo } from "@/libs/endorserServer"; import { Account } from "@/db/tables/accounts"; import { Contact } from "@/db/tables/contacts"; @@ -185,10 +189,12 @@ export default class HomeView extends Vue { (acc) => acc.did === this.activeDid, this.allAccounts ); - console.log("about to parse from", this.activeDid, account?.identity); + //console.log("about to parse from", this.activeDid, account?.identity); const identity = JSON.parse(account?.identity || "undefined"); const token = await accessToken(identity); headers["Authorization"] = "Bearer " + token; + } else { + // it's OK without auth... we just won't get any identifiers } return fetch(this.apiServer + "/api/v2/report/gives?" + beforeQuery, { method: "GET", @@ -240,22 +246,66 @@ export default class HomeView extends Vue { return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; } - recordGive(contact) { - console.log("recordGive", contact); - } - openDialog(contact) { this.$refs.customDialog.open(contact); } handleDialogResult(result) { if (result.action === "confirm") { return new Promise((resolve) => { - console.log("Dialog confirmed: ", result.description); + this.recordGive(result.contact, result.description, result.hours); resolve(); }); } else { - // action was "cancel" + // action was "cancel" so do nothing + } + } + + /** + * + * @param contact may be null + * @param description may be an empty string + * @param hours may be 0 + */ + recordGive(contact, description, hours) { + if (this.activeDid == null) { + this.alertTitle = "Error"; + this.alertMessage = + "You must select an identity before you can record a give."; + return; } + const account = R.find( + (acc) => acc.did === this.activeDid, + this.allAccounts + ); + //console.log("about to parse from", this.activeDid, account?.identity); + const identity = JSON.parse(account?.identity || "undefined"); + createAndSubmitGive( + this.axios, + this.apiServer, + identity, + contact?.did, + this.activeDid, + description, + hours + ) + .then((result) => { + if (result.status != 201 || result.data?.error) { + console.log("Error with give result:", result); + this.alertTitle = "Error"; + this.alertMessage = + result.data?.message || "There was an error recording the give."; + } else { + this.alertTitle = "Success"; + this.alertMessage = "That gift was recorded."; + //this.updateAllFeed(); // full update is overkill but we should show something + } + }) + .catch((e) => { + console.log("Error with give caught:", e); + this.alertTitle = "Error"; + this.alertMessage = + e.userMessage || "There was an error recording the give."; + }); } // This same popup code is in many files.