diff --git a/package-lock.json b/package-lock.json index 3814b71..eb4d759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@vueuse/core": "^9.6.0", "@zxing/text-encoding": "^0.9.0", "axios": "^1.2.2", + "buffer": "^6.0.3", "class-transformer": "^0.5.1", "core-js": "^3.26.1", "dexie": "^3.2.2", @@ -7510,9 +7511,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.11.9", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.11.9.tgz", - "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==" + "version": "18.15.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.5.tgz", + "integrity": "sha512-Ark2WDjjZO7GmvsyFFf81MXuGTA/d6oP38anyxWOL6EREyBKAxKoFHwBhaZxCfLRLpO8JgVXwqOwSwa7jRcjew==" }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", diff --git a/package.json b/package.json index 9a7a3cb..b805938 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@vueuse/core": "^9.6.0", "@zxing/text-encoding": "^0.9.0", "axios": "^1.2.2", + "buffer": "^6.0.3", "class-transformer": "^0.5.1", "core-js": "^3.26.1", "dexie": "^3.2.2", diff --git a/project.yaml b/project.yaml index a94ad5a..7560ea2 100644 --- a/project.yaml +++ b/project.yaml @@ -12,12 +12,15 @@ - replace user-affecting console.logs with error messages (eg. catches) - contacts v1 : - - .2 show gives with new setting + - test confirmed vs unconfirmed amounts + - remove 'copy' until it works + - switch to prod server - 01 show gives with confirmations - .5 Add page to show seed. - 01 Provide a way to import the non-sensitive data. - 01 Provide way to share your contact info. - - .1 remove "scan new contact" + - .2 move all "identity" references to temporary account access + - get 'copy' to work on account page - contacts v+ : - .5 make advanced "show/hide amounts" button into a nice UI toggle diff --git a/src/main.ts b/src/main.ts index 8fc26a4..88dbedb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,46 +10,58 @@ import "./assets/styles/tailwind.css"; import { library } from "@fortawesome/fontawesome-svg-core"; import { + faCalendar, faChevronLeft, - faHouseChimney, - faMagnifyingGlass, - faFolderOpen, - faHand, + faCircleCheck, + faCircleQuestion, faCircleUser, faCopy, - faShareNodes, - faQrcode, - faUser, - faUsers, + faEllipsisVertical, + faEye, + faEyeSlash, + faFolderOpen, + faHand, + faHouseChimney, + faMagnifyingGlass, faPen, + faPersonCircleCheck, + faPersonCircleQuestion, faPlus, - faTrashCan, - faCalendar, - faEllipsisVertical, + faQrcode, + faRotate, + faShareNodes, faSpinner, - faCircleCheck, + faTrashCan, + faUser, + faUsers, faXmark, } from "@fortawesome/free-solid-svg-icons"; library.add( + faCalendar, faChevronLeft, - faHouseChimney, - faMagnifyingGlass, - faFolderOpen, - faHand, + faCircleCheck, + faCircleQuestion, faCircleUser, faCopy, - faShareNodes, - faQrcode, - faUser, - faUsers, + faEllipsisVertical, + faEye, + faEyeSlash, + faFolderOpen, + faHand, + faHouseChimney, + faMagnifyingGlass, faPen, + faPersonCircleCheck, + faPersonCircleQuestion, faPlus, - faTrashCan, - faCalendar, - faEllipsisVertical, + faQrcode, + faRotate, + faShareNodes, faSpinner, - faCircleCheck, + faTrashCan, + faUser, + faUsers, faXmark ); diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 43407be..243cc46 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -110,10 +110,20 @@ -
Public Key
+
Public Key (base 64)
- {{ publicHex }} + + {{ publicBase64 }} + + +
+ +
Public Key (hex)
+
+ + {{ publicHex }} @@ -138,15 +148,6 @@ Edit Identity -

Contact Actions

- - - Scan New Contact - -

Data

{{ showContactGives ? "Showing" : "Hiding" }} amounts given with contacts (Click to @@ -201,6 +202,29 @@
+
+ +
+ Rate Limits +

+ 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) }} +

+
+
+
-
+
Hours to Add: +
+
+
@@ -96,23 +112,91 @@ {{ contact.name || "(no name)" }}
{{ contact.did }}
-
{{ contact.publicKeyBase64 }}
-
+
+ Public Key (base 64): {{ contact.publicKeyBase64 }} +
+ + + + + + + + + + + +
- to: {{ givenByMeTotals[contact.did] || 0 }} - - by: {{ givenToMeTotals[contact.did] || 0 }} - +
+ to: + {{ + /* eslint-disable prettier/prettier */ + this.showGiveTotals + ? ((givenByMeConfirmed[contact.did] || 0) + + (givenByMeUnconfirmed[contact.did] || 0)) + : this.showGiveConfirmed + ? (givenByMeConfirmed[contact.did] || 0) + : (givenByMeUnconfirmed[contact.did] || 0) + /* eslint-enable prettier/prettier */ + }} + {{ + givenByMeDescriptions[contact.did] + }} + +
+
+ by: + {{ + /* eslint-disable prettier/prettier */ + this.showGiveTotals + ? ((givenToMeConfirmed[contact.did] || 0) + + (givenToMeUnconfirmed[contact.did] || 0)) + : this.showGiveConfirmed + ? (givenToMeConfirmed[contact.did] || 0) + : (givenToMeUnconfirmed[contact.did] || 0) + /* eslint-enable prettier/prettier */ + }} + + {{ givenToMeDescriptions[contact.did] }} + + +
@@ -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 cac9e87..a48f6fc 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. +

    diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index 49d6497..13d4e1b 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -9,7 +9,7 @@ > -
  • +
  • -
  • +
  • - My Plans + Your Plans