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