+ You have done {{ limits.doneClaimsThisWeek }} claims out of
+ {{ limits.maxClaimsPerWeek }} for this week. Your claims counter
+ resets at {{ readableTime(limits.nextWeekBeginDateTime) }}
+
+
+ You have done {{ limits.doneRegistrationsThisMonth }} registrations
+ out of {{ limits.maxRegistrationsPerMonth }} for this month. Your
+ registrations counter resets at
+ {{ readableTime(limits.nextMonthBeginDateTime) }}
+
@@ -135,13 +219,30 @@
import { AxiosError } from "axios";
import * as didJwt from "did-jwt";
import * as R from "ramda";
+import { IIdentifier } from "@veramo/core";
import { Options, Vue } from "vue-class-component";
import { AppString } from "@/constants/app";
import { accessToken, SimpleSigner } from "@/libs/crypto";
-import { IIdentifier } from "@veramo/core";
-import { accountsDB, db } from "../db";
-import { Contact } from "../db/tables/contacts";
+import { accountsDB, db } from "@/db";
+import { Contact } from "@/db/tables/contacts";
+import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const Buffer = require("buffer/").Buffer;
+
+const SERVICE_ID = "endorser.ch";
+
+export interface GiveServerRecord {
+ agentDid: string;
+ amount: number;
+ confirmed: number;
+ description: string;
+ fullClaim: GiveVerifiableCredential;
+ handleId: string;
+ recipientDid: string;
+ unit: string;
+}
export interface GiveVerifiableCredential {
"@context": string;
@@ -152,20 +253,38 @@ export interface GiveVerifiableCredential {
recipient: { identifier: string };
}
+export interface RegisterVerifiableCredential {
+ "@context": string;
+ "@type": string;
+ agent: { identifier: string };
+ object: string;
+ recipient: { identifier: string };
+}
+
@Options({
components: {},
})
export default class ContactsView extends Vue {
contacts: Array = [];
contactInput = "";
+ // { "did:...": concatenated-descriptions } entry for each contact
+ givenByMeDescriptions: Record = {};
// { "did:...": amount } entry for each contact
- givenByMeTotals: Record = {};
+ givenByMeConfirmed: Record = {};
// { "did:...": amount } entry for each contact
- givenToMeTotals: Record = {};
+ givenByMeUnconfirmed: Record = {};
+ // { "did:...": concatenated-descriptions } entry for each contact
+ givenToMeDescriptions: Record = {};
+ // { "did:...": amount } entry for each contact
+ givenToMeConfirmed: Record = {};
+ // { "did:...": amount } entry for each contact
+ givenToMeUnconfirmed: Record = {};
hourDescriptionInput = "";
hourInput = "0";
identity: IIdentifier | null = null;
- showGiveTotals = false;
+ showGiveNumbers = false;
+ showGiveTotals = true;
+ showGiveConfirmed = true;
// 'created' hook runs when the Vue instance is first created
async created() {
@@ -174,13 +293,16 @@ export default class ContactsView extends Vue {
this.identity = JSON.parse(accounts[0].identity);
await db.open();
- this.contacts = await db.contacts.toArray();
-
- const params = new URLSearchParams(window.location.search);
- this.showGiveTotals = params.get("showGiveTotals") == "true";
- if (this.showGiveTotals) {
+ const settings = await db.settings.get(MASTER_SETTINGS_KEY);
+ this.showGiveNumbers = !!settings?.showContactGivesInline;
+ if (this.showGiveNumbers) {
this.loadGives();
}
+ const allContacts = await db.contacts.toArray();
+ this.contacts = R.sort(
+ (a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
+ allContacts
+ );
}
async onClickNewContact(): Promise {
@@ -196,9 +318,18 @@ export default class ContactsView extends Vue {
publicKeyBase64 = this.contactInput.substring(commaPos2 + 1).trim();
}
}
+ // help with potential mistakes while this sharing requires copy-and-paste
+ if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
+ // it must be all hex (compressed public key), so convert
+ publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
+ }
const newContact = { did, name, publicKeyBase64 };
await db.contacts.add(newContact);
- this.contacts = this.contacts.concat([newContact]);
+ const allContacts = this.contacts.concat([newContact]);
+ this.contacts = R.sort(
+ (a: Contact, b) => (a.name || "").localeCompare(b.name || ""),
+ allContacts
+ );
}
async loadGives() {
@@ -223,18 +354,30 @@ export default class ContactsView extends Vue {
Authorization: "Bearer " + token,
};
const resp = await this.axios.get(url, { headers });
- //console.log("Server response", resp.status, resp.data);
+ console.log("All your gifts:", resp.data);
if (resp.status === 200) {
- const contactTotals: Record = {};
- for (const give of resp.data.data) {
+ const contactDescriptions: Record = {};
+ const contactConfirmed: Record = {};
+ const contactUnconfirmed: Record = {};
+ const allData: Array = resp.data.data;
+ for (const give of allData) {
if (give.unit == "HUR") {
const recipDid: string = give.recipientDid;
- const prevAmount = contactTotals[recipDid] || 0;
- contactTotals[recipDid] = prevAmount + give.amount;
+ if (give.confirmed) {
+ const prevAmount = contactConfirmed[recipDid] || 0;
+ contactConfirmed[recipDid] = prevAmount + give.amount;
+ } else {
+ const prevAmount = contactUnconfirmed[recipDid] || 0;
+ contactUnconfirmed[recipDid] = prevAmount + give.amount;
+ }
+ const prevDesc = contactDescriptions[recipDid] || "";
+ // Since many make the tooltip too big, we'll just use the latest;
+ contactDescriptions[recipDid] = give.description || prevDesc;
}
}
- //console.log("Done retrieving gives", contactTotals);
- this.givenByMeTotals = contactTotals;
+ //console.log("Done retrieving gives", contactConfirmed);
+ this.givenByMeDescriptions = contactDescriptions;
+ this.givenByMeConfirmed = contactConfirmed;
}
} catch (error) {
this.alertTitle = "Error from Server";
@@ -254,17 +397,29 @@ export default class ContactsView extends Vue {
Authorization: "Bearer " + token,
};
const resp = await this.axios.get(url, { headers });
- //console.log("Server response", resp.status, resp.data);
+ console.log("All gifts you've recieved:", resp.data);
if (resp.status === 200) {
- const contactTotals: Record = {};
- for (const give of resp.data.data) {
+ const contactDescriptions: Record = {};
+ const contactConfirmed: Record = {};
+ const contactUnconfirmed: Record = {};
+ const allData: Array = resp.data.data;
+ for (const give of allData) {
if (give.unit == "HUR") {
- const prevAmount = contactTotals[give.agentDid] || 0;
- contactTotals[give.agentDid] = prevAmount + give.amount;
+ if (give.confirmed) {
+ const prevAmount = contactConfirmed[give.agentDid] || 0;
+ contactConfirmed[give.agentDid] = prevAmount + give.amount;
+ } else {
+ const prevAmount = contactUnconfirmed[give.agentDid] || 0;
+ contactUnconfirmed[give.agentDid] = prevAmount + give.amount;
+ }
+ const prevDesc = contactDescriptions[give.agentDid] || "";
+ // Since many make the tooltip too big, we'll just use the latest;
+ contactDescriptions[give.agentDid] = give.description || prevDesc;
}
}
- //console.log("Done retrieving receipts", contactTotals);
- this.givenToMeTotals = contactTotals;
+ //console.log("Done retrieving receipts", contactConfirmed);
+ this.givenToMeDescriptions = contactDescriptions;
+ this.givenToMeConfirmed = contactConfirmed;
}
} catch (error) {
this.alertTitle = "Error from Server";
@@ -273,6 +428,187 @@ export default class ContactsView extends Vue {
}
}
+ async deleteContact(contact: Contact) {
+ if (
+ confirm(
+ "Are you sure you want to delete " +
+ this.nameForDid(this.contacts, contact.did) +
+ " with DID " +
+ contact.did +
+ " ?"
+ )
+ ) {
+ await db.open();
+ await db.contacts.delete(contact.did);
+ this.contacts = R.without([contact], this.contacts);
+ }
+ }
+
+ async register(contact: Contact) {
+ if (
+ confirm(
+ "Are you sure you want to use one of your registrations for " +
+ this.nameForDid(this.contacts, contact.did) +
+ "?"
+ )
+ ) {
+ await accountsDB.open();
+ const accounts = await accountsDB.accounts.toArray();
+ const identity = JSON.parse(accounts[0].identity);
+ // Make a claim
+ const vcClaim: RegisterVerifiableCredential = {
+ "@context": "https://schema.org",
+ "@type": "RegisterAction",
+ agent: { identifier: identity.did },
+ object: SERVICE_ID,
+ recipient: { identifier: contact.did },
+ };
+ // 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) {
+ // 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 endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
+ const url = endorserApiServer + "/api/v2/claim";
+ const token = await accessToken(identity);
+ const headers = {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + token,
+ };
+
+ try {
+ const resp = await this.axios.post(url, payload, { headers });
+ //console.log("Got resp data:", resp.data);
+ if (resp.data?.success?.handleId) {
+ contact.registered = true;
+ db.contacts.update(contact.did, { registered: true });
+
+ this.alertTitle = "Registration Success";
+ this.alertMessage = contact.name + " has been registered.";
+ this.isAlertVisible = true;
+ }
+ } catch (error) {
+ let userMessage = "There was an error. See logs for more info.";
+ const serverError = error as AxiosError;
+ if (serverError) {
+ if (serverError.message) {
+ userMessage = serverError.message; // Info for the user
+ } else {
+ userMessage = JSON.stringify(serverError.toJSON());
+ }
+ } else {
+ userMessage = error as string;
+ }
+ // Now set that error for the user to see.
+ this.alertTitle = "Error with Server";
+ this.alertMessage = userMessage;
+ this.isAlertVisible = true;
+ }
+ }
+ }
+ }
+
+ async setVisibility(contact: Contact, visibility: boolean) {
+ const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
+ const url =
+ endorserApiServer +
+ "/api/report/" +
+ (visibility ? "canSeeMe" : "cannotSeeMe");
+ await accountsDB.open();
+ const accounts = await accountsDB.accounts.toArray();
+ const identity = JSON.parse(accounts[0].identity);
+ const token = await accessToken(identity);
+ const headers = {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + token,
+ };
+ const payload = JSON.stringify({ did: contact.did });
+
+ try {
+ const resp = await this.axios.post(url, payload, { headers });
+ if (resp.status === 200) {
+ contact.seesMe = visibility;
+ db.contacts.update(contact.did, { seesMe: visibility });
+ } else {
+ this.alertTitle = "Error from Server";
+ console.log("Bad response setting visibility: ", resp.data);
+ if (resp.data.error?.message) {
+ this.alertMessage = resp.data.error?.message;
+ } else {
+ this.alertMessage = "Bad server response of " + resp.status;
+ }
+ this.isAlertVisible = true;
+ }
+ } catch (err) {
+ this.alertTitle = "Error from Server";
+ this.alertMessage = err as string;
+ this.isAlertVisible = true;
+ }
+ }
+
+ async checkVisibility(contact: Contact) {
+ const endorserApiServer = AppString.DEFAULT_ENDORSER_API_SERVER;
+ const url =
+ endorserApiServer +
+ "/api/report/canDidExplicitlySeeMe?did=" +
+ encodeURIComponent(contact.did);
+ await accountsDB.open();
+ const accounts = await accountsDB.accounts.toArray();
+ const identity = JSON.parse(accounts[0].identity);
+ const token = await accessToken(identity);
+ const headers = {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + token,
+ };
+
+ try {
+ const resp = await this.axios.get(url, { headers });
+ if (resp.status === 200) {
+ const visibility = resp.data;
+ contact.seesMe = visibility;
+ db.contacts.update(contact.did, { seesMe: visibility });
+
+ this.alertTitle = "Refreshed";
+ this.alertMessage =
+ this.nameForContact(contact, true) +
+ " can " +
+ (visibility ? "" : "not ") +
+ "see your activity.";
+ this.isAlertVisible = true;
+ } else {
+ this.alertTitle = "Error from Server";
+ if (resp.data.error?.message) {
+ this.alertMessage = resp.data.error?.message;
+ } else {
+ this.alertMessage = "Bad server response of " + resp.status;
+ }
+ this.isAlertVisible = true;
+ }
+ } catch (err) {
+ this.alertTitle = "Error from Server";
+ this.alertMessage = err as string;
+ this.isAlertVisible = true;
+ }
+ }
+
// from https://stackoverflow.com/a/175787/845494
//
private isNumeric(str: string): boolean {
@@ -281,7 +617,11 @@ export default class ContactsView extends Vue {
private nameForDid(contacts: Array, did: string): string {
const contact = R.find((con) => con.did == did, contacts);
- return contact?.name || "this unnamed user";
+ return this.nameForContact(contact);
+ }
+
+ private nameForContact(contact?: Contact, capitalize?: boolean): string {
+ return contact?.name || (capitalize ? "T" : "t") + "this unnamed user";
}
async onClickAddGive(fromDid: string, toDid: string): Promise {
@@ -305,12 +645,19 @@ export default class ContactsView extends Vue {
} else {
toFrom = "from " + this.nameForDid(this.contacts, fromDid) + " to you";
}
+ let description;
+ if (this.hourDescriptionInput) {
+ description = " with description '" + this.hourDescriptionInput + "'";
+ } else {
+ description = " with no description";
+ }
if (
confirm(
"Are you sure you want to record " +
this.hourInput +
" hours " +
toFrom +
+ description +
"?"
)
) {
@@ -382,16 +729,17 @@ export default class ContactsView extends Vue {
this.alertTitle = "";
this.alertMessage = "";
if (fromDid === identity.did) {
- this.givenByMeTotals[toDid] = this.givenByMeTotals[toDid] + amount;
+ this.givenByMeConfirmed[toDid] =
+ this.givenByMeConfirmed[toDid] + amount;
// do this to update the UI (is there a better way?)
// eslint-disable-next-line no-self-assign
- this.givenByMeTotals = this.givenByMeTotals;
+ this.givenByMeConfirmed = this.givenByMeConfirmed;
} else {
- this.givenToMeTotals[fromDid] =
- this.givenToMeTotals[fromDid] + amount;
+ this.givenToMeConfirmed[fromDid] =
+ this.givenToMeConfirmed[fromDid] + amount;
// do this to update the UI (is there a better way?)
// eslint-disable-next-line no-self-assign
- this.givenToMeTotals = this.givenToMeTotals;
+ this.givenToMeConfirmed = this.givenToMeConfirmed;
}
}
} catch (error) {
@@ -414,6 +762,32 @@ export default class ContactsView extends Vue {
}
}
+ public selectedGiveTotal(
+ contactGivesConfirmed: Record,
+ contactGivesUnconfirmed: Record,
+ did: string
+ ) {
+ /* eslint-disable prettier/prettier */
+ this.showGiveTotals
+ ? ((contactGivesConfirmed[did] || 0) + (contactGivesUnconfirmed[did] || 0))
+ : this.showGiveConfirmed
+ ? (contactGivesConfirmed[did] || 0)
+ : (contactGivesUnconfirmed[did] || 0);
+ /* eslint-enable prettier/prettier */
+ }
+ public toggleShowGiveTotals() {
+ if (this.showGiveTotals) {
+ this.showGiveTotals = false;
+ this.showGiveConfirmed = true;
+ } else if (this.showGiveConfirmed) {
+ this.showGiveTotals = false; // stays the same
+ this.showGiveConfirmed = false;
+ } else {
+ this.showGiveTotals = true;
+ this.showGiveConfirmed = true;
+ }
+ }
+
alertTitle = "";
alertMessage = "";
isAlertVisible = false;
@@ -440,5 +814,62 @@ export default class ContactsView extends Vue {
"duration-300": true,
};
}
+
+ public showGiveAmountsClassNames() {
+ return {
+ "bg-slate-900": this.showGiveTotals,
+ "bg-green-600": !this.showGiveTotals && this.showGiveConfirmed,
+ "bg-yellow-600": !this.showGiveTotals && !this.showGiveConfirmed,
+ };
+ }
}
+
+
diff --git a/src/views/HelpView.vue b/src/views/HelpView.vue
index cac9e8799..a48f6fc25 100644
--- a/src/views/HelpView.vue
+++ b/src/views/HelpView.vue
@@ -134,6 +134,18 @@
Make sure you have your backup file (above), then contact us.
+
+
+ How do I add someone to my contacts?
+
+
+ Tell them to copy their ID, which typically starts with "did:ethr:...",
+ and send it to you. Go to the Contacts
+ page and enter that into the top
+ form. You may add a name by adding a comma followed by their name; you
+ may also add their public key by adding another comma followed by the
+ key.
+