<template> <QuickNav selected="Contacts" /> <TopMessage /> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Breadcrumb --> <div id="ViewBreadcrumb" class="mb-8"> <h1 class="text-lg text-center font-light relative px-7"> <!-- Back --> <button @click="$router.go(-1)" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" > <fa icon="chevron-left" class="fa-fw"></fa> </button> Identifier Details </h1> </div> <!-- Identity Details --> <div v-if="!!contactFromDid" class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4" > <div> <h2 class="text-xl font-semibold"> {{ contactFromDid?.name || "(no name)" }} <router-link :to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }" > <fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" /> </router-link> </h2> <button @click="showDidDetails = !showDidDetails" class="ml-2 mr-2 mt-4" > Details <fa v-if="showDidDetails" icon="chevron-down" class="text-blue-400" /> <fa v-else icon="chevron-right" class="text-blue-400" /> </button> <!-- Keep the dump contents directly between > and < to avoid weird spacing. --> <pre v-if="showDidDetails" class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" >{{ contactYaml }}</pre > </div> <div class="flex justify-center mt-4"> <span v-if="contactFromDid?.profileImageUrl" class="flex justify-between" > <EntityIcon :icon-size="96" :profileImageUrl="contactFromDid?.profileImageUrl" class="inline-block align-text-bottom border border-slate-300 rounded" @click="showLargeIdenticonUrl = contactFromDid?.profileImageUrl" /> </span> </div> <div class="flex justify-between mt-4"> <div class="flex items-center"> <div v-if="activeDid" class="flex justify-between"> <div> <button v-if=" contactFromDid?.seesMe && contactFromDid.did !== activeDid " class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" @click="confirmSetVisibility(contactFromDid, false)" title="They can see you" > <fa icon="eye" class="fa-fw" /> </button> <button v-else-if=" !contactFromDid?.seesMe && contactFromDid?.did !== activeDid " class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" @click="confirmSetVisibility(contactFromDid, true)" title="They cannot see you" > <fa icon="eye-slash" class="fa-fw" /> </button> <button class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md" @click="checkVisibility(contactFromDid)" title="Check Visibility" v-if="contactFromDid?.did !== activeDid" > <fa icon="rotate" class="fa-fw" /> </button> </div> <button @click="confirmRegister(contactFromDid)" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" v-if="contactFromDid?.did !== activeDid" title="Registration" > <fa v-if="contactFromDid?.registered" icon="person-circle-check" class="fa-fw" /> <fa v-else icon="person-circle-question" class="fa-fw" /> </button> </div> <button @click="confirmDeleteContact(contactFromDid)" class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md" title="Delete" > <fa icon="trash-can" class="fa-fw" /> </button> </div> <div v-if="!contactFromDid?.profileImageUrl"> <div>Auto-Generated Icon</div> <div class="flex justify-center"> <EntityIcon :entityId="viewingDid" :iconSize="64" class="inline-block align-middle border border-slate-300 rounded-md mr-1" @click="showLargeIdenticonId = viewingDid" /> </div> </div> </div> <div v-if="showLargeIdenticonId || showLargeIdenticonUrl" class="fixed z-[100] top-0 inset-x-0 w-full" > <div class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" > <EntityIcon :entityId="showLargeIdenticonId" :iconSize="512" :profileImageUrl="showLargeIdenticonUrl" class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" @click=" showLargeIdenticonId = undefined; showLargeIdenticonUrl = undefined; " /> </div> </div> </div> <div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <!-- !contactFromDid --> <div> <h2 class="text-xl font-semibold"> {{ isMyDid ? "You" : "(no name)" }} </h2> </div> </div> <!-- Loading Animation --> <div class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full" v-if="isLoading" > <fa icon="spinner" class="fa-spin-pulse"></fa> </div> <!-- Results List --> <div v-if="claims.length > 0" class="mt-4"> <div class="text-l font-bold text-center"> Claims That Involve {{ isMyDid ? "You" : "Them" }} </div> </div> <InfiniteScroll @reached-bottom="loadMoreData"> <ul> <li class="border-b border-slate-300" v-for="claim in claims" :key="claim.handleId" > <div class="grid grid-cols-12 gap-4"> <span class="col-span-2"> {{ claim.issuedAt.substring(0, 10) }} </span> <span class="col-span-2"> {{ capitalizeAndInsertSpacesBeforeCaps(claim.claimType) }} </span> <span class="col-span-2"> {{ claimAmount(claim) }} </span> <span class="col-span-5"> {{ claimDescription(claim) }} </span> <span class="col-span-1"> <a @click="onClickLoadClaim(claim.id)" class="cursor-pointer"> <fa icon="file-lines" class="pl-2 pt-1 text-blue-500" /> </a> </span> </div> </li> </ul> </InfiniteScroll> <div v-if="!isLoading && claims.length === 0" class="flex justify-center mt-4" > <span v-if="isMyDid">You have no claims yet.</span> <span v-else>They are in no claims visible to you.</span> </div> </section> </template> <script lang="ts"> import { AxiosError } from "axios"; import * as yaml from "js-yaml"; import { Component, Vue } from "vue-facing-decorator"; import { Router } from "vue-router"; import QuickNav from "@/components/QuickNav.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue"; import TopMessage from "@/components/TopMessage.vue"; import { NotificationIface } from "@/constants/app"; import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import { BoundingBox } from "@/db/tables/settings"; import { capitalizeAndInsertSpacesBeforeCaps, didInfoForContact, displayAmount, getHeaders, GenericCredWrapper, GenericVerifiableCredential, GiveVerifiableCredential, OfferVerifiableCredential, register, setVisibilityUtil, } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; import EntityIcon from "@/components/EntityIcon.vue"; @Component({ components: { EntityIcon, InfiniteScroll, QuickNav, TopMessage, }, }) export default class DIDView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; libsUtil = libsUtil; yaml = yaml; activeDid = ""; apiServer = ""; claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = []; contactFromDid?: Contact; contactYaml = ""; hitEnd = false; isLoading = false; isMyDid = false; searchBox: { name: string; bbox: BoundingBox } | null = null; showDidDetails = false; showLargeIdenticonId?: string; showLargeIdenticonUrl?: string; viewingDid?: string; capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps; didInfoForContact = didInfoForContact; displayAmount = displayAmount; async mounted() { const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; this.apiServer = settings.apiServer || ""; const pathParam = window.location.pathname.substring("/did/".length); if (pathParam) { this.viewingDid = decodeURIComponent(pathParam); this.contactFromDid = await db.contacts.get(this.viewingDid); if (this.contactFromDid) { this.contactYaml = yaml.dump(this.contactFromDid); } await this.loadClaimsAbout(); const allAccountDids = await libsUtil.retrieveAccountDids(); this.isMyDid = allAccountDids.includes(this.viewingDid); } } /** * Data loader used by infinite scroller * @param payload is the flag from the InfiniteScroll indicating if it should load **/ async loadMoreData(payload: boolean) { if (this.claims.length > 0 && !this.hitEnd && payload) { this.loadClaimsAbout(); } } // prompt with confirmation if they want to delete a contact confirmDeleteContact(contact: Contact) { let message = "Are you sure you want to remove " + libsUtil.nameForContact(contact, false) + " from your contact list?"; if (contact.seesMe) { message += " Note that they can see your activity, so if you want to hide your activity from them then you should do that first."; } this.$notify( { group: "modal", type: "confirm", title: "Delete", text: message, onYes: async () => { await this.deleteContact(contact); }, }, -1, ); } async deleteContact(contact: Contact) { await db.open(); await db.contacts.delete(contact.did); this.$notify( { group: "alert", type: "success", title: "Deleted", text: "Contact has been removed.", }, 3000, ); (this.$router as Router).push({ name: "contacts" }); } // confirm to register a new contact async confirmRegister(contact: Contact) { this.$notify( { group: "modal", type: "confirm", title: "Register", text: "Are you sure you want to register " + libsUtil.nameForContact(this.contactFromDid, false) + (contact.registered ? " -- especially since they are already marked as registered" : "") + "?", onYes: async () => { await this.register(contact); }, }, -1, ); } // note that this is also in ContactView.vue async register(contact: Contact) { this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); try { const regResult = await register( this.activeDid, this.apiServer, this.axios, contact, ); if (regResult.success) { contact.registered = true; await db.contacts.update(contact.did, { registered: true }); this.$notify( { group: "alert", type: "success", title: "Registration Success", text: (contact.name || "That unnamed person") + " has been registered.", }, 5000, ); } else { this.$notify( { group: "alert", type: "danger", title: "Registration Error", text: (regResult.error as string) || "Something went wrong during registration.", }, 5000, ); } } catch (error) { console.error("Error when registering:", error); let userMessage = "There was an error."; const serverError = error as AxiosError; if (serverError) { if (serverError.response?.data?.error?.message) { userMessage = serverError.response.data.error.message; } else 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.$notify( { group: "alert", type: "danger", title: "Registration Error", text: userMessage, }, 5000, ); } } public async loadClaimsAbout() { if (!this.viewingDid) { console.error("This should never be called without a DID."); return; } const queryParams = "claimContents=" + encodeURIComponent(this.viewingDid); let postfix = ""; if (this.claims.length > 0) { postfix = "&beforeId=" + this.claims[this.claims.length - 1].id; } try { this.isLoading = true; const response = await fetch( this.apiServer + "/api/v2/report/claims?" + queryParams + postfix, { method: "GET", headers: await getHeaders(this.activeDid), }, ); if (response.status !== 200) { const details = await response.text(); console.error("Problem with full search:", details); this.$notify( { group: "alert", type: "danger", title: "Error", text: `There was a problem accessing the server. Try again later.`, }, 5000, ); return; } const results = await response.json(); this.claims = this.claims.concat(results.data); this.hitEnd = !results.hitLimit; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { console.error("Error with feed load:", e); this.$notify( { group: "alert", type: "danger", title: "Error", text: e.userMessage || "There was a problem retrieving claims.", }, -1, ); } finally { this.isLoading = false; } } onClickLoadClaim(jwtId: string) { const route = { path: "/claim/" + encodeURIComponent(jwtId), }; (this.$router as Router).push(route); } public claimAmount(claim: GenericVerifiableCredential) { if (claim.claimType === "GiveAction") { const giveClaim = claim.claim as GiveVerifiableCredential; if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) { return displayAmount( giveClaim.object.unitCode, giveClaim.object.amountOfThisGood, ); } else { return ""; } } else if (claim.claimType === "Offer") { const offerClaim = claim.claim as OfferVerifiableCredential; if ( offerClaim.includesObject?.unitCode && offerClaim.includesObject?.amountOfThisGood ) { return displayAmount( offerClaim.includesObject.unitCode, offerClaim.includesObject.amountOfThisGood, ); } else { return ""; } } return ""; } claimDescription(claim: GenericVerifiableCredential) { return claim.claim.name || claim.claim.description || ""; } // note that this is also in ContactView.vue async confirmSetVisibility(contact: Contact, visibility: boolean) { const visibilityPrompt = visibility ? "Are you sure you want to make your activity visible to them?" : "Are you sure you want to hide all your activity from them?"; this.$notify( { group: "modal", type: "confirm", title: "Set Visibility", text: visibilityPrompt, onYes: async () => { const success = await this.setVisibility(contact, visibility, true); if (success) { contact.seesMe = visibility; // didn't work inside setVisibility } }, }, -1, ); } // note that this is also in ContactView.vue async setVisibility( contact: Contact, visibility: boolean, showSuccessAlert: boolean, ) { const result = await setVisibilityUtil( this.activeDid, this.apiServer, this.axios, db, contact, visibility, ); if (result.success) { //contact.seesMe = visibility; // why doesn't it affect the UI from here? //console.log("Set result & seesMe", result, contact.seesMe, contact.did); if (showSuccessAlert) { this.$notify( { group: "alert", type: "success", title: "Visibility Set", text: (contact.name || "That user") + " can " + (visibility ? "" : "not ") + "see your activity.", }, 3000, ); } return true; } else { console.error("Got strange result from setting visibility:", result); const message = (result.error as string) || "Could not set visibility on the server."; this.$notify( { group: "alert", type: "danger", title: "Error Setting Visibility", text: message, }, 5000, ); return false; } } // note that this is also in ContactView.vue async checkVisibility(contact: Contact) { const url = this.apiServer + "/api/report/canDidExplicitlySeeMe?did=" + encodeURIComponent(contact.did); const headers = await getHeaders(this.activeDid); if (!headers["Authorization"]) { this.$notify( { group: "alert", type: "danger", title: "No Identity", text: "There is no identity to use to check visibility.", }, 3000, ); return; } try { const resp = await this.axios.get(url, { headers }); if (resp.status === 200) { const visibility = resp.data; contact.seesMe = visibility; //console.log("Visi check:", visibility, contact.seesMe, contact.did); await db.contacts.update(contact.did, { seesMe: visibility }); this.$notify( { group: "alert", type: "info", title: "Visibility Refreshed", text: libsUtil.nameForContact(contact, true) + " can " + (visibility ? "" : "not ") + "see your activity.", }, 3000, ); } else { console.error("Got bad server response checking visibility:", resp); const message = resp.data.error?.message || "Got bad server response."; this.$notify( { group: "alert", type: "danger", title: "Error Checking Visibility", text: message, }, 5000, ); } } catch (err) { console.error("Caught error from request to check visibility:", err); this.$notify( { group: "alert", type: "danger", title: "Error Checking Visibility", text: "Check connectivity and try again.", }, 3000, ); } } } </script> <style> .dialog-overlay { z-index: 50; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; padding: 1.5rem; } .dialog { background-color: white; padding: 1rem; border-radius: 0.5rem; width: 100%; max-width: 500px; } </style>