Browse Source

sllow quick gifting all the way to the server, maybe with hours

world-fix
Trent Larson 1 year ago
parent
commit
a8794be2ea
  1. 42
      src/components/GiftedDialog.vue
  2. 118
      src/libs/endorserServer.ts
  3. 4
      src/main.ts
  4. 72
      src/views/HomeView.vue

42
src/components/GiftedDialog.vue

@ -1,17 +1,41 @@
<template> <template>
<div v-if="visible" class="dialog-overlay"> <div v-if="visible" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
<h1>Received from {{ contact?.name || "nobody in particular" }}</h1> <h1 class="text-lg text-center">
<p>{{ message }}</p> Received from {{ contact?.name || "nobody in particular" }}
</h1>
<p class="py-2">{{ message }}</p>
<input <input
type="text" type="text"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
placeholder="What you received" placeholder="What you received"
v-model="description" v-model="description"
/> />
<button @click="confirm">Confirm</button> <div class="flex flex-row">
&nbsp; <span class="py-4">Hours</span>
<button @click="cancel">Cancel</button> <input
type="text"
class="block w-8 rounded border border-slate-400 ml-4 text-center"
v-model="hours"
/>
<div class="flex flex-col px-1">
<div>
<fa icon="square-caret-up" size="2xl" @click="increment()" />
</div>
<div>
<fa icon="square-caret-down" size="2xl" @click="decrement()" />
</div>
</div>
</div>
<div class="text-right">
<button class="rounded border border-slate-400" @click="confirm">
<span class="m-2">Confirm</span>
</button>
&nbsp;
<button class="rounded border border-slate-400" @click="cancel">
<span class="m-2">Cancel</span>
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -23,6 +47,7 @@ export default {
return { return {
contact: null, contact: null,
description: "", description: "",
hours: "0",
visible: false, visible: false,
}; };
}, },
@ -34,11 +59,18 @@ export default {
close() { close() {
this.visible = false; this.visible = false;
}, },
increment() {
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
},
decrement() {
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
},
confirm() { confirm() {
this.close(); this.close();
this.$emit("dialog-result", { this.$emit("dialog-result", {
action: "confirm", action: "confirm",
contact: this.contact, contact: this.contact,
hours: parseFloat(this.hours),
description: this.description, description: this.description,
}); });
}, },

118
src/libs/endorserServer.ts

@ -1,23 +1,32 @@
import * as R from "ramda"; 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 SCHEMA_ORG_CONTEXT = "https://schema.org";
export const SERVICE_ID = "endorser.ch"; export const SERVICE_ID = "endorser.ch";
export interface GenericClaim { export interface AgreeVerifiableCredential {
"@context": string; "@context": string;
"@type": string; "@type": string;
issuedAt: string;
// "any" because arbitrary objects can be subject of agreement // "any" because arbitrary objects can be subject of agreement
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
claim: Record<any, any>; object: Record<any, any>;
} }
export interface AgreeVerifiableCredential { export interface ClaimResult {
success: { claimId: string; handleId: string };
error: { code: string; message: string };
}
export interface GenericClaim {
"@context": string; "@context": string;
"@type": string; "@type": string;
issuedAt: string;
// "any" because arbitrary objects can be subject of agreement // "any" because arbitrary objects can be subject of agreement
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
object: Record<any, any>; claim: Record<any, any>;
} }
export interface GiveServerRecord { export interface GiveServerRecord {
@ -35,10 +44,10 @@ export interface GiveServerRecord {
export interface GiveVerifiableCredential { export interface GiveVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded, eg. in an Agree
"@type": string; "@type": string;
agent: { identifier: string }; agent?: { identifier: string };
description?: string; description?: string;
identifier?: string; identifier?: string;
object: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string };
recipient: { identifier: string }; recipient: { identifier: string };
} }
@ -50,6 +59,11 @@ export interface RegisterVerifiableCredential {
recipient: { identifier: string }; recipient: { identifier: string };
} }
export interface InternalError {
error: string; // for system logging
userMessage?: string; // for user display
}
// This is used to check for hidden info. // This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN"; 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); const contact = R.find((c) => c.did === did, contacts);
if (contact) { if (contact) {
return contact.name || "Someone Unnamed in Contacts"; return contact.name || "Someone Unnamed in Contacts";
} else if (!did) {
return "Unpecified Person";
} else if (isHiddenDid(did)) { } else if (isHiddenDid(did)) {
return "Someone Not In Network"; return "Someone Not In Network";
} else { } 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<AxiosResponse<ClaimResult> | 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<InternalError>((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;
}

4
src/main.ts

@ -36,6 +36,8 @@ import {
faRotate, faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquareCaretDown,
faSquareCaretUp,
faTrashCan, faTrashCan,
faUser, faUser,
faUsers, faUsers,
@ -69,6 +71,8 @@ library.add(
faRotate, faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquareCaretDown,
faSquareCaretUp,
faTrashCan, faTrashCan,
faUser, faUser,
faUsers, faUsers,

72
src/views/HomeView.vue

@ -57,10 +57,14 @@
v-for="contact in allContacts" v-for="contact in allContacts"
:key="contact.did" :key="contact.did"
@click="openDialog(contact)" @click="openDialog(contact)"
style="color: blue"
> >
{{ contact.name }},&nbsp; &nbsp;{{ contact.name }},
</button>
or
<button @click="openDialog()" style="color: blue">
nobody in particular
</button> </button>
<button @click="openDialog()">or nobody in particular</button>
</div> </div>
</div> </div>
@ -71,7 +75,7 @@
> >
</GiftedDialog> </GiftedDialog>
<div> <div class="py-4">
<h1 class="text-2xl">Latest Activity</h1> <h1 class="text-2xl">Latest Activity</h1>
<span :class="{ hidden: isHiddenSpinner }"> <span :class="{ hidden: isHiddenSpinner }">
<fa icon="spinner" class="fa-fw"></fa> <fa icon="spinner" class="fa-fw"></fa>
@ -110,7 +114,7 @@ import GiftedDialog from "@/components/GiftedDialog.vue";
import { db, accountsDB } from "@/db"; import { db, accountsDB } from "@/db";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { accessToken } from "@/libs/crypto"; import { accessToken } from "@/libs/crypto";
import { didInfo } from "@/libs/endorserServer"; import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
@ -185,10 +189,12 @@ export default class HomeView extends Vue {
(acc) => acc.did === this.activeDid, (acc) => acc.did === this.activeDid,
this.allAccounts 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 identity = JSON.parse(account?.identity || "undefined");
const token = await accessToken(identity); const token = await accessToken(identity);
headers["Authorization"] = "Bearer " + token; 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, { return fetch(this.apiServer + "/api/v2/report/gives?" + beforeQuery, {
method: "GET", method: "GET",
@ -240,22 +246,66 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
} }
recordGive(contact) {
console.log("recordGive", contact);
}
openDialog(contact) { openDialog(contact) {
this.$refs.customDialog.open(contact); this.$refs.customDialog.open(contact);
} }
handleDialogResult(result) { handleDialogResult(result) {
if (result.action === "confirm") { if (result.action === "confirm") {
return new Promise((resolve) => { return new Promise((resolve) => {
console.log("Dialog confirmed: ", result.description); this.recordGive(result.contact, result.description, result.hours);
resolve(); resolve();
}); });
} else { } 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. // This same popup code is in many files.

Loading…
Cancel
Save