12 changed files with 851 additions and 71 deletions
@ -0,0 +1,190 @@ |
|||||
|
<template> |
||||
|
<section id="Content"> |
||||
|
<div v-if="claimData"> |
||||
|
<canvas ref="claimCanvas"></canvas> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
canvas { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { nextTick } from "vue"; |
||||
|
import QRCode from "qrcode"; |
||||
|
|
||||
|
import { APP_SERVER, NotificationIface } from "@/constants/app"; |
||||
|
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; |
||||
|
import * as endorserServer from "@/libs/endorserServer"; |
||||
|
|
||||
|
@Component |
||||
|
export default class ClaimReportCertificateView extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
activeDid = ""; |
||||
|
allMyDids: Array<string> = []; |
||||
|
apiServer = ""; |
||||
|
claimId = ""; |
||||
|
claimData = null; |
||||
|
|
||||
|
endorserServer = endorserServer; |
||||
|
|
||||
|
async created() { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
const pathParams = window.location.pathname.substring( |
||||
|
"/claim-cert/".length, |
||||
|
); |
||||
|
this.claimId = pathParams; |
||||
|
await this.fetchClaim(); |
||||
|
} |
||||
|
|
||||
|
async fetchClaim() { |
||||
|
try { |
||||
|
const response = await fetch( |
||||
|
`${this.apiServer}/api/claim/${this.claimId}`, |
||||
|
); |
||||
|
if (response.ok) { |
||||
|
this.claimData = await response.json(); |
||||
|
await nextTick(); // Wait for the DOM to update |
||||
|
if (this.claimData) { |
||||
|
this.drawCanvas(this.claimData); |
||||
|
} |
||||
|
} else { |
||||
|
throw new Error(`Error fetching claim: ${response.statusText}`); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error("Failed to load claim:", error); |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: "There was a problem loading the claim.", |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async drawCanvas( |
||||
|
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>, |
||||
|
) { |
||||
|
await db.open(); |
||||
|
const allContacts = await db.contacts.toArray(); |
||||
|
|
||||
|
const canvas = this.$refs.claimCanvas as HTMLCanvasElement; |
||||
|
if (canvas) { |
||||
|
const CANVAS_WIDTH = 1100; |
||||
|
const CANVAS_HEIGHT = 850; |
||||
|
|
||||
|
// size to approximate portrait of 8.5"x11" |
||||
|
canvas.width = CANVAS_WIDTH; |
||||
|
canvas.height = CANVAS_HEIGHT; |
||||
|
const ctx = canvas.getContext("2d"); |
||||
|
if (ctx) { |
||||
|
// Load the background image |
||||
|
const backgroundImage = new Image(); |
||||
|
backgroundImage.src = "/img/background/cert-frame-2.jpg"; |
||||
|
backgroundImage.onload = async () => { |
||||
|
// Draw the background image |
||||
|
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); |
||||
|
|
||||
|
// Set font and styles |
||||
|
ctx.fillStyle = "black"; |
||||
|
|
||||
|
// Draw claim type |
||||
|
ctx.font = "bold 20px Arial"; |
||||
|
const claimTypeText = |
||||
|
this.endorserServer.capitalizeAndInsertSpacesBeforeCaps( |
||||
|
claimData.claimType || "", |
||||
|
); |
||||
|
const claimTypeWidth = ctx.measureText(claimTypeText).width; |
||||
|
ctx.fillText( |
||||
|
claimTypeText, |
||||
|
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally |
||||
|
CANVAS_HEIGHT * 0.33, |
||||
|
); |
||||
|
|
||||
|
if (claimData.claim.agent) { |
||||
|
const presentedText = "Presented to "; |
||||
|
ctx.font = "14px Arial"; |
||||
|
const presentedWidth = ctx.measureText(presentedText).width; |
||||
|
ctx.fillText( |
||||
|
presentedText, |
||||
|
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally |
||||
|
CANVAS_HEIGHT * 0.37, |
||||
|
); |
||||
|
const agentText = endorserServer.didInfoForCertificate( |
||||
|
claimData.claim.agent, |
||||
|
allContacts, |
||||
|
); |
||||
|
ctx.font = "bold 20px Arial"; |
||||
|
const agentWidth = ctx.measureText(agentText).width; |
||||
|
ctx.fillText( |
||||
|
agentText, |
||||
|
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally |
||||
|
CANVAS_HEIGHT * 0.4, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const descriptionText = |
||||
|
claimData.claim.name || claimData.claim.description; |
||||
|
if (descriptionText) { |
||||
|
const descriptionLine = |
||||
|
descriptionText.length > 50 |
||||
|
? descriptionText.substring(0, 75) + "..." |
||||
|
: descriptionText; |
||||
|
ctx.font = "14px Arial"; |
||||
|
const descriptionWidth = ctx.measureText(descriptionLine).width; |
||||
|
ctx.fillText( |
||||
|
descriptionLine, |
||||
|
(CANVAS_WIDTH - descriptionWidth) / 2, |
||||
|
CANVAS_HEIGHT * 0.45, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Draw claim issuer & recipient |
||||
|
if (claimData.issuer) { |
||||
|
ctx.font = "14px Arial"; |
||||
|
const issuerText = |
||||
|
"Issued by " + |
||||
|
endorserServer.didInfoForCertificate( |
||||
|
claimData.issuer, |
||||
|
allContacts, |
||||
|
); |
||||
|
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6); |
||||
|
} |
||||
|
|
||||
|
// Draw claim ID |
||||
|
ctx.font = "14px Arial"; |
||||
|
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7); |
||||
|
ctx.fillText( |
||||
|
"via EndorserSearch.com", |
||||
|
CANVAS_WIDTH * 0.3, |
||||
|
CANVAS_HEIGHT * 0.73, |
||||
|
); |
||||
|
|
||||
|
// Generate and draw QR code |
||||
|
const qrCodeCanvas = document.createElement("canvas"); |
||||
|
await QRCode.toCanvas( |
||||
|
qrCodeCanvas, |
||||
|
APP_SERVER + "/claim/" + this.claimId, |
||||
|
{ |
||||
|
width: 150, |
||||
|
color: { light: "#0000" /* Transparent background */ }, |
||||
|
}, |
||||
|
); |
||||
|
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55); |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,355 @@ |
|||||
|
<template> |
||||
|
<QuickNav selected="Contacts" /> |
||||
|
<TopMessage /> |
||||
|
|
||||
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> |
||||
|
<!-- Heading --> |
||||
|
<h1 id="ViewHeading" class="text-4xl text-center font-light"> |
||||
|
Onboarding Meeting |
||||
|
</h1> |
||||
|
|
||||
|
<!-- Existing Meeting Section --> |
||||
|
<div v-if="existingMeeting" class="mt-8 p-4 border rounded-lg bg-white shadow"> |
||||
|
<h2 class="text-2xl mb-4">Current Meeting</h2> |
||||
|
<div class="space-y-2"> |
||||
|
<p><strong>Name:</strong> {{ existingMeeting.name }}</p> |
||||
|
<p><strong>Expires:</strong> {{ formatExpirationTime(existingMeeting.expiresAt) }}</p> |
||||
|
<p class="mt-4 text-sm text-gray-600">Share the the password with the people you want to onboard.</p> |
||||
|
</div> |
||||
|
<div class="flex justify-end mt-4"> |
||||
|
<button |
||||
|
@click="confirmDelete" |
||||
|
class="flex items-center justify-center w-10 h-10 rounded-full bg-red-100 hover:bg-red-200 transition-colors duration-200" |
||||
|
:disabled="isDeleting" |
||||
|
:class="{ 'opacity-50 cursor-not-allowed': isDeleting }" |
||||
|
title="Delete Meeting" |
||||
|
> |
||||
|
<fa icon="trash-can" class="fa-fw text-red-600" /> |
||||
|
<span class="sr-only">{{ isDeleting ? 'Deleting...' : 'Delete Meeting' }}</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Delete Confirmation Dialog --> |
||||
|
<div v-if="showDeleteConfirm" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"> |
||||
|
<div class="bg-white rounded-lg p-6 max-w-sm w-full"> |
||||
|
<h3 class="text-lg font-medium mb-4">Delete Meeting?</h3> |
||||
|
<p class="text-gray-600 mb-6">This action cannot be undone. Are you sure you want to delete this meeting?</p> |
||||
|
<div class="flex justify-between space-x-4"> |
||||
|
<button |
||||
|
@click="showDeleteConfirm = false" |
||||
|
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700" |
||||
|
> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
@click="deleteMeeting" |
||||
|
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" |
||||
|
> |
||||
|
Delete |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Create New Meeting Form --> |
||||
|
<div v-if="!existingMeeting && !isLoading" class="mt-8"> |
||||
|
<h2 class="text-2xl mb-4">Create New Meeting</h2> |
||||
|
<form @submit.prevent="createMeeting" class="space-y-4"> |
||||
|
<div> |
||||
|
<label for="meetingName" class="block text-sm font-medium text-gray-700">Meeting Name</label> |
||||
|
<input |
||||
|
id="meetingName" |
||||
|
v-model="currentMeeting.name" |
||||
|
type="text" |
||||
|
required |
||||
|
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" |
||||
|
placeholder="Enter meeting name" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label for="expirationTime" class="block text-sm font-medium text-gray-700">Meeting Expiration Time</label> |
||||
|
<input |
||||
|
id="expirationTime" |
||||
|
v-model="currentMeeting.expiresAt" |
||||
|
type="datetime-local" |
||||
|
required |
||||
|
:min="minDateTime" |
||||
|
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label for="password" class="block text-sm font-medium text-gray-700">Meeting Password</label> |
||||
|
<input |
||||
|
id="password" |
||||
|
v-model="password" |
||||
|
type="text" |
||||
|
required |
||||
|
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" |
||||
|
placeholder="Enter meeting password" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label for="userName" class="block text-sm font-medium text-gray-700">Your Name</label> |
||||
|
<input |
||||
|
id="userName" |
||||
|
v-model="currentMeeting.userFullName" |
||||
|
type="text" |
||||
|
required |
||||
|
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" |
||||
|
placeholder="Your name" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<button |
||||
|
type="submit" |
||||
|
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800" |
||||
|
:disabled="isLoading" |
||||
|
> |
||||
|
{{ isLoading ? 'Creating...' : 'Create Meeting' }} |
||||
|
</button> |
||||
|
</form> |
||||
|
</div> |
||||
|
<div v-else-if="isLoading"> |
||||
|
<div class="flex justify-center items-center h-full"> |
||||
|
<fa icon="spinner" class="fa-spin-pulse" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from 'vue-facing-decorator'; |
||||
|
import QuickNav from '@/components/QuickNav.vue'; |
||||
|
import TopMessage from '@/components/TopMessage.vue'; |
||||
|
import { retrieveSettingsForActiveAccount } from '@/db/index'; |
||||
|
import { getHeaders, serverMessageForUser } from '@/libs/endorserServer'; |
||||
|
import { encryptMessage } from '@/libs/crypto'; |
||||
|
|
||||
|
interface Meeting { |
||||
|
groupId?: string; |
||||
|
name: string; |
||||
|
expiresAt: string; |
||||
|
} |
||||
|
|
||||
|
interface NewMeeting extends Meeting { |
||||
|
userFullName: string; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
components: { |
||||
|
QuickNav, |
||||
|
TopMessage, |
||||
|
}, |
||||
|
}) |
||||
|
export default class OnboardMeetingView extends Vue { |
||||
|
$notify!: (notification: { group: string; type: string; title: string; text: string }, timeout?: number) => void; |
||||
|
|
||||
|
existingMeeting: Meeting | null = null; |
||||
|
currentMeeting: NewMeeting = { |
||||
|
name: '', |
||||
|
expiresAt: this.getDefaultExpirationTime(), |
||||
|
userFullName: '', |
||||
|
}; |
||||
|
password = ''; |
||||
|
isLoading = false; |
||||
|
activeDid = ''; |
||||
|
apiServer = ''; |
||||
|
isDeleting = false; |
||||
|
showDeleteConfirm = false; |
||||
|
|
||||
|
get minDateTime() { |
||||
|
const now = new Date(); |
||||
|
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future |
||||
|
return this.formatDateForInput(now); |
||||
|
} |
||||
|
|
||||
|
async created() { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ''; |
||||
|
this.apiServer = settings.apiServer || ''; |
||||
|
this.currentMeeting.userFullName = settings.firstName || ''; |
||||
|
|
||||
|
await this.fetchExistingMeeting(); |
||||
|
} |
||||
|
|
||||
|
getDefaultExpirationTime(): string { |
||||
|
const date = new Date(); |
||||
|
// Round up to the next hour |
||||
|
date.setMinutes(0); |
||||
|
date.setSeconds(0); |
||||
|
date.setMilliseconds(0); |
||||
|
date.setHours(date.getHours() + 1); // Round up to next hour |
||||
|
date.setHours(date.getHours() + 2); // Add 2 more hours |
||||
|
return this.formatDateForInput(date); |
||||
|
} |
||||
|
|
||||
|
// Format a date object to YYYY-MM-DDTHH:mm format for datetime-local input |
||||
|
private formatDateForInput(date: Date): string { |
||||
|
const year = date.getFullYear(); |
||||
|
const month = String(date.getMonth() + 1).padStart(2, '0'); |
||||
|
const day = String(date.getDate()).padStart(2, '0'); |
||||
|
const hours = String(date.getHours()).padStart(2, '0'); |
||||
|
const minutes = String(date.getMinutes()).padStart(2, '0'); |
||||
|
|
||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`; |
||||
|
} |
||||
|
|
||||
|
async fetchExistingMeeting() { |
||||
|
try { |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
const response = await this.axios.get( |
||||
|
this.apiServer + '/api/partner/groupOnboard', |
||||
|
{ headers } |
||||
|
); |
||||
|
|
||||
|
if (response.data && response.data.data) { |
||||
|
this.existingMeeting = response.data.data; |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.log('Error fetching existing meeting:', error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: 'alert', |
||||
|
type: 'danger', |
||||
|
title: 'Error', |
||||
|
text: serverMessageForUser(error) || 'Failed to fetch existing meeting.', |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async createMeeting() { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
try { |
||||
|
// Convert local time to UTC for comparison and server submission |
||||
|
const localExpiresAt = new Date(this.currentMeeting.expiresAt); |
||||
|
const now = new Date(); |
||||
|
if (localExpiresAt <= now) { |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: 'alert', |
||||
|
type: 'warning', |
||||
|
title: 'Invalid Time', |
||||
|
text: 'Select a future time for the meeting expiration.', |
||||
|
}, |
||||
|
5000 |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// create content with user's name and DID encrypted with password |
||||
|
const content = { |
||||
|
name: this.currentMeeting.userFullName, |
||||
|
did: this.activeDid, |
||||
|
}; |
||||
|
const encryptedContent = await encryptMessage(JSON.stringify(content), this.password); |
||||
|
|
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
const response = await this.axios.post( |
||||
|
this.apiServer + '/api/partner/groupOnboard', |
||||
|
{ |
||||
|
name: this.currentMeeting.name, |
||||
|
expiresAt: localExpiresAt.toISOString(), |
||||
|
content: encryptedContent, |
||||
|
}, |
||||
|
{ headers } |
||||
|
); |
||||
|
|
||||
|
if (response.data && response.data.success) { |
||||
|
this.existingMeeting = { |
||||
|
groupId: response.data.groupId, |
||||
|
name: this.currentMeeting.name, |
||||
|
expiresAt: localExpiresAt.toISOString(), |
||||
|
}; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: 'alert', |
||||
|
type: 'success', |
||||
|
title: 'Success', |
||||
|
text: 'Meeting created.', |
||||
|
}, |
||||
|
3000 |
||||
|
); |
||||
|
} else { |
||||
|
throw new Error('Failed to create meeting due to unexpected response data: ' + JSON.stringify(response.data)); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('Error creating meeting:', error); |
||||
|
const errorMessage = serverMessageForUser(error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: 'alert', |
||||
|
type: 'danger', |
||||
|
title: 'Error', |
||||
|
text: errorMessage || 'Failed to create meeting. Try reloading or submitting again.', |
||||
|
}, |
||||
|
5000 |
||||
|
); |
||||
|
} finally { |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
formatExpirationTime(expiresAt: string): string { |
||||
|
const expiration = new Date(expiresAt); // Server time is in UTC |
||||
|
const now = new Date(); |
||||
|
const diffHours = Math.round((expiration.getTime() - now.getTime()) / (1000 * 60 * 60)); |
||||
|
|
||||
|
if (diffHours < 0) { |
||||
|
return 'Expired'; |
||||
|
} else if (diffHours < 1) { |
||||
|
return 'Less than an hour'; |
||||
|
} else if (diffHours === 1) { |
||||
|
return '1 hour'; |
||||
|
} else { |
||||
|
return `${diffHours} hours`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
confirmDelete() { |
||||
|
this.showDeleteConfirm = true; |
||||
|
} |
||||
|
|
||||
|
async deleteMeeting() { |
||||
|
this.isDeleting = true; |
||||
|
try { |
||||
|
const headers = await getHeaders(this.activeDid); |
||||
|
await this.axios.delete( |
||||
|
this.apiServer + '/api/partner/groupOnboard', |
||||
|
{ headers } |
||||
|
); |
||||
|
|
||||
|
this.existingMeeting = null; |
||||
|
this.showDeleteConfirm = false; |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: 'alert', |
||||
|
type: 'success', |
||||
|
title: 'Success', |
||||
|
text: 'Meeting deleted successfully.', |
||||
|
}, |
||||
|
3000 |
||||
|
); |
||||
|
} catch (error) { |
||||
|
console.error('Error deleting meeting:', error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: 'alert', |
||||
|
type: 'danger', |
||||
|
title: 'Error', |
||||
|
text: serverMessageForUser(error) || 'Failed to delete meeting.', |
||||
|
}, |
||||
|
5000 |
||||
|
); |
||||
|
} finally { |
||||
|
this.isDeleting = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
Loading…
Reference in new issue