You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

838 lines
26 KiB

<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 id="ViewHeading" class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="goBack"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</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 } }"
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link>
</h2>
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
Details
<font-awesome
v-if="showDidDetails"
icon="chevron-down"
class="text-blue-400"
/>
<font-awesome 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"
:profile-image-url="contactFromDid?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeProfileImage"
/>
</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"
title="They can see you"
@click="confirmSetVisibility(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" 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"
title="They cannot see you"
@click="confirmSetVisibility(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-if="
contactFromDid?.iViewContent &&
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"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.iViewContent &&
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"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-if="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"
title="Check Visibility"
@click="checkVisibility(contactFromDid)"
>
<font-awesome icon="rotate" class="fa-fw" />
</button>
</div>
<button
v-if="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 ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Registration"
@click="confirmRegister(contactFromDid)"
>
<font-awesome
v-if="contactFromDid?.registered"
icon="person-circle-check"
class="fa-fw"
/>
<font-awesome
v-else
icon="person-circle-question"
class="fa-fw"
/>
</button>
</div>
<button
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"
@click="confirmDeleteContact(contactFromDid)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<div v-if="!contactFromDid?.profileImageUrl">
<div>Auto-Generated Icon</div>
<div class="flex justify-center">
<EntityIcon
:entity-id="viewingDid"
:icon-size="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticon"
/>
</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
:entity-id="showLargeIdenticonId"
:icon-size="512"
:profile-image-url="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="hideLargeImage"
/>
</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
v-if="isLoading"
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
>
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</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
v-for="claim in claims"
:key="claim.handleId"
class="border-b border-slate-300"
>
<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 ?? "Unknown",
)
}}
</span>
<span class="col-span-2">
{{ claimAmount(claim.claim) }}
</span>
<span class="col-span-5">
{{ claimDescription(claim.claim) }}
</span>
<span class="col-span-1">
<a class="cursor-pointer" @click="onClickLoadClaim(claim.id)">
<font-awesome
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 { RouteLocationNormalizedLoaded, 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 { Contact } from "../db/tables/contacts";
import { BoundingBox } from "../db/tables/settings";
import {
GenericCredWrapper,
GenericVerifiableCredential,
GiveActionClaim,
OfferClaim,
} from "../interfaces";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
getHeaders,
register,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import EntityIcon from "../components/EntityIcon.vue";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_DEFAULT_TO_ACTIVE_DID,
NOTIFY_CONTACT_DELETED,
NOTIFY_CONTACT_DELETE_FAILED,
NOTIFY_REGISTRATION_SUCCESS,
NOTIFY_REGISTRATION_ERROR,
NOTIFY_SERVER_ACCESS_ERROR,
NOTIFY_NO_IDENTITY_ERROR,
} from "@/constants/notifications";
/**
* DIDView Component
*
* Displays detailed information about a DID (Decentralized Identifier) entity, including:
* - Basic identity information (name, profile image)
* - Contact management controls (visibility, registration status)
* - Associated claims and their details
*
* The view supports both viewing one's own DID and other contacts' DIDs.
* It provides infinite scrolling for claims and interactive controls for contact management.
*/
@Component({
components: {
EntityIcon,
InfiniteScroll,
QuickNav,
TopMessage,
},
mixins: [PlatformServiceMixin],
})
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
notify!: ReturnType<typeof createNotifyHelpers>;
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;
/**
* Initializes notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Initializes the view with DID information
*
* Workflow:
* 1. Retrieves active account settings (DID and API server)
* 2. Determines which DID to display from URL params or defaults to active DID
* 3. Loads contact information if available
* 4. Loads associated claims
* 5. Determines if viewing own DID
*/
async mounted() {
await this.initializeSettings();
await this.determineDIDToDisplay();
if (this.viewingDid) {
await this.loadContactInformation();
await this.loadClaimsAbout();
await this.checkIfOwnDID();
}
}
/**
* Initializes component settings from active account
*/
private async initializeSettings() {
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}
/**
* Determines which DID to display based on URL parameters
* Falls back to active DID if no parameter provided
*/
private async determineDIDToDisplay() {
const pathParam = window.location.pathname.substring("/did/".length);
let showDid = pathParam;
if (!showDid) {
showDid = this.activeDid;
if (showDid) {
this.notifyDefaultToActiveDID();
}
}
if (showDid) {
this.viewingDid = decodeURIComponent(showDid);
}
}
/**
* Notifies user that we're showing their DID info by default
*/
private notifyDefaultToActiveDID() {
this.notify.toast(
NOTIFY_DEFAULT_TO_ACTIVE_DID.title,
NOTIFY_DEFAULT_TO_ACTIVE_DID.message,
TIMEOUTS.SHORT,
);
}
/**
* Loads contact information for the viewing DID
* Updates contact YAML representation if contact exists
*/
private async loadContactInformation() {
if (!this.viewingDid) return;
const contact = await this.$getContact(this.viewingDid);
if (contact) {
this.contactFromDid = contact;
this.contactYaml = yaml.dump(this.contactFromDid);
} else {
this.contactFromDid = undefined;
this.contactYaml = "";
}
}
/**
* Checks if the viewing DID belongs to the current user
*/
private async checkIfOwnDID() {
if (!this.viewingDid) return;
const allAccountDids = await libsUtil.retrieveAccountDids();
this.isMyDid = allAccountDids.includes(this.viewingDid);
}
/**
* Loads additional claims when user scrolls to bottom
* Used by infinite scroll component to implement pagination
*
* @param payload - Boolean indicating if more data should be loaded
*/
async loadMoreData(payload: boolean) {
if (this.claims.length > 0 && !this.hitEnd && payload) {
this.loadClaimsAbout();
}
}
/**
* Navigation helper methods
*/
goBack() {
this.$router.go(-1);
}
/**
* UI state helper methods
*/
toggleDidDetails() {
this.showDidDetails = !this.showDidDetails;
}
showLargeProfileImage() {
this.showLargeIdenticonUrl = this.contactFromDid?.profileImageUrl;
}
showLargeIdenticon() {
this.showLargeIdenticonId = this.viewingDid;
}
hideLargeImage() {
this.showLargeIdenticonId = undefined;
this.showLargeIdenticonUrl = undefined;
}
/**
* Prompts user to confirm contact deletion
* Shows additional warning if contact has visibility permissions
*
* @param contact - Contact object to be deleted
*/
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.confirm(message, async () => {
await this.deleteContact(contact);
});
}
/**
* Deletes contact from local database and navigates back to contacts list
*
* @param contact - Contact object to be deleted
*/
async deleteContact(contact: Contact) {
const success = await this.$deleteContact(contact.did);
if (success) {
this.notify.success(NOTIFY_CONTACT_DELETED.message, TIMEOUTS.SHORT);
this.$router.push({ name: "contacts" });
} else {
this.notify.error(NOTIFY_CONTACT_DELETE_FAILED.message, TIMEOUTS.LONG);
}
}
/**
* Prompts user to confirm registering a contact
* Shows additional warning if contact is already registered
*
* @param contact - Contact to be registered
*/
async confirmRegister(contact: Contact) {
const message =
"Are you sure you want to register " +
libsUtil.nameForContact(this.contactFromDid, false) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?";
this.notify.confirm(message, async () => {
await this.register(contact);
});
}
/**
* Registers a contact with the endorser server
* Updates local database with registration status
*
* @param contact - Contact to register
*/
async register(contact: Contact) {
this.notify.toast("Processing", "Sent...", TIMEOUTS.SHORT);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
await this.$updateContact(contact.did, { registered: true });
const name = contact.name || "That unnamed person";
this.notify.success(
`${name} ${NOTIFY_REGISTRATION_SUCCESS.message}`,
TIMEOUTS.LONG,
);
} else {
this.notify.error(
(regResult.error as string) || NOTIFY_REGISTRATION_ERROR.message,
TIMEOUTS.LONG,
);
}
} catch (error) {
logger.error("Error when registering:", error);
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
const errorData = serverError.response?.data as {
error?: { message?: string };
};
if (errorData?.error?.message) {
userMessage = errorData.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.error(userMessage, TIMEOUTS.LONG);
}
}
/**
* Loads claims that involve the viewed DID
* Implements pagination using beforeId parameter
* Updates loading state and hit-end status
*/
public async loadClaimsAbout() {
if (!this.viewingDid) {
logger.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();
logger.error("Problem with full search:", details);
this.notify.error(NOTIFY_SERVER_ACCESS_ERROR.message, TIMEOUTS.LONG);
return;
}
const results = await response.json();
this.claims = this.claims.concat(results.data);
this.hitEnd = !results.hitLimit;
} catch (e: unknown) {
logger.error("Error with feed load:", e);
const error = e as { userMessage?: string };
this.notify.error(
error.userMessage || "There was a problem retrieving claims.",
TIMEOUTS.SHORT,
);
} finally {
this.isLoading = false;
}
}
/**
* Navigates to detailed claim view
*
* @param jwtId - JWT ID of the claim to view
*/
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
this.$router.push(route);
}
/**
* Extracts and formats claim amount information
* Handles different claim types (GiveAction, Offer)
*
* @param claim - Claim object to process
* @returns Formatted amount string or empty string if no amount
*/
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveActionClaim;
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 OfferClaim;
if (
offerClaim.includesObject?.unitCode &&
offerClaim.includesObject?.amountOfThisGood
) {
return displayAmount(
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
} else {
return "";
}
}
return "";
}
/**
* Extracts claim description
* Falls back to name if no description available
*
* @param claim - Claim to get description from
* @returns Description string or empty string
*/
claimDescription(claim: GenericVerifiableCredential) {
if (!claim || !claim.claim) {
return "";
}
const claimData = claim.claim as { name?: string; description?: string };
return claimData.name || claimData.description || "";
}
/**
* Prompts user to confirm visibility change for a contact
*
* @param contact - Contact to modify visibility for
* @param visibility - New visibility state to set
*/
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.confirm(visibilityPrompt, async () => {
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
});
}
/**
* Updates contact visibility on server and local database
*
* @param contact - Contact to update visibility for
* @param visibility - New visibility state
* @param showSuccessAlert - Whether to show success notification
* @returns Boolean indicating success
*/
async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
// Update contact visibility using mixin method
await this.$updateContact(contact.did, { seesMe: visibility });
if (showSuccessAlert) {
const message =
(contact.name || "That user") +
" can " +
(visibility ? "" : "not ") +
"see your activity.";
this.notify.success(message, TIMEOUTS.SHORT);
}
return true;
}
/**
* Checks current visibility status of contact on server
* Updates local database with current status
*
* @param contact - Contact to check visibility for
*/
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.error(NOTIFY_NO_IDENTITY_ERROR.message, TIMEOUTS.SHORT);
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 this.$updateContact(contact.did, { seesMe: visibility });
const message =
libsUtil.nameForContact(contact, true) +
" can" +
(visibility ? "" : " not") +
" see your activity.";
this.notify.info(message, TIMEOUTS.SHORT);
} else {
logger.error("Got bad server response checking visibility:", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.notify.error(message, TIMEOUTS.LONG);
}
} catch (err) {
logger.error("Caught error from request to check visibility:", err);
this.notify.error("Check connectivity and try again.", TIMEOUTS.SHORT);
}
}
/**
* Confirm whether the user want to see/hide the other's content, then execute it
*
* @param contact Contact content to show/hide from user
* @param view whether user wants to view this contact
*/
async confirmViewContent(contact: Contact, view: boolean) {
const contentVisibilityPrompt = view
? "Are you sure you want to see their content?"
: "Are you sure you want to hide their content from you?";
this.notify.confirm(contentVisibilityPrompt, async () => {
const success = await this.setViewContent(contact, view);
if (success) {
contact.iViewContent = view; // see visibility note about not working inside setVisibility
}
});
}
/**
* Updates contact content visibility for this device
*
* @param contact - Contact to update content visibility for
* @param visibility - New content visibility state
* @returns Boolean indicating success
*/
async setViewContent(contact: Contact, visibility: boolean) {
await this.$updateContact(contact.did, { iViewContent: visibility });
const message =
"You will" +
(visibility ? "" : " not") +
` see ${contact.name}'s activity.`;
this.notify.success(message, TIMEOUTS.SHORT);
return true;
}
}
</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>