Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec96bd8235 | |||
| 62ae603778 | |||
| b8ca2a03fe | |||
| 287a440b3e | |||
| 9411096ab7 | |||
| fe71c3f754 | |||
| 93831c372a | |||
| 34248a2ee5 | |||
| 0b05ca3de8 | |||
| dffecae565 | |||
| 4cd130244c | |||
| d5f4337558 | |||
| 114f0e4405 | |||
| 64830eeb05 | |||
| dd281e78fd | |||
| 31d573684a | |||
| 40765feea1 | |||
| 5ff91186e2 | |||
| 2a23587c3b | |||
| 51c8d8ac8b | |||
| 65cc13977d | |||
| 30552916a2 | |||
| 920d3f4d25 | |||
| d57aee203f | |||
| 7ababb4e1b | |||
| 41041d72c0 |
@@ -3,4 +3,4 @@ VITE_APP_SERVER=https://timesafari.app
|
|||||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
||||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.timesafari.app
|
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -6,10 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.54] - 2025.02.06
|
||||||
|
### Added
|
||||||
|
- Group onboarding meetings
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.53] - 2025.01.30
|
||||||
|
### Added
|
||||||
|
- Hints for contacting the creator of a project
|
||||||
|
|
||||||
|
|
||||||
|
## [0.3.52] - 2025.01.22
|
||||||
|
### Fixed
|
||||||
|
- User profile endpoint server for map was broken.
|
||||||
|
|
||||||
|
|
||||||
## [0.3.51] - 2025.01.22
|
## [0.3.51] - 2025.01.22
|
||||||
### Fixed
|
### Fixed
|
||||||
- User profile map jumped on first zoom.
|
- User profile map jumped on first zoom.
|
||||||
|
|
||||||
|
|
||||||
## [0.3.50] - 2025.01.20 - b9fedcd3fd3e34c3fb0fc79150d1a81a76eaeb40
|
## [0.3.50] - 2025.01.20 - b9fedcd3fd3e34c3fb0fc79150d1a81a76eaeb40
|
||||||
### Added
|
### Added
|
||||||
- User public profiles
|
- User public profiles
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ See [project.task.yaml](project.task.yaml) for current priorities.
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
|
We like pkgx: `sh <(curl https://pkgx.sh) +vite sh`
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
@@ -70,11 +70,11 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
|
|||||||
|
|
||||||
* `pkgx +npm sh`
|
* `pkgx +npm sh`
|
||||||
|
|
||||||
* `cd crowd-funder-for-time-pwa && git pull && git checkout 0.3.36 && npm install && npm run build && cd -`
|
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.36 && npm install && npm run build && cd -`
|
||||||
|
|
||||||
(The plain `npm run build` will use the .env.production file.)
|
(The plain `npm run build` uses the .env.production file.)
|
||||||
|
|
||||||
* Back up the time-safari/dist folder, then `mv time-safari/dist time-safari-dist-prev9` && `mv crowd-funder-for-time-pwa/dist time-safari/`
|
* Back up the time-safari/dist folder, then `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
|
||||||
|
|
||||||
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "TimeSafari",
|
"name": "TimeSafari",
|
||||||
"version": "0.3.51",
|
"version": "0.3.54",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "TimeSafari",
|
"name": "TimeSafari",
|
||||||
"version": "0.3.51",
|
"version": "0.3.54",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.1.2",
|
"@capacitor/android": "^6.1.2",
|
||||||
"@capacitor/cli": "^6.1.2",
|
"@capacitor/cli": "^6.1.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TimeSafari",
|
"name": "TimeSafari",
|
||||||
"version": "0.3.51",
|
"version": "0.3.54",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
|
|||||||
152
src/components/ChoiceButtonDialog.vue
Normal file
152
src/components/ChoiceButtonDialog.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<NotificationGroup group="customModal">
|
||||||
|
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||||
|
<Notification
|
||||||
|
v-slot="{ notifications, close }"
|
||||||
|
enter="transform ease-out duration-300 transition"
|
||||||
|
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
|
||||||
|
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
|
||||||
|
leave="transition ease-in duration-500"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
move="transition duration-500"
|
||||||
|
move-delay="delay-300"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
class="w-full"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||||
|
<span class="font-semibold text-lg">{{ title }}</span>
|
||||||
|
<p class="text-sm mb-2">{{ text }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleOption1(close)"
|
||||||
|
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
{{ option1Text }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleOption2(close)"
|
||||||
|
class="block w-full text-center text-md font-bold capitalize bg-blue-700 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
{{ option2Text }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleOption3(close)"
|
||||||
|
class="block w-full text-center text-md font-bold capitalize bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||||
|
>
|
||||||
|
{{ option3Text }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleCancel(close)"
|
||||||
|
class="block w-full text-center text-md font-bold capitalize bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Notification>
|
||||||
|
</div>
|
||||||
|
</NotificationGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class PromptDialog extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
title = "";
|
||||||
|
text = "";
|
||||||
|
option1Text = "";
|
||||||
|
option2Text = "";
|
||||||
|
option3Text = "";
|
||||||
|
onOption1?: () => void;
|
||||||
|
onOption2?: () => void;
|
||||||
|
onOption3?: () => void;
|
||||||
|
onCancel?: () => Promise<void>;
|
||||||
|
|
||||||
|
open(options: {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
option1Text?: string;
|
||||||
|
option2Text?: string;
|
||||||
|
option3Text?: string;
|
||||||
|
onOption1?: () => void;
|
||||||
|
onOption2?: () => void;
|
||||||
|
onOption3?: () => void;
|
||||||
|
onCancel?: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
this.title = options.title;
|
||||||
|
this.text = options.text;
|
||||||
|
this.option1Text = options.option1Text || "";
|
||||||
|
this.option2Text = options.option2Text || "";
|
||||||
|
this.option3Text = options.option3Text || "";
|
||||||
|
this.onOption1 = options.onOption1;
|
||||||
|
this.onOption2 = options.onOption2;
|
||||||
|
this.onOption3 = options.onOption3;
|
||||||
|
this.onCancel = options.onCancel;
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "customModal",
|
||||||
|
type: "confirm",
|
||||||
|
title: this.title,
|
||||||
|
text: this.text,
|
||||||
|
option1Text: this.option1Text,
|
||||||
|
option2Text: this.option2Text,
|
||||||
|
option3Text: this.option3Text,
|
||||||
|
onOption1: this.onOption1,
|
||||||
|
onOption2: this.onOption2,
|
||||||
|
onOption3: this.onOption3,
|
||||||
|
onCancel: this.onCancel,
|
||||||
|
} as NotificationIface,
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOption1(close: (id: string) => void) {
|
||||||
|
if (this.onOption1) {
|
||||||
|
this.onOption1();
|
||||||
|
}
|
||||||
|
close("string that does not matter");
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOption2(close: (id: string) => void) {
|
||||||
|
if (this.onOption2) {
|
||||||
|
this.onOption2();
|
||||||
|
}
|
||||||
|
close("string that does not matter");
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOption3(close: (id: string) => void) {
|
||||||
|
if (this.onOption3) {
|
||||||
|
this.onOption3();
|
||||||
|
}
|
||||||
|
close("string that does not matter");
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancel(close: (id: string) => void) {
|
||||||
|
if (this.onCancel) {
|
||||||
|
this.onCancel();
|
||||||
|
}
|
||||||
|
close("string that does not matter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -90,7 +90,11 @@
|
|||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
import { createAndSubmitGive, didInfo } from "@/libs/endorserServer";
|
import {
|
||||||
|
createAndSubmitGive,
|
||||||
|
didInfo,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
import * as libsUtil from "@/libs/util";
|
import * as libsUtil from "@/libs/util";
|
||||||
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
|
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
|
||||||
import { Contact } from "@/db/tables/contacts";
|
import { Contact } from "@/db/tables/contacts";
|
||||||
@@ -336,7 +340,7 @@ export default class GiftedDialog extends Vue {
|
|||||||
console.error("Error with give recordation caught:", error);
|
console.error("Error with give recordation caught:", error);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error.userMessage ||
|
error.userMessage ||
|
||||||
error.response?.data?.error?.message ||
|
serverMessageForUser(error) ||
|
||||||
"There was an error recording the give.";
|
"There was an error recording the give.";
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
|
|||||||
182
src/components/HiddenDidDialog.vue
Normal file
182
src/components/HiddenDidDialog.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>
|
||||||
|
<button @click="close" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<fa icon="times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<!-- This is somewhat similar to ClaimView.vue and ConfirmGiftView.vue -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="mb-4">
|
||||||
|
<span v-if="R.isEmpty(visibleToDids)">
|
||||||
|
The {{ roleName }} is not visible to you or any of your contacts.
|
||||||
|
</span>
|
||||||
|
<span v-else> The {{ roleName }} is not visible to you. </span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="R.isEmpty(visibleToDids)">
|
||||||
|
<p class="mt-2">
|
||||||
|
You can ask one of your contacts to take a look and see if their
|
||||||
|
contacts can see more details. Someone is connected to people closer
|
||||||
|
to them; if you don't know who to ask, try the person who registered
|
||||||
|
you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<p class="mb-2">
|
||||||
|
They are visible to some of your contacts. If you'd like an
|
||||||
|
introduction, ask them if they'll tell you more.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ml-4">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(visDid, idx) of visibleToDids"
|
||||||
|
:key="idx"
|
||||||
|
class="list-disc ml-4 mb-2"
|
||||||
|
>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span>
|
||||||
|
{{ didInfo(visDid) }}
|
||||||
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||||
|
<a
|
||||||
|
:href="`/did/${visDid}`"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<span v-if="canShare">
|
||||||
|
If you'd like an introduction,
|
||||||
|
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||||
|
>click here to share the information with them and ask if they'll
|
||||||
|
tell you more about the {{ roleName }}.</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
If you'd like an introduction,
|
||||||
|
<a
|
||||||
|
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||||
|
class="text-blue-500"
|
||||||
|
>click here to copy this page, paste it into a message, and ask if
|
||||||
|
they'll tell you more about the {{ roleName }}.</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import * as R from "ramda";
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class HiddenDidDialog extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
isOpen = false;
|
||||||
|
roleName = "";
|
||||||
|
visibleToDids: string[] = [];
|
||||||
|
allContacts: Array<Contact> = [];
|
||||||
|
activeDid = "";
|
||||||
|
allMyDids: Array<string> = [];
|
||||||
|
canShare = false;
|
||||||
|
windowLocation = window.location.href;
|
||||||
|
|
||||||
|
R = R;
|
||||||
|
serverUtil = serverUtil;
|
||||||
|
|
||||||
|
created() {
|
||||||
|
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||||
|
// then use this truer check: navigator.canShare && navigator.canShare()
|
||||||
|
this.canShare = !!navigator.share;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(
|
||||||
|
roleName: string,
|
||||||
|
visibleToDids: string[],
|
||||||
|
allContacts: Array<Contact>,
|
||||||
|
activeDid: string,
|
||||||
|
allMyDids: Array<string>,
|
||||||
|
) {
|
||||||
|
this.roleName = roleName;
|
||||||
|
this.visibleToDids = visibleToDids;
|
||||||
|
this.allContacts = allContacts;
|
||||||
|
this.activeDid = activeDid;
|
||||||
|
this.allMyDids = allMyDids;
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
didInfo(did: string) {
|
||||||
|
return serverUtil.didInfo(
|
||||||
|
did,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToClipboard(name: string, text: string) {
|
||||||
|
useClipboard()
|
||||||
|
.copy(text)
|
||||||
|
.then(() => {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "toast",
|
||||||
|
title: "Copied",
|
||||||
|
text: (name || "That") + " was copied to the clipboard.",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickShareClaim() {
|
||||||
|
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||||
|
window.navigator.share({
|
||||||
|
title: "Help Connect Me",
|
||||||
|
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||||
|
url: this.windowLocation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
495
src/components/MembersList.vue
Normal file
495
src/components/MembersList.vue
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||||
|
<fa icon="spinner" class="fa-spin-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members List -->
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="decryptedMembers.length < members.length"
|
||||||
|
class="text-center text-red-600 py-4"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
decryptFailureMessage ||
|
||||||
|
"Your password failed. Please go back and try again."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div v-if="missingMyself" class="py-4 text-red-600">
|
||||||
|
You are not admitted. The organizer will admit you.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
v-if="showOrganizerTools && isOrganizer"
|
||||||
|
class="inline-flex items-center flex-wrap"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
Use
|
||||||
|
<span
|
||||||
|
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
|
||||||
|
>
|
||||||
|
<fa icon="plus" class="text-sm" />
|
||||||
|
</span>
|
||||||
|
and
|
||||||
|
<span
|
||||||
|
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
|
||||||
|
>
|
||||||
|
<fa icon="minus" class="text-sm" />
|
||||||
|
</span>
|
||||||
|
to add/remove them to/from the meeting.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
Use
|
||||||
|
<span
|
||||||
|
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
|
||||||
|
>
|
||||||
|
<fa icon="circle-user" class="text-xl" />
|
||||||
|
</span>
|
||||||
|
to add them to your contacts.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="members.length > 0" class="flex justify-center">
|
||||||
|
<button
|
||||||
|
@click="fetchMembers"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
|
||||||
|
title="Refresh members list"
|
||||||
|
>
|
||||||
|
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="member in membersToShow()"
|
||||||
|
:key="member.member.memberId"
|
||||||
|
class="p-4 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h3 class="text-lg font-medium">{{ member.name }}</h3>
|
||||||
|
<div
|
||||||
|
v-if="!getContactFor(member.did) && member.did !== activeDid"
|
||||||
|
class="flex justify-end"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="addAsContact(member)"
|
||||||
|
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800 transition-colors"
|
||||||
|
title="Add as contact"
|
||||||
|
>
|
||||||
|
<fa icon="circle-user" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="member.did !== activeDid"
|
||||||
|
@click="
|
||||||
|
informAboutAddingContact(
|
||||||
|
getContactFor(member.did) !== undefined,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
|
||||||
|
title="Contact info"
|
||||||
|
>
|
||||||
|
<fa icon="circle-info" class="text-base" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
showOrganizerTools && isOrganizer && member.did !== activeDid
|
||||||
|
"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="checkWhetherContactBeforeAdmitting(member)"
|
||||||
|
class="mr-2 w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
|
||||||
|
:title="
|
||||||
|
member.member.admitted ? 'Remove member' : 'Admit member'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<fa
|
||||||
|
:icon="member.member.admitted ? 'minus' : 'plus'"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="informAboutAdmission()"
|
||||||
|
class="mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
|
||||||
|
title="Admission info"
|
||||||
|
>
|
||||||
|
<fa icon="circle-info" class="text-base" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 truncate">
|
||||||
|
{{ member.did }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="members.length > 0" class="flex justify-center mt-4">
|
||||||
|
<button
|
||||||
|
@click="fetchMembers"
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
|
||||||
|
title="Refresh members list"
|
||||||
|
>
|
||||||
|
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="members.length === 0" class="text-gray-500 py-4">
|
||||||
|
No members have joined this meeting yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import {
|
||||||
|
logConsoleAndDb,
|
||||||
|
retrieveSettingsForActiveAccount,
|
||||||
|
db,
|
||||||
|
} from "@/db/index";
|
||||||
|
import {
|
||||||
|
errorStringForLog,
|
||||||
|
getHeaders,
|
||||||
|
register,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { decryptMessage } from "@/libs/crypto";
|
||||||
|
import { Contact } from "@/db/tables/contacts";
|
||||||
|
import * as libsUtil from "@/libs/util";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
admitted: boolean;
|
||||||
|
content: string;
|
||||||
|
memberId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecryptedMember {
|
||||||
|
member: Member;
|
||||||
|
name: string;
|
||||||
|
did: string;
|
||||||
|
isRegistered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class MembersList extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
libsUtil = libsUtil;
|
||||||
|
|
||||||
|
@Prop({ required: true }) password!: string;
|
||||||
|
@Prop({ default: "Your password failed. Please go back and try again." })
|
||||||
|
decryptFailureMessage!: string;
|
||||||
|
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||||
|
|
||||||
|
decryptedMembers: DecryptedMember[] = [];
|
||||||
|
missingPassword = false;
|
||||||
|
missingMyself = false;
|
||||||
|
isLoading = false;
|
||||||
|
isOrganizer = false;
|
||||||
|
members: Member[] = [];
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
contacts: Array<Contact> = [];
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
|
this.activeDid = settings.activeDid || "";
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
await this.fetchMembers();
|
||||||
|
await this.loadContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMembers() {
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.get(
|
||||||
|
`${this.apiServer}/api/partner/groupOnboardMembers`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data && response.data.data) {
|
||||||
|
this.members = response.data.data;
|
||||||
|
await this.decryptMemberContents();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error fetching members: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.$emit(
|
||||||
|
"error",
|
||||||
|
serverMessageForUser(error) || "Failed to fetch members.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptMemberContents() {
|
||||||
|
this.decryptedMembers = [];
|
||||||
|
|
||||||
|
if (!this.password) {
|
||||||
|
this.missingPassword = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFirstEntry = true,
|
||||||
|
foundMyself = false;
|
||||||
|
for (const member of this.members) {
|
||||||
|
try {
|
||||||
|
const decryptedContent = await decryptMessage(
|
||||||
|
member.content,
|
||||||
|
this.password,
|
||||||
|
);
|
||||||
|
const content = JSON.parse(decryptedContent);
|
||||||
|
|
||||||
|
this.decryptedMembers.push({
|
||||||
|
member: member,
|
||||||
|
name: content.name,
|
||||||
|
did: content.did,
|
||||||
|
isRegistered: !!content.isRegistered,
|
||||||
|
});
|
||||||
|
if (isFirstEntry && content.did === this.activeDid) {
|
||||||
|
this.isOrganizer = true;
|
||||||
|
}
|
||||||
|
if (content.did === this.activeDid) {
|
||||||
|
foundMyself = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing, relying on the count of members to determine if there was an error
|
||||||
|
}
|
||||||
|
isFirstEntry = false;
|
||||||
|
}
|
||||||
|
this.missingMyself = !foundMyself;
|
||||||
|
}
|
||||||
|
|
||||||
|
membersToShow(): DecryptedMember[] {
|
||||||
|
if (this.isOrganizer) {
|
||||||
|
if (this.showOrganizerTools) {
|
||||||
|
return this.decryptedMembers;
|
||||||
|
} else {
|
||||||
|
return this.decryptedMembers.filter(
|
||||||
|
(member: DecryptedMember) => member.member.admitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// non-organizers only get visible members from server
|
||||||
|
return this.decryptedMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
informAboutAdmission() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Admission info",
|
||||||
|
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
|
||||||
|
},
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
informAboutAddingContact(contactImportedAlready: boolean) {
|
||||||
|
if (contactImportedAlready) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Contact Exists",
|
||||||
|
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
|
||||||
|
},
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Contact Available",
|
||||||
|
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
|
||||||
|
},
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadContacts() {
|
||||||
|
this.contacts = await db.contacts.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContactFor(did: string): Contact | undefined {
|
||||||
|
return this.contacts.find((contact) => contact.did === did);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
|
||||||
|
const contact = this.getContactFor(decrMember.did);
|
||||||
|
if (!decrMember.member.admitted && !contact) {
|
||||||
|
// If not a contact, show confirmation dialog
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Add as Contact First?",
|
||||||
|
text: "This person is not in your contacts. Would you like to add them as a contact first?",
|
||||||
|
yesText: "Add as Contact",
|
||||||
|
noText: "Skip Adding Contact",
|
||||||
|
onYes: async () => {
|
||||||
|
await this.addAsContact(decrMember);
|
||||||
|
// After adding as contact, proceed with admission
|
||||||
|
await this.toggleAdmission(decrMember);
|
||||||
|
},
|
||||||
|
onNo: async () => {
|
||||||
|
// If they choose not to add as contact, show second confirmation
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Continue Without Adding?",
|
||||||
|
text: "Are you sure you want to proceed with admission even though they are not a contact?",
|
||||||
|
yesText: "Continue",
|
||||||
|
onYes: async () => {
|
||||||
|
await this.toggleAdmission(decrMember);
|
||||||
|
},
|
||||||
|
onCancel: async () => {
|
||||||
|
// Do nothing, effectively canceling the operation
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If already a contact, proceed directly with admission
|
||||||
|
this.toggleAdmission(decrMember);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleAdmission(decrMember: DecryptedMember) {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
await this.axios.put(
|
||||||
|
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
|
||||||
|
{ admitted: !decrMember.member.admitted },
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
// Update local state
|
||||||
|
decrMember.member.admitted = !decrMember.member.admitted;
|
||||||
|
|
||||||
|
const oldContact = this.getContactFor(decrMember.did);
|
||||||
|
// if admitted, now register that user if they are not registered
|
||||||
|
if (
|
||||||
|
decrMember.member.admitted &&
|
||||||
|
!decrMember.isRegistered &&
|
||||||
|
!oldContact?.registered
|
||||||
|
) {
|
||||||
|
const contactOldOrNew: Contact = oldContact || {
|
||||||
|
did: decrMember.did,
|
||||||
|
name: decrMember.name,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await register(
|
||||||
|
this.activeDid,
|
||||||
|
this.apiServer,
|
||||||
|
this.axios,
|
||||||
|
contactOldOrNew,
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
decrMember.isRegistered = true;
|
||||||
|
if (oldContact) {
|
||||||
|
await db.contacts.update(decrMember.did, { registered: true });
|
||||||
|
oldContact.registered = true;
|
||||||
|
}
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Registered",
|
||||||
|
text: "Besides being admitted, they were also registered.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw result;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
// registration failure is likely explained by a message from the server
|
||||||
|
const additionalInfo =
|
||||||
|
serverMessageForUser(error) || error?.error || "";
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Registration failed",
|
||||||
|
text:
|
||||||
|
"They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
|
||||||
|
additionalInfo,
|
||||||
|
},
|
||||||
|
12000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error toggling admission: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.$emit(
|
||||||
|
"error",
|
||||||
|
serverMessageForUser(error) ||
|
||||||
|
"Failed to update member admission status.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAsContact(member: DecryptedMember) {
|
||||||
|
try {
|
||||||
|
const newContact = {
|
||||||
|
did: member.did,
|
||||||
|
name: member.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.contacts.add(newContact);
|
||||||
|
this.contacts.push(newContact);
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Contact Added",
|
||||||
|
text: "They were added to your contacts.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
|
||||||
|
let message = "An error prevented adding this contact.";
|
||||||
|
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
|
||||||
|
message = "This person is already in your contact list.";
|
||||||
|
}
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Contact Not Added",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -83,7 +83,10 @@
|
|||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||||
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
import { NotificationIface } from "@/constants/app";
|
||||||
import { createAndSubmitOffer } from "@/libs/endorserServer";
|
import {
|
||||||
|
createAndSubmitOffer,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
import * as libsUtil from "@/libs/util";
|
import * as libsUtil from "@/libs/util";
|
||||||
import { retrieveSettingsForActiveAccount } from "@/db/index";
|
import { retrieveSettingsForActiveAccount } from "@/db/index";
|
||||||
|
|
||||||
@@ -304,9 +307,9 @@ export default class OfferDialog extends Vue {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
getOfferCreationErrorMessage(result: any) {
|
getOfferCreationErrorMessage(result: any) {
|
||||||
return (
|
return (
|
||||||
|
serverMessageForUser(result) ||
|
||||||
result.error?.userMessage ||
|
result.error?.userMessage ||
|
||||||
result.error?.error ||
|
result.error?.error
|
||||||
result.response?.data?.error?.message
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
<h1 class="text-xl font-bold text-center mb-4 relative">
|
||||||
Welcome to Time Safari
|
Welcome to Time Safari
|
||||||
<br />
|
<br />
|
||||||
- Showcasing Gratitude & Magnifing Time
|
- Showcasing Gratitude & Magnifying Time
|
||||||
<div
|
<div
|
||||||
class="text-lg text-center leading-none absolute right-0 -top-1"
|
class="text-lg text-center leading-none absolute right-0 -top-1"
|
||||||
@click="onClickClose(true)"
|
@click="onClickClose(true)"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const newIdentifier = (
|
|||||||
*
|
*
|
||||||
*
|
*
|
||||||
* @param {string} mnemonic
|
* @param {string} mnemonic
|
||||||
* @return {*} {[string, string, string, string]}
|
* @return {[string, string, string, string]} address, privateHex, publicHex, derivationPath
|
||||||
*/
|
*/
|
||||||
export const deriveAddress = (
|
export const deriveAddress = (
|
||||||
mnemonic: string,
|
mnemonic: string,
|
||||||
@@ -88,7 +88,8 @@ export const generateSeed = (): string => {
|
|||||||
/**
|
/**
|
||||||
* Retrieve an access token, or "" if no DID is provided.
|
* Retrieve an access token, or "" if no DID is provided.
|
||||||
*
|
*
|
||||||
* @return {*}
|
* @param {string} did
|
||||||
|
* @return {string} JWT with basic payload
|
||||||
*/
|
*/
|
||||||
export const accessToken = async (did?: string) => {
|
export const accessToken = async (did?: string) => {
|
||||||
if (did) {
|
if (did) {
|
||||||
@@ -147,3 +148,156 @@ export const nextDerivationPath = (origDerivPath: string) => {
|
|||||||
.join("/");
|
.join("/");
|
||||||
return newDerivPath;
|
return newDerivPath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Base64 encoding/decoding utilities for browser
|
||||||
|
function base64ToArrayBuffer(base64: string): Uint8Array {
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const binary = String.fromCharCode(...new Uint8Array(buffer));
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SALT_LENGTH = 16;
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const KEY_LENGTH = 256;
|
||||||
|
const ITERATIONS = 100000;
|
||||||
|
|
||||||
|
// Encryption helper function
|
||||||
|
export async function encryptMessage(message: string, password: string) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
|
|
||||||
|
// Derive key from password using PBKDF2
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(password),
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveBits", "deriveKey"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: ITERATIONS,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: "AES-GCM", length: KEY_LENGTH },
|
||||||
|
false,
|
||||||
|
["encrypt"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypt the message
|
||||||
|
const encryptedContent = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encoder.encode(message),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return a JSON structure with base64-encoded components
|
||||||
|
const result = {
|
||||||
|
salt: arrayBufferToBase64(salt),
|
||||||
|
iv: arrayBufferToBase64(iv),
|
||||||
|
encrypted: arrayBufferToBase64(encryptedContent),
|
||||||
|
};
|
||||||
|
|
||||||
|
return btoa(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decryption helper function
|
||||||
|
export async function decryptMessage(encryptedJson: string, password: string) {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
|
||||||
|
|
||||||
|
// Convert base64 components back to Uint8Arrays
|
||||||
|
const saltArray = base64ToArrayBuffer(salt);
|
||||||
|
const ivArray = base64ToArrayBuffer(iv);
|
||||||
|
const encryptedContent = base64ToArrayBuffer(encrypted);
|
||||||
|
|
||||||
|
// Derive the same key using PBKDF2 with the extracted salt
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(password),
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveBits", "deriveKey"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: saltArray,
|
||||||
|
iterations: ITERATIONS,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: "AES-GCM", length: KEY_LENGTH },
|
||||||
|
false,
|
||||||
|
["decrypt"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt the content
|
||||||
|
const decryptedContent = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: ivArray,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encryptedContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert the decrypted content back to a string
|
||||||
|
return decoder.decode(decryptedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test function to verify encryption/decryption
|
||||||
|
export async function testEncryptionDecryption() {
|
||||||
|
try {
|
||||||
|
const testMessage = "Hello, this is a test message! 🚀";
|
||||||
|
const testPassword = "myTestPassword123";
|
||||||
|
|
||||||
|
console.log("Original message:", testMessage);
|
||||||
|
|
||||||
|
// Test encryption
|
||||||
|
console.log("Encrypting...");
|
||||||
|
const encrypted = await encryptMessage(testMessage, testPassword);
|
||||||
|
console.log("Encrypted result:", encrypted);
|
||||||
|
|
||||||
|
// Test decryption
|
||||||
|
console.log("Decrypting...");
|
||||||
|
const decrypted = await decryptMessage(encrypted, testPassword);
|
||||||
|
console.log("Decrypted result:", decrypted);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
const success = testMessage === decrypted;
|
||||||
|
console.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
|
||||||
|
console.log("Messages match:", success);
|
||||||
|
|
||||||
|
// Test with wrong password
|
||||||
|
console.log("\nTesting with wrong password...");
|
||||||
|
try {
|
||||||
|
await decryptMessage(encrypted, "wrongPassword");
|
||||||
|
console.log("Should not reach here");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Correctly failed with wrong password ✅");
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed with error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export function didInfoForContact(
|
|||||||
} else if (contact) {
|
} else if (contact) {
|
||||||
return {
|
return {
|
||||||
displayName: contact.name || "Contact With No Name",
|
displayName: contact.name || "Contact With No Name",
|
||||||
known: !!contact,
|
known: true,
|
||||||
profileImageUrl: contact.profileImageUrl,
|
profileImageUrl: contact.profileImageUrl,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -478,6 +478,19 @@ export function didInfoForContact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns full contact info object (never undefined), where did is searched in contacts and allMyDids
|
||||||
|
*/
|
||||||
|
export function didInfoObject(
|
||||||
|
did: string | undefined,
|
||||||
|
activeDid: string | undefined,
|
||||||
|
allMyDids: string[],
|
||||||
|
contacts: Contact[],
|
||||||
|
): { known: boolean; displayName: string; profileImageUrl?: string } {
|
||||||
|
const contact = contactForDid(did, contacts);
|
||||||
|
return didInfoForContact(did, activeDid, contact, allMyDids);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
always returns text, maybe something like "unnamed" or "unknown"
|
always returns text, maybe something like "unnamed" or "unknown"
|
||||||
|
|
||||||
@@ -608,41 +621,6 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
|
|||||||
max: 500,
|
max: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify
|
|
||||||
*
|
|
||||||
* @param error
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function errorStringForLog(error: any) {
|
|
||||||
let stringifiedError = "" + error;
|
|
||||||
try {
|
|
||||||
stringifiedError = JSON.stringify(error);
|
|
||||||
} catch (e) {
|
|
||||||
// can happen with Dexie, eg:
|
|
||||||
// TypeError: Converting circular structure to JSON
|
|
||||||
// --> starting at object with constructor 'DexieError2'
|
|
||||||
// | property '_promise' -> object with constructor 'DexiePromise'
|
|
||||||
// --- property '_value' closes the circle
|
|
||||||
}
|
|
||||||
let fullError = "" + error + " - JSON: " + stringifiedError;
|
|
||||||
const errorResponseText = JSON.stringify(error.response);
|
|
||||||
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
|
|
||||||
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
|
|
||||||
// add error.response stuff
|
|
||||||
if (R.equals(error?.config, error?.response?.config)) {
|
|
||||||
// but exclude "config" because it's already in there
|
|
||||||
const newErrorResponseText = JSON.stringify(
|
|
||||||
R.omit(["config"] as never[], error.response),
|
|
||||||
);
|
|
||||||
fullError += " - .response w/o same config JSON: " + newErrorResponseText;
|
|
||||||
} else {
|
|
||||||
fullError += " - .response JSON: " + errorResponseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fullError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param handleId nullable, in which case "undefined" will be returned
|
* @param handleId nullable, in which case "undefined" will be returned
|
||||||
* @param requesterDid optional, in which case no private info will be returned
|
* @param requesterDid optional, in which case no private info will be returned
|
||||||
@@ -697,6 +675,56 @@ export async function setPlanInCache(
|
|||||||
planCache.set(handleId, planSummary);
|
planCache.set(handleId, planSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param error that is thrown from an Endorser server call by Axios
|
||||||
|
* @returns user-friendly message, or undefined if none found
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function serverMessageForUser(error: any) {
|
||||||
|
return (
|
||||||
|
// this is how most user messages are returned
|
||||||
|
error?.response?.data?.error?.message
|
||||||
|
// some are returned as "error" with a string, but those are more for devs and are less helpful to the user
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify
|
||||||
|
* It works with AxiosError, eg handling an error.response intelligently.
|
||||||
|
*
|
||||||
|
* @param error
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function errorStringForLog(error: any) {
|
||||||
|
let stringifiedError = "" + error;
|
||||||
|
try {
|
||||||
|
stringifiedError = JSON.stringify(error);
|
||||||
|
} catch (e) {
|
||||||
|
// can happen with Dexie, eg:
|
||||||
|
// TypeError: Converting circular structure to JSON
|
||||||
|
// --> starting at object with constructor 'DexieError2'
|
||||||
|
// | property '_promise' -> object with constructor 'DexiePromise'
|
||||||
|
// --- property '_value' closes the circle
|
||||||
|
}
|
||||||
|
let fullError = "" + error + " - JSON: " + stringifiedError;
|
||||||
|
const errorResponseText = JSON.stringify(error.response);
|
||||||
|
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
|
||||||
|
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
|
||||||
|
// add error.response stuff
|
||||||
|
if (R.equals(error?.config, error?.response?.config)) {
|
||||||
|
// but exclude "config" because it's already in there
|
||||||
|
const newErrorResponseText = JSON.stringify(
|
||||||
|
R.omit(["config"] as never[], error.response),
|
||||||
|
);
|
||||||
|
fullError += " - .response w/o same config JSON: " + newErrorResponseText;
|
||||||
|
} else {
|
||||||
|
fullError += " - .response JSON: " + errorResponseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fullError;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @returns { data: Array<OfferSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
|
* @returns { data: Array<OfferSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
|
||||||
@@ -1100,7 +1128,7 @@ export async function createAndSubmitClaim(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error submitting claim:", error);
|
console.error("Error submitting claim:", error);
|
||||||
const errorMessage: string =
|
const errorMessage: string =
|
||||||
error.response?.data?.error?.message ||
|
serverMessageForUser(error) ||
|
||||||
error.message ||
|
error.message ||
|
||||||
"Got some error submitting the claim. Check your permissions, network, and error logs.";
|
"Got some error submitting the claim. Check your permissions, network, and error logs.";
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ export const isGiveAction = (
|
|||||||
return isGiveClaimType(veriClaim.claimType);
|
return isGiveClaimType(veriClaim.claimType);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const shortDid = (did: string) => {
|
||||||
|
if (did.startsWith("did:peer:")) {
|
||||||
|
return (
|
||||||
|
did.substring(0, "did:peer:".length + 2) +
|
||||||
|
"..." +
|
||||||
|
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
|
||||||
|
"..."
|
||||||
|
);
|
||||||
|
} else if (did.startsWith("did:ethr:")) {
|
||||||
|
return did.substring(0, "did:ethr:".length + 9) + "...";
|
||||||
|
} else {
|
||||||
|
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const nameForDid = (
|
export const nameForDid = (
|
||||||
activeDid: string,
|
activeDid: string,
|
||||||
contacts: Array<Contact>,
|
contacts: Array<Contact>,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
faCalendar,
|
faCalendar,
|
||||||
faCamera,
|
faCamera,
|
||||||
faCaretDown,
|
faCaretDown,
|
||||||
|
faChair,
|
||||||
faCheck,
|
faCheck,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
@@ -73,6 +74,7 @@ import {
|
|||||||
faPlus,
|
faPlus,
|
||||||
faQuestion,
|
faQuestion,
|
||||||
faQrcode,
|
faQrcode,
|
||||||
|
faRightFromBracket,
|
||||||
faRotate,
|
faRotate,
|
||||||
faShareNodes,
|
faShareNodes,
|
||||||
faSpinner,
|
faSpinner,
|
||||||
@@ -100,6 +102,7 @@ library.add(
|
|||||||
faCalendar,
|
faCalendar,
|
||||||
faCamera,
|
faCamera,
|
||||||
faCaretDown,
|
faCaretDown,
|
||||||
|
faChair,
|
||||||
faCheck,
|
faCheck,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
@@ -151,6 +154,7 @@ library.add(
|
|||||||
faQrcode,
|
faQrcode,
|
||||||
faQuestion,
|
faQuestion,
|
||||||
faRotate,
|
faRotate,
|
||||||
|
faRightFromBracket,
|
||||||
faShareNodes,
|
faShareNodes,
|
||||||
faSpinner,
|
faSpinner,
|
||||||
faSquare,
|
faSquare,
|
||||||
|
|||||||
@@ -179,6 +179,21 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "offer-details",
|
name: "offer-details",
|
||||||
component: () => import("../views/OfferDetailsView.vue"),
|
component: () => import("../views/OfferDetailsView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/onboard-meeting-list",
|
||||||
|
name: "onboard-meeting-list",
|
||||||
|
component: () => import("../views/OnboardMeetingListView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/onboard-meeting-members/:groupId",
|
||||||
|
name: "onboard-meeting-members",
|
||||||
|
component: () => import("../views/OnboardMeetingMembersView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/onboard-meeting-setup",
|
||||||
|
name: "onboard-meeting-setup",
|
||||||
|
component: () => import("../views/OnboardMeetingSetupView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/project/:id?",
|
path: "/project/:id?",
|
||||||
name: "project",
|
name: "project",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default class ClaimCertificateView extends Vue {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (claimData.claimType === "GiveAction" && claimData.claim.agent) {
|
if (claimData.claimType === "GiveAction" && claimData.claim.agent) {
|
||||||
const presentedText = "Thanks To ";
|
const presentedText = "Thanks To";
|
||||||
ctx.font = "14px Arial";
|
ctx.font = "14px Arial";
|
||||||
const presentedWidth = ctx.measureText(presentedText).width;
|
const presentedWidth = ctx.measureText(presentedText).width;
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
@@ -148,8 +148,36 @@ export default class ClaimCertificateView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alternatively, show some offer details
|
||||||
|
if (claimData.claimType === "Offer") {
|
||||||
|
const presentedText = "To";
|
||||||
|
ctx.font = "14px Arial";
|
||||||
|
const presentedWidth = ctx.measureText(presentedText).width;
|
||||||
|
ctx.fillText(
|
||||||
|
presentedText,
|
||||||
|
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
|
||||||
|
CANVAS_HEIGHT * 0.37,
|
||||||
|
);
|
||||||
|
// fulfills
|
||||||
|
const agentDid =
|
||||||
|
claimData.claim.agent.identifier || claimData.claim.agent;
|
||||||
|
const agentText = serverUtil.didInfoForCertificate(
|
||||||
|
agentDid,
|
||||||
|
allContacts,
|
||||||
|
);
|
||||||
|
ctx.font = "bold 20px Arial";
|
||||||
|
const agentWidth = ctx.measureText(agentText).width;
|
||||||
|
ctx.fillText(
|
||||||
|
agentText,
|
||||||
|
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
|
||||||
|
CANVAS_HEIGHT * 0.41,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const descriptionText =
|
const descriptionText =
|
||||||
claimData.claim.name || claimData.claim.description;
|
claimData.claim.name ||
|
||||||
|
claimData.claim.description ||
|
||||||
|
claimData.claim.itemOffered?.description; // for Offers
|
||||||
if (descriptionText) {
|
if (descriptionText) {
|
||||||
const descriptionLine =
|
const descriptionLine =
|
||||||
descriptionText.length > 50
|
descriptionText.length > 50
|
||||||
@@ -164,12 +192,12 @@ export default class ClaimCertificateView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const possibleObject =
|
||||||
claimData.claim.object?.amountOfThisGood &&
|
claimData.claim.object || // for GiveActions
|
||||||
claimData.claim.object?.unitCode
|
claimData.claim.includesObject; // for Offers
|
||||||
) {
|
if (possibleObject?.amountOfThisGood && possibleObject?.unitCode) {
|
||||||
const amount = claimData.claim.object.amountOfThisGood;
|
const amount = possibleObject.amountOfThisGood;
|
||||||
const unit = claimData.claim.object.unitCode;
|
const unit = possibleObject.unitCode;
|
||||||
const amountText = serverUtil.displayAmount(unit, amount);
|
const amountText = serverUtil.displayAmount(unit, amount);
|
||||||
const amountWidth = ctx.measureText(amountText).width;
|
const amountWidth = ctx.measureText(amountText).width;
|
||||||
// if there was no description then put this in that spot, otherwise put it below the description
|
// if there was no description then put this in that spot, otherwise put it below the description
|
||||||
|
|||||||
190
src/views/ClaimReportCertificateView.vue
Normal file
190
src/views/ClaimReportCertificateView.vue
Normal file
@@ -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>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<button
|
<button
|
||||||
title="Copy Link"
|
title="Copy Link"
|
||||||
@click="
|
@click="
|
||||||
copyToClipboard('Current page link', window.location.href)
|
copyToClipboard('A link to this page', window.location.href)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<fa icon="link" class="text-slate-500" />
|
<fa icon="link" class="text-slate-500" />
|
||||||
@@ -270,16 +270,13 @@
|
|||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
{{ didInfo(confirmerId) }}
|
{{ didInfo(confirmerId) }}
|
||||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
||||||
<button
|
<a
|
||||||
@click="
|
:href="`/did/${confirmerId}`"
|
||||||
copyToClipboard(
|
target="_blank"
|
||||||
'The DID of ' + confirmerId,
|
class="text-blue-500"
|
||||||
confirmerId,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
</button>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,16 +308,13 @@
|
|||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
{{ didInfo(confsVisibleTo) }}
|
{{ didInfo(confsVisibleTo) }}
|
||||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
|
||||||
<button
|
<a
|
||||||
@click="
|
:href="`/did/${confsVisibleTo}`"
|
||||||
copyToClipboard(
|
target="_blank"
|
||||||
'The DID of ' + confsVisibleTo,
|
class="text-blue-500"
|
||||||
confsVisibleTo,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
</button>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,7 +338,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Note that a similar section is found in ConfirmGiftView.vue -->
|
<!-- Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue -->
|
||||||
<h2
|
<h2
|
||||||
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
|
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
|
||||||
@click="showVeriClaimDump = !showVeriClaimDump"
|
@click="showVeriClaimDump = !showVeriClaimDump"
|
||||||
@@ -364,24 +358,26 @@
|
|||||||
Some of the details are not visible to you; they show as "HIDDEN". They
|
Some of the details are not visible to you; they show as "HIDDEN". They
|
||||||
are not visible to any of your direct contacts, either.
|
are not visible to any of your direct contacts, either.
|
||||||
<span v-if="canShare">
|
<span v-if="canShare">
|
||||||
If you'd like to ask any of your contacts to take a look and see if
|
You can ask one of your contacts to take a look and see if their
|
||||||
their contacts can see more details,
|
contacts can see more details:
|
||||||
<a @click="onClickShareClaim()" class="text-blue-500"
|
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||||
>click to send them this info</a
|
>click to send them this page info</a
|
||||||
>
|
>
|
||||||
and see if they are willing to make an introduction. They are surely
|
and see if they can make an introduction. Someone is connected to
|
||||||
connected to someone; if you don't know who to ask, you might try the
|
people closer to them; if you don't know who to ask, try the person
|
||||||
person who registered you.
|
who registered you.
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
If you'd like to ask any of your contacts to take a look and see if
|
You can ask one of your contacts to take a look and see if their
|
||||||
their contacts can see more details,
|
contacts can see more details:
|
||||||
<a
|
<a
|
||||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
>share this page with them</a
|
>click to copy this page info</a
|
||||||
>
|
>
|
||||||
and see if they are willing to make an introduction.
|
and see if they can make an introduction. Someone is connected to
|
||||||
|
people closer to them; if you don't know who to ask, try the person
|
||||||
|
who registered you.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -425,18 +421,21 @@
|
|||||||
<span>
|
<span>
|
||||||
{{ didInfo(visDid) }}
|
{{ didInfo(visDid) }}
|
||||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||||
<button
|
<a
|
||||||
@click="copyToClipboard('The DID of ' + visDid, visDid)"
|
:href="`/did/${visDid}`"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-500"
|
||||||
>
|
>
|
||||||
<fa icon="copy" class="text-slate-400 fa-fw" />
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
</button>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||||
>, found at <a
|
>, found at <a
|
||||||
:href="veriClaim.publicUrls?.[visDid]"
|
:href="veriClaim.publicUrls?.[visDid]"
|
||||||
|
target="_blank"
|
||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
>
|
>
|
||||||
<fa icon="globe" class="fa-fw text-slate-400" />
|
<fa icon="globe" class="fa-fw" />
|
||||||
{{
|
{{
|
||||||
veriClaim.publicUrls[visDid].substring(
|
veriClaim.publicUrls[visDid].substring(
|
||||||
veriClaim.publicUrls[visDid].indexOf("//") + 2,
|
veriClaim.publicUrls[visDid].indexOf("//") + 2,
|
||||||
@@ -452,7 +451,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isEditedGlobalId" class="mt-2">
|
<span v-if="isEditedGlobalId" class="mt-2">
|
||||||
This record is an edited version. The latest version is here.
|
This record is an edited version. The latest version is shown.
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
|
||||||
@@ -963,9 +962,10 @@ export default class ClaimView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClickShareClaim() {
|
onClickShareClaim() {
|
||||||
|
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||||
window.navigator.share({
|
window.navigator.share({
|
||||||
title: "Help Connect Me",
|
title: "Help Connect Me",
|
||||||
text: "I'm trying to find the full details of this claim. Can you help me?",
|
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||||
url: this.windowLocation,
|
url: this.windowLocation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,7 +254,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Note that a similar section is found in ClaimView.vue -->
|
<!-- Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue -->
|
||||||
<h2
|
<h2
|
||||||
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
|
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
|
||||||
@click="showVeriClaimDump = !showVeriClaimDump"
|
@click="showVeriClaimDump = !showVeriClaimDump"
|
||||||
@@ -274,24 +274,26 @@
|
|||||||
Some of the details are not visible to you; they show as "HIDDEN".
|
Some of the details are not visible to you; they show as "HIDDEN".
|
||||||
They are not visible to any of your direct contacts, either.
|
They are not visible to any of your direct contacts, either.
|
||||||
<span v-if="canShare">
|
<span v-if="canShare">
|
||||||
If you'd like to ask any of your contacts to take a look and see if
|
You can ask one of your contacts to take a look and see if their
|
||||||
their contacts can see more details,
|
contacts can see more details:
|
||||||
<a @click="onClickShareClaim()" class="text-blue-500"
|
<a @click="onClickShareClaim()" class="text-blue-500"
|
||||||
>click to send them this info</a
|
>click to send them this page info</a
|
||||||
>
|
>
|
||||||
and see if they are willing to make an introduction. They are surely
|
and see if they can make an introduction. Someone is connected to
|
||||||
connected to someone; if you don't know who to ask, you might try
|
people closer to them; if you don't know who to ask, try the person
|
||||||
the person who registered you.
|
who registered you.
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
If you'd like to ask any of your contacts to take a look and see if
|
You can ask one of your contacts to take a look and see if their
|
||||||
their contacts can see more details,
|
contacts can see more details:
|
||||||
<a
|
<a
|
||||||
@click="copyToClipboard('Location', windowLocation.href)"
|
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
>share this page with them</a
|
>click to copy this page info</a
|
||||||
>
|
>
|
||||||
and see if they are willing to make an introduction.
|
and see if they can make an introduction. Someone is connected to
|
||||||
|
people closer to them; if you don't know who to ask, try the person
|
||||||
|
who registered you.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,9 +310,7 @@
|
|||||||
<span v-else>
|
<span v-else>
|
||||||
If you'd like an introduction,
|
If you'd like an introduction,
|
||||||
<a
|
<a
|
||||||
@click="
|
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||||
copyToClipboard('A link to this page', windowLocation.href)
|
|
||||||
"
|
|
||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
>share this page with them and ask if they'll tell you more about
|
>share this page with them and ask if they'll tell you more about
|
||||||
about the participants.</a
|
about the participants.</a
|
||||||
@@ -448,7 +448,7 @@ export default class ClaimView extends Vue {
|
|||||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
veriClaimDump = "";
|
veriClaimDump = "";
|
||||||
veriClaimDidsVisible = {};
|
veriClaimDidsVisible = {};
|
||||||
windowLocation = window.location;
|
windowLocation = window.location.href;
|
||||||
|
|
||||||
R = R;
|
R = R;
|
||||||
yaml = yaml;
|
yaml = yaml;
|
||||||
@@ -856,10 +856,11 @@ export default class ClaimView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClickShareClaim() {
|
onClickShareClaim() {
|
||||||
|
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||||
window.navigator.share({
|
window.navigator.share({
|
||||||
title: "Help Connect Me",
|
title: "Help Connect Me",
|
||||||
text: "I'm trying to find the full details of this claim. Can you help me?",
|
text: "I'm trying to find the full details of this claim. Can you help me?",
|
||||||
url: this.windowLocation.href,
|
url: this.windowLocation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,27 +23,50 @@
|
|||||||
|
|
||||||
<!-- New Contact -->
|
<!-- New Contact -->
|
||||||
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
|
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
|
||||||
<router-link
|
<span class="flex" v-if="isRegistered">
|
||||||
v-if="isRegistered"
|
<router-link
|
||||||
:to="{ name: 'invite-one' }"
|
:to="{ name: 'invite-one' }"
|
||||||
class="flex items-center 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-1.5 py-1 mr-1 rounded-md"
|
class="flex items-center 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-1.5 py-1 mr-1 rounded-md"
|
||||||
>
|
>
|
||||||
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
|
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
|
||||||
</router-link>
|
</router-link>
|
||||||
<span
|
|
||||||
v-else
|
<button
|
||||||
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
@click="showOnboardMeetingDialog()"
|
||||||
>
|
class="flex items-center 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-1.5 py-1 mr-1 rounded-md"
|
||||||
<fa
|
>
|
||||||
icon="envelope-open-text"
|
<fa icon="chair" class="fa-fw text-2xl" />
|
||||||
class="fa-fw text-2xl"
|
</button>
|
||||||
@click="
|
</span>
|
||||||
danger(
|
<span v-else class="flex">
|
||||||
'You must get registered before you can invite others.',
|
<span
|
||||||
'Not Registered',
|
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||||
)
|
>
|
||||||
"
|
<fa
|
||||||
/>
|
icon="envelope-open-text"
|
||||||
|
class="fa-fw text-2xl"
|
||||||
|
@click="
|
||||||
|
warning(
|
||||||
|
'You must get registered before you can create invites.',
|
||||||
|
'Not Registered',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||||
|
>
|
||||||
|
<fa
|
||||||
|
icon="chair"
|
||||||
|
class="fa-fw text-2xl"
|
||||||
|
@click="
|
||||||
|
warning(
|
||||||
|
'You must get registered before you can initiate an onboarding meeting.',
|
||||||
|
'Not Registered',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
@@ -196,7 +219,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<span class="ml-4 text-sm overflow-hidden">{{
|
<span class="ml-4 text-sm overflow-hidden">{{
|
||||||
shortDid(contact.did)
|
libsUtil.shortDid(contact.did)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 text-sm">
|
<div class="ml-4 text-sm">
|
||||||
@@ -587,6 +610,18 @@ export default class ContactsView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private warning(message: string, title: string = "Error", timeout = 5000) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: title,
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private showOnboardingInfo() {
|
private showOnboardingInfo() {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
@@ -1338,21 +1373,6 @@ export default class ContactsView extends Vue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private shortDid(did: string) {
|
|
||||||
if (did.startsWith("did:peer:")) {
|
|
||||||
return (
|
|
||||||
did.substring(0, "did:peer:".length + 2) +
|
|
||||||
"..." +
|
|
||||||
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
|
|
||||||
"..."
|
|
||||||
);
|
|
||||||
} else if (did.startsWith("did:ethr:")) {
|
|
||||||
return did.substring(0, "did:ethr:".length + 9) + "...";
|
|
||||||
} else {
|
|
||||||
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private showCopySelectionsInfo() {
|
private showCopySelectionsInfo() {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
@@ -1364,5 +1384,59 @@ export default class ContactsView extends Vue {
|
|||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async showOnboardMeetingDialog() {
|
||||||
|
try {
|
||||||
|
// First check if they're in a meeting
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const memberResponse = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMember",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (memberResponse.data.data) {
|
||||||
|
// They're in a meeting, check if they're the host
|
||||||
|
const hostResponse = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupOnboard",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hostResponse.data.data) {
|
||||||
|
// They're the host, take them to setup
|
||||||
|
(this.$router as Router).push({ name: "onboard-meeting-setup" });
|
||||||
|
} else {
|
||||||
|
// They're not the host, take them to list
|
||||||
|
(this.$router as Router).push({ name: "onboard-meeting-list" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// They're not in a meeting, show the dialog
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Onboarding Meeting",
|
||||||
|
text: "Would you like to start a new meeting?",
|
||||||
|
onYes: async () => {
|
||||||
|
(this.$router as Router).push({ name: "onboard-meeting-setup" });
|
||||||
|
},
|
||||||
|
yesText: "Start New Meeting",
|
||||||
|
onNo: async () => {
|
||||||
|
(this.$router as Router).push({ name: "onboard-meeting-list" });
|
||||||
|
},
|
||||||
|
noText: "Join Existing Meeting",
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error checking meeting status:" + errorStringForLog(error),
|
||||||
|
);
|
||||||
|
this.danger(
|
||||||
|
"There was an error checking your meeting status.",
|
||||||
|
"Meeting Error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -366,6 +366,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ChoiceButtonDialog ref="choiceButtonDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -383,6 +385,7 @@ import OnboardingDialog from "@/components/OnboardingDialog.vue";
|
|||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import TopMessage from "@/components/TopMessage.vue";
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
import UserNameDialog from "@/components/UserNameDialog.vue";
|
import UserNameDialog from "@/components/UserNameDialog.vue";
|
||||||
|
import ChoiceButtonDialog from "@/components/ChoiceButtonDialog.vue";
|
||||||
import {
|
import {
|
||||||
AppString,
|
AppString,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
@@ -448,6 +451,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
|
|||||||
GiftedPrompts,
|
GiftedPrompts,
|
||||||
InfiniteScroll,
|
InfiniteScroll,
|
||||||
OnboardingDialog,
|
OnboardingDialog,
|
||||||
|
ChoiceButtonDialog,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
UserNameDialog,
|
UserNameDialog,
|
||||||
@@ -949,24 +953,22 @@ export default class HomeView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
promptForShareMethod() {
|
promptForShareMethod() {
|
||||||
this.$notify(
|
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
|
||||||
{
|
title: "How can you share your info?",
|
||||||
group: "modal",
|
text: "",
|
||||||
type: "confirm",
|
option1Text: "We are in a meeting together",
|
||||||
title: "Are you nearby with cameras?",
|
option2Text: "We are nearby with cameras",
|
||||||
text: "If so, we'll use those with QR codes to share.",
|
option3Text: "We will share some other way",
|
||||||
onCancel: async () => {},
|
onOption1: () => {
|
||||||
onNo: async () => {
|
(this.$router as Router).push({ name: "onboard-meeting-list" });
|
||||||
(this.$router as Router).push({ name: "share-my-contact-info" });
|
|
||||||
},
|
|
||||||
onYes: async () => {
|
|
||||||
(this.$router as Router).push({ name: "contact-qr" });
|
|
||||||
},
|
|
||||||
noText: "we will share another way",
|
|
||||||
yesText: "we are nearby with cameras",
|
|
||||||
},
|
},
|
||||||
-1,
|
onOption2: () => {
|
||||||
);
|
(this.$router as Router).push({ name: "contact-qr" });
|
||||||
|
},
|
||||||
|
onOption3: () => {
|
||||||
|
(this.$router as Router).push({ name: "share-my-contact-info" });
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<fa
|
<fa
|
||||||
v-if="dids[0] == selectedArrayFirstDid"
|
v-if="dids[0] == selectedArrayFirstDid"
|
||||||
icon="circle"
|
icon="circle"
|
||||||
class="fa-fw text-blue-400 text-xl mr-3"
|
class="fa-fw text-blue-500 text-xl mr-3"
|
||||||
></fa>
|
></fa>
|
||||||
<fa
|
<fa
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -203,8 +203,16 @@ import "leaflet/dist/leaflet.css";
|
|||||||
import { AxiosError, AxiosRequestHeaders } from "axios";
|
import { AxiosError, AxiosRequestHeaders } from "axios";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
import type { EventTemplate, VerifiedEvent } from "nostr-tools/lib/types/core";
|
// these core imports could also be included as "import type ..."
|
||||||
import { accountFromSeedWords } from "nostr-tools/nip06";
|
import {
|
||||||
|
EventTemplate,
|
||||||
|
UnsignedEvent,
|
||||||
|
VerifiedEvent,
|
||||||
|
} from "nostr-tools/lib/types/core";
|
||||||
|
import {
|
||||||
|
accountFromExtendedKey,
|
||||||
|
extendedKeysFromSeedWords,
|
||||||
|
} from "nostr-tools/nip06";
|
||||||
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
|
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||||
@@ -225,7 +233,6 @@ import {
|
|||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import {
|
import {
|
||||||
retrieveAccountCount,
|
retrieveAccountCount,
|
||||||
retrieveAccountMetadata,
|
|
||||||
retrieveFullyDecryptedAccount,
|
retrieveFullyDecryptedAccount,
|
||||||
} from "@/libs/util";
|
} from "@/libs/util";
|
||||||
|
|
||||||
@@ -472,31 +479,45 @@ export default class NewEditProjectView extends Vue {
|
|||||||
try {
|
try {
|
||||||
const resp = await this.axios.post(url, payload, { headers });
|
const resp = await this.axios.post(url, payload, { headers });
|
||||||
if (resp.data?.success?.handleId) {
|
if (resp.data?.success?.handleId) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Saved",
|
||||||
|
text: "The project was saved successfully.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
|
||||||
this.errorMessage = "";
|
this.errorMessage = "";
|
||||||
|
|
||||||
const projectPath = encodeURIComponent(resp.data.success.handleId);
|
const projectPath = encodeURIComponent(resp.data.success.handleId);
|
||||||
|
|
||||||
if (this.sendToTrustroots || this.sendToTripHopping) {
|
if (this.sendToTrustroots || this.sendToTripHopping) {
|
||||||
if (this.latitude && this.longitude) {
|
if (this.latitude && this.longitude) {
|
||||||
let signedPayload: VerifiedEvent | undefined; // sign something to prove ownership of pubkey
|
let payloadAndKey; // sign something to prove ownership of pubkey
|
||||||
if (this.sendToTrustroots) {
|
if (this.sendToTrustroots) {
|
||||||
signedPayload = await this.signPayload();
|
payloadAndKey = await this.signSomePayload();
|
||||||
|
// not going to await... the save was successful, so we'll continue to the next page
|
||||||
this.sendToNostrPartner(
|
this.sendToNostrPartner(
|
||||||
"NOSTR-EVENT-TRUSTROOTS",
|
"NOSTR-EVENT-TRUSTROOTS",
|
||||||
"Trustroots",
|
"Trustroots",
|
||||||
resp.data.success.claimId,
|
resp.data.success.claimId,
|
||||||
signedPayload,
|
payloadAndKey.signedEvent,
|
||||||
|
payloadAndKey.publicExtendedKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.sendToTripHopping) {
|
if (this.sendToTripHopping) {
|
||||||
if (!signedPayload) {
|
if (!payloadAndKey) {
|
||||||
signedPayload = await this.signPayload();
|
payloadAndKey = await this.signSomePayload();
|
||||||
}
|
}
|
||||||
|
// not going to await... the save was successful, so we'll continue to the next page
|
||||||
this.sendToNostrPartner(
|
this.sendToNostrPartner(
|
||||||
"NOSTR-EVENT-TRIPHOPPING",
|
"NOSTR-EVENT-TRIPHOPPING",
|
||||||
"TripHopping",
|
"TripHopping",
|
||||||
resp.data.success.claimId,
|
resp.data.success.claimId,
|
||||||
signedPayload,
|
payloadAndKey.signedEvent,
|
||||||
|
payloadAndKey.publicExtendedKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -576,19 +597,28 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async signPayload(): Promise<VerifiedEvent> {
|
/**
|
||||||
|
* @return a signed payload and an extended public key for later transmission
|
||||||
|
*/
|
||||||
|
private async signSomePayload(): Promise<{
|
||||||
|
signedEvent: VerifiedEvent;
|
||||||
|
publicExtendedKey: string;
|
||||||
|
}> {
|
||||||
const account = await retrieveFullyDecryptedAccount(this.activeDid);
|
const account = await retrieveFullyDecryptedAccount(this.activeDid);
|
||||||
// get the last number of the derivationPath
|
// get the last number of the derivationPath
|
||||||
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
|
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
|
||||||
// remove any trailing '
|
// remove any trailing '
|
||||||
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
|
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
|
||||||
const accountNum = Number(finalDerNumNoApostrophe || 0);
|
const accountNum = Number(finalDerNumNoApostrophe || 0);
|
||||||
const pubPri = accountFromSeedWords(
|
const extPubPri = extendedKeysFromSeedWords(
|
||||||
account?.mnemonic as string,
|
account?.mnemonic as string,
|
||||||
"",
|
"",
|
||||||
accountNum,
|
accountNum,
|
||||||
);
|
);
|
||||||
const privateBytes = hexToBytes(pubPri?.privateKey);
|
const publicExtendedKey: string = extPubPri?.publicExtendedKey;
|
||||||
|
const privateExtendedKey = extPubPri?.privateExtendedKey;
|
||||||
|
const privateKey = accountFromExtendedKey(privateExtendedKey).privateKey;
|
||||||
|
const privateBytes = hexToBytes(privateKey);
|
||||||
// No real content is necessary, we just want something signed,
|
// No real content is necessary, we just want something signed,
|
||||||
// so we might as well use nostr libs for nostr functions.
|
// so we might as well use nostr libs for nostr functions.
|
||||||
// Besides: someday we may create real content that we can relay.
|
// Besides: someday we may create real content that we can relay.
|
||||||
@@ -598,9 +628,12 @@ export default class NewEditProjectView extends Vue {
|
|||||||
content: "",
|
content: "",
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
};
|
};
|
||||||
// Why does IntelliJ not see matching types?
|
const signedEvent: VerifiedEvent = finalizeEvent(
|
||||||
const signedEvent = finalizeEvent(event, privateBytes);
|
// Why does IntelliJ not see matching types?
|
||||||
return signedEvent;
|
event as EventTemplate,
|
||||||
|
privateBytes,
|
||||||
|
) as VerifiedEvent;
|
||||||
|
return { signedEvent, publicExtendedKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendToNostrPartner(
|
private async sendToNostrPartner(
|
||||||
@@ -608,41 +641,37 @@ export default class NewEditProjectView extends Vue {
|
|||||||
serviceName: string,
|
serviceName: string,
|
||||||
jwtId: string,
|
jwtId: string,
|
||||||
signedPayload: VerifiedEvent,
|
signedPayload: VerifiedEvent,
|
||||||
|
publicExtendedKey: string,
|
||||||
) {
|
) {
|
||||||
// first, get the public key for nostr
|
|
||||||
const account = await retrieveAccountMetadata(this.activeDid);
|
|
||||||
// get the last number of the derivationPath
|
|
||||||
const finalDerNum = account?.derivationPath?.split?.("/")?.reverse()[0];
|
|
||||||
// remove any trailing '
|
|
||||||
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
|
|
||||||
const accountNum = Number(finalDerNumNoApostrophe || 0);
|
|
||||||
const pubPri = accountFromSeedWords(
|
|
||||||
account?.mnemonic as string,
|
|
||||||
"",
|
|
||||||
accountNum,
|
|
||||||
);
|
|
||||||
const nostrPubKey = pubPri?.publicKey;
|
|
||||||
|
|
||||||
let partnerServer = DEFAULT_PARTNER_API_SERVER;
|
|
||||||
const settings = await retrieveSettingsForActiveAccount();
|
|
||||||
if (settings.partnerApiServer) {
|
|
||||||
partnerServer = settings.partnerApiServer;
|
|
||||||
}
|
|
||||||
const endorserPartnerUrl = partnerServer + "/api/partner/link";
|
|
||||||
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
|
|
||||||
const content = this.fullClaim.name + " - see " + timeSafariUrl;
|
|
||||||
// Why does IntelliJ not see matching types?
|
|
||||||
const payload = serializeEvent(signedPayload);
|
|
||||||
const partnerParams = {
|
|
||||||
jwtId: jwtId,
|
|
||||||
linkCode: linkCode,
|
|
||||||
inputJson: JSON.stringify(content),
|
|
||||||
pubKeyHex: nostrPubKey,
|
|
||||||
pubKeyImage: payload,
|
|
||||||
pubKeySigHex: signedPayload.sig,
|
|
||||||
};
|
|
||||||
const headers = await getHeaders(this.activeDid);
|
|
||||||
try {
|
try {
|
||||||
|
let partnerServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
|
if (settings.partnerApiServer) {
|
||||||
|
partnerServer = settings.partnerApiServer;
|
||||||
|
}
|
||||||
|
const endorserPartnerUrl = partnerServer + "/api/partner/link";
|
||||||
|
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
|
||||||
|
const content = this.fullClaim.name + " - see " + timeSafariUrl;
|
||||||
|
const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey;
|
||||||
|
const unsignedPayload: UnsignedEvent = {
|
||||||
|
// why doesn't "...signedPayload" work?
|
||||||
|
kind: signedPayload.kind,
|
||||||
|
tags: signedPayload.tags,
|
||||||
|
content: signedPayload.content,
|
||||||
|
created_at: signedPayload.created_at,
|
||||||
|
pubkey: publicKeyHex,
|
||||||
|
};
|
||||||
|
// Why does IntelliJ not see matching types?
|
||||||
|
const payload = serializeEvent(unsignedPayload as UnsignedEvent);
|
||||||
|
const partnerParams = {
|
||||||
|
jwtId: jwtId,
|
||||||
|
linkCode: linkCode,
|
||||||
|
inputJson: JSON.stringify(content),
|
||||||
|
pubKeyHex: publicKeyHex,
|
||||||
|
pubKeyImage: payload,
|
||||||
|
pubKeySigHex: signedPayload.sig,
|
||||||
|
};
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
const linkResp = await this.axios.post(
|
const linkResp = await this.axios.post(
|
||||||
endorserPartnerUrl,
|
endorserPartnerUrl,
|
||||||
partnerParams,
|
partnerParams,
|
||||||
@@ -731,7 +760,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "info",
|
type: "info",
|
||||||
title: "About Nostr Events",
|
title: "About Nostr Events",
|
||||||
text: "This will cause a submission to a partner on the nostr network. It will contain your public key data which may allow correlation, so don't allow this if you're not comfortable with that.",
|
text: "This will submit this project to a partner on the nostr network. It will contain your public key data which may allow correlation, so don't allow this if you're not comfortable with that.",
|
||||||
},
|
},
|
||||||
7000,
|
7000,
|
||||||
);
|
);
|
||||||
|
|||||||
343
src/views/OnboardMeetingListView.vue
Normal file
343
src/views/OnboardMeetingListView.vue
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
<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 mb-8">
|
||||||
|
Onboarding Meetings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||||
|
<fa icon="spinner" class="fa-spin-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="attendingMeeting">
|
||||||
|
<p>You are in this meeting.</p>
|
||||||
|
<div
|
||||||
|
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
@click="promptPassword(attendingMeeting)"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-medium">{{ attendingMeeting.name }}</h2>
|
||||||
|
<button
|
||||||
|
@click.stop="leaveMeeting"
|
||||||
|
class="text-red-600 hover:text-red-700 p-2"
|
||||||
|
title="Leave Meeting"
|
||||||
|
>
|
||||||
|
<fa icon="right-from-bracket" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meeting List -->
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="meeting in meetings"
|
||||||
|
:key="meeting.groupId"
|
||||||
|
class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
@click="promptPassword(meeting)"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-medium">{{ meeting.name }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="meetings.length === 0" class="text-center text-gray-500 py-8">
|
||||||
|
No onboarding meetings available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Dialog -->
|
||||||
|
<div
|
||||||
|
v-if="showPasswordDialog"
|
||||||
|
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">Enter Meeting Password</h3>
|
||||||
|
<input
|
||||||
|
ref="passwordInput"
|
||||||
|
v-model="password"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border rounded-md mb-4"
|
||||||
|
placeholder="Enter password"
|
||||||
|
@keyup.enter="submitPassword"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<button
|
||||||
|
@click="cancelPasswordDialog"
|
||||||
|
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="submitPassword"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { nextTick } from "vue";
|
||||||
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
|
||||||
|
import {
|
||||||
|
errorStringForLog,
|
||||||
|
getHeaders,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { encryptMessage } from "@/libs/crypto";
|
||||||
|
|
||||||
|
interface Meeting {
|
||||||
|
name: string;
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class OnboardMeetingListView extends Vue {
|
||||||
|
$notify!: (
|
||||||
|
notification: {
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
onYes?: () => void;
|
||||||
|
yesText?: string;
|
||||||
|
},
|
||||||
|
timeout?: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
attendingMeeting: Meeting | null = null;
|
||||||
|
firstName = "";
|
||||||
|
isLoading = false;
|
||||||
|
isRegistered = false;
|
||||||
|
meetings: Meeting[] = [];
|
||||||
|
password = "";
|
||||||
|
selectedMeeting: Meeting | null = null;
|
||||||
|
showPasswordDialog = false;
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
|
this.activeDid = settings.activeDid || "";
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
this.firstName = settings.firstName || "";
|
||||||
|
this.isRegistered = !!settings.isRegistered;
|
||||||
|
await this.fetchMeetings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMeetings() {
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
// get the meeting that the user is attending
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMember",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.data) {
|
||||||
|
// they're in a meeting already
|
||||||
|
const attendingMeetingId = response.data.data.groupId;
|
||||||
|
// retrieve the meeting details
|
||||||
|
const headers2 = await getHeaders(this.activeDid);
|
||||||
|
const response2 = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupOnboard/" + attendingMeetingId,
|
||||||
|
{ headers: headers2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response2.data?.data) {
|
||||||
|
this.attendingMeeting = response2.data.data;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// this should never happen
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error fetching meeting for user after saying they are in one.",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers2 = await getHeaders(this.activeDid);
|
||||||
|
const response2 = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupsOnboarding",
|
||||||
|
{ headers: headers2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response2.data?.data) {
|
||||||
|
this.meetings = response2.data.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error fetching meetings: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: serverMessageForUser(error) || "Failed to fetch meetings.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptPassword(meeting: Meeting) {
|
||||||
|
this.password = "";
|
||||||
|
this.selectedMeeting = meeting;
|
||||||
|
this.showPasswordDialog = true;
|
||||||
|
nextTick(() => {
|
||||||
|
const input = this.$refs.passwordInput as HTMLInputElement;
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPasswordDialog() {
|
||||||
|
this.password = "";
|
||||||
|
this.selectedMeeting = null;
|
||||||
|
this.showPasswordDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPassword() {
|
||||||
|
if (!this.selectedMeeting) {
|
||||||
|
// this should never happen
|
||||||
|
logConsoleAndDb(
|
||||||
|
"No meeting selected when prompting for password, which should never happen.",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create member data object
|
||||||
|
const memberData = {
|
||||||
|
name: this.firstName,
|
||||||
|
did: this.activeDid,
|
||||||
|
isRegistered: this.isRegistered,
|
||||||
|
};
|
||||||
|
const memberDataString = JSON.stringify(memberData);
|
||||||
|
const encryptedMemberData = await encryptMessage(
|
||||||
|
memberDataString,
|
||||||
|
this.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get headers for authentication
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
|
||||||
|
// Encrypt the member data
|
||||||
|
const postResult = await this.axios.post(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMember",
|
||||||
|
{
|
||||||
|
groupId: this.selectedMeeting.groupId,
|
||||||
|
content: encryptedMemberData,
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (postResult.data && postResult.data.success) {
|
||||||
|
// Navigate to members view with password and groupId
|
||||||
|
(this.$router as Router).push({
|
||||||
|
name: "onboard-meeting-members",
|
||||||
|
params: {
|
||||||
|
groupId: this.selectedMeeting.groupId.toString(),
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
password: this.password,
|
||||||
|
memberId: postResult.data.memberId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cancelPasswordDialog();
|
||||||
|
} else {
|
||||||
|
throw { response: postResult };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error joining meeting: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text:
|
||||||
|
serverMessageForUser(error) || "You failed to join the meeting.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveMeeting() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "modal",
|
||||||
|
type: "confirm",
|
||||||
|
title: "Leave Meeting",
|
||||||
|
text: "Are you sure you want to leave this meeting?",
|
||||||
|
onYes: async () => {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
await this.axios.delete(
|
||||||
|
this.apiServer + "/api/partner/groupOnboardMember",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.attendingMeeting = null;
|
||||||
|
await this.fetchMeetings();
|
||||||
|
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: "You left the meeting.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error leaving meeting: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text:
|
||||||
|
serverMessageForUser(error) ||
|
||||||
|
"You failed to leave the meeting.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
72
src/views/OnboardMeetingMembersView.vue
Normal file
72
src/views/OnboardMeetingMembersView.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<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 mb-8">
|
||||||
|
Meeting Members
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-if="errorMessage">
|
||||||
|
<div class="text-center text-red-600 py-8">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
For authorization, wait for your meeting organizer to approve you.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members List -->
|
||||||
|
<MembersList
|
||||||
|
v-else
|
||||||
|
:password="password"
|
||||||
|
:decrypt-failure-message="'That password failed. You may be in the wrong meeting. Go back and try again.'"
|
||||||
|
@error="handleError"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { RouteLocation } from "vue-router";
|
||||||
|
|
||||||
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
|
import TopMessage from "@/components/TopMessage.vue";
|
||||||
|
import MembersList from "@/components/MembersList.vue";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
MembersList,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class OnboardMeetingMembersView extends Vue {
|
||||||
|
errorMessage = "";
|
||||||
|
|
||||||
|
get groupId(): string {
|
||||||
|
return (this.$route as RouteLocation).params.groupId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
get password(): string {
|
||||||
|
return (this.$route as RouteLocation).query.password as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async created() {
|
||||||
|
if (!this.groupId) {
|
||||||
|
this.errorMessage = "The group info is missing. Go back and try again.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.password) {
|
||||||
|
this.errorMessage = "The password is missing. Go back and try again.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleError(message: string) {
|
||||||
|
this.errorMessage = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
676
src/views/OnboardMeetingSetupView.vue
Normal file
676
src/views/OnboardMeetingSetupView.vue
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
<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="!isLoading && currentMeeting != null && !isInEditOrCreateMode()"
|
||||||
|
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h2 class="text-2xl">Current Meeting</h2>
|
||||||
|
<button
|
||||||
|
@click="startEditing"
|
||||||
|
class="mb-4 text-blue-600 hover:text-blue-800 transition-colors duration-200 ml-2"
|
||||||
|
title="Edit Meeting"
|
||||||
|
>
|
||||||
|
<fa icon="pen" class="fa-fw" />
|
||||||
|
<span class="sr-only">{{
|
||||||
|
isInCreateMode() ? "Create Meeting" : "Edit Meeting"
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="confirmDelete"
|
||||||
|
class="text-red-600 hover:text-red-800 transition-colors duration-200"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': isDeleting }"
|
||||||
|
title="Delete Meeting"
|
||||||
|
>
|
||||||
|
<fa icon="trash-can" class="fa-fw" />
|
||||||
|
<span class="sr-only">{{
|
||||||
|
isDeleting ? "Deleting..." : "Delete Meeting"
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p><strong>Name:</strong> {{ currentMeeting.name }}</p>
|
||||||
|
<p>
|
||||||
|
<strong>Expires:</strong>
|
||||||
|
{{ formatExpirationTime(currentMeeting.expiresAt) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="currentMeeting.password" class="mt-4">
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Share the password with the people you want to onboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-red-600">
|
||||||
|
Your copy of the password is not saved. Edit the meeting, or delete it
|
||||||
|
and create a new meeting.
|
||||||
|
</div>
|
||||||
|
</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/Edit Meeting Form -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!isLoading &&
|
||||||
|
isInEditOrCreateMode() &&
|
||||||
|
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
|
||||||
|
"
|
||||||
|
class="mt-8"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl mb-4">
|
||||||
|
{{ isInCreateMode() ? "Create New Meeting" : "Edit Meeting" }}
|
||||||
|
</h2>
|
||||||
|
<!-- This is my first form. Not sure if I like it; will see if the browser benefits extend to the native app. -->
|
||||||
|
<form
|
||||||
|
@submit.prevent="isInCreateMode() ? createMeeting() : updateMeeting()"
|
||||||
|
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="newOrUpdatedMeeting.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="newOrUpdatedMeeting.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="newOrUpdatedMeeting.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="newOrUpdatedMeeting.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
|
||||||
|
? isInCreateMode()
|
||||||
|
? "Creating..."
|
||||||
|
: "Updating..."
|
||||||
|
: isInCreateMode()
|
||||||
|
? "Create Meeting"
|
||||||
|
: "Update Meeting"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isInEditOrCreateMode()"
|
||||||
|
type="button"
|
||||||
|
@click="cancelEditing"
|
||||||
|
class="w-full bg-slate-500 text-white px-4 py-2 rounded-md hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members Section -->
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && currentMeeting != null"
|
||||||
|
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-2xl">Meeting Members</h2>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="onboardMeetingMembersLink()"
|
||||||
|
class="inline-block text-blue-600"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Open shortcut page for members <fa icon="external-link" />
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<MembersList
|
||||||
|
:password="currentMeeting.password || ''"
|
||||||
|
:decrypt-failure-message="DECRYPT_FAILURE_MESSAGE"
|
||||||
|
:show-organizer-tools="true"
|
||||||
|
@error="handleMembersError"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
</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 MembersList from "@/components/MembersList.vue";
|
||||||
|
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
|
||||||
|
import {
|
||||||
|
errorStringForLog,
|
||||||
|
getHeaders,
|
||||||
|
serverMessageForUser,
|
||||||
|
} from "@/libs/endorserServer";
|
||||||
|
import { encryptMessage } from "@/libs/crypto";
|
||||||
|
|
||||||
|
interface ServerMeeting {
|
||||||
|
groupId: number; // from the server
|
||||||
|
name: string; // from the server
|
||||||
|
expiresAt: string; // from the server
|
||||||
|
userFullName?: string; // from the user's session
|
||||||
|
password?: string; // from the user's session
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeetingSetupInfo {
|
||||||
|
name: string;
|
||||||
|
expiresAt: string;
|
||||||
|
userFullName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
QuickNav,
|
||||||
|
TopMessage,
|
||||||
|
MembersList,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class OnboardMeetingView extends Vue {
|
||||||
|
$notify!: (
|
||||||
|
notification: { group: string; type: string; title: string; text: string },
|
||||||
|
timeout?: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
DECRYPT_FAILURE_MESSAGE =
|
||||||
|
"Unable to decrypt some member information. Check your password, or have them reset theirs if they don't show here.";
|
||||||
|
|
||||||
|
currentMeeting: ServerMeeting | null = null;
|
||||||
|
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
|
||||||
|
activeDid = "";
|
||||||
|
apiServer = "";
|
||||||
|
isDeleting = false;
|
||||||
|
isLoading = true;
|
||||||
|
isRegistered = false;
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
fullName = "";
|
||||||
|
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.fullName = settings.firstName || "";
|
||||||
|
this.isRegistered = !!settings.isRegistered;
|
||||||
|
|
||||||
|
await this.fetchCurrentMeeting();
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInCreateMode(): boolean {
|
||||||
|
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInEditOrCreateMode(): boolean {
|
||||||
|
return this.newOrUpdatedMeeting != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
blankMeeting(): MeetingSetupInfo {
|
||||||
|
return {
|
||||||
|
// no groupId yet
|
||||||
|
name: "",
|
||||||
|
expiresAt: this.getDefaultExpirationTime(),
|
||||||
|
userFullName: this.fullName,
|
||||||
|
password: (this.currentMeeting?.password as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCurrentMeeting() {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.get(
|
||||||
|
this.apiServer + "/api/partner/groupOnboard",
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.data?.data) {
|
||||||
|
this.currentMeeting = {
|
||||||
|
...response.data.data,
|
||||||
|
userFullName: this.fullName,
|
||||||
|
password: this.currentMeeting?.password || "",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// no meeting found
|
||||||
|
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// no meeting found
|
||||||
|
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMeeting() {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.newOrUpdatedMeeting) {
|
||||||
|
throw Error(
|
||||||
|
"There was no meeting data to create. We should never get here.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert local time to UTC for comparison and server submission
|
||||||
|
const localExpiresAt = new Date(this.newOrUpdatedMeeting.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;
|
||||||
|
}
|
||||||
|
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Invalid Name",
|
||||||
|
text: "Please enter your name.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.newOrUpdatedMeeting.password) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Invalid Password",
|
||||||
|
text: "Please enter a password.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create content with user's name and DID encrypted with password
|
||||||
|
const content = {
|
||||||
|
name: this.newOrUpdatedMeeting.userFullName,
|
||||||
|
did: this.activeDid,
|
||||||
|
isRegistered: this.isRegistered,
|
||||||
|
};
|
||||||
|
const encryptedContent = await encryptMessage(
|
||||||
|
JSON.stringify(content),
|
||||||
|
this.newOrUpdatedMeeting.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.post(
|
||||||
|
this.apiServer + "/api/partner/groupOnboard",
|
||||||
|
{
|
||||||
|
name: this.newOrUpdatedMeeting.name,
|
||||||
|
expiresAt: localExpiresAt.toISOString(),
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
this.currentMeeting = {
|
||||||
|
...this.newOrUpdatedMeeting,
|
||||||
|
groupId: response.data.success.groupId,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.newOrUpdatedMeeting = null;
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "success",
|
||||||
|
title: "Success",
|
||||||
|
text: "Meeting created.",
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw { response: response };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error creating meeting: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
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.currentMeeting = null;
|
||||||
|
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startEditing() {
|
||||||
|
// Populate form with existing meeting data
|
||||||
|
if (this.currentMeeting) {
|
||||||
|
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||||
|
this.newOrUpdatedMeeting = {
|
||||||
|
name: this.currentMeeting.name,
|
||||||
|
expiresAt: this.formatDateForInput(localExpiresAt),
|
||||||
|
userFullName: this.currentMeeting.userFullName || "",
|
||||||
|
password: this.currentMeeting.password || "",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"There is no current meeting to edit. We should never get here.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEditing() {
|
||||||
|
// Reset form data
|
||||||
|
this.newOrUpdatedMeeting = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMeeting() {
|
||||||
|
this.isLoading = true;
|
||||||
|
if (!this.newOrUpdatedMeeting) {
|
||||||
|
throw Error("There was no meeting data to update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert local time to UTC for comparison and server submission
|
||||||
|
const localExpiresAt = new Date(this.newOrUpdatedMeeting.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;
|
||||||
|
}
|
||||||
|
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Invalid Name",
|
||||||
|
text: "Please enter your name.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.newOrUpdatedMeeting.password) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Invalid Password",
|
||||||
|
text: "Please enter a password.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// create content with user's name and DID encrypted with password
|
||||||
|
const content = {
|
||||||
|
name: this.newOrUpdatedMeeting.userFullName,
|
||||||
|
did: this.activeDid,
|
||||||
|
isRegistered: this.isRegistered,
|
||||||
|
};
|
||||||
|
const encryptedContent = await encryptMessage(
|
||||||
|
JSON.stringify(content),
|
||||||
|
this.newOrUpdatedMeeting.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const response = await this.axios.put(
|
||||||
|
this.apiServer + "/api/partner/groupOnboard",
|
||||||
|
{
|
||||||
|
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
|
||||||
|
name: this.newOrUpdatedMeeting.name,
|
||||||
|
expiresAt: localExpiresAt.toISOString(),
|
||||||
|
content: encryptedContent,
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
// Update the current meeting with only the necessary fields
|
||||||
|
this.currentMeeting = {
|
||||||
|
...this.newOrUpdatedMeeting,
|
||||||
|
groupId: (this.currentMeeting?.groupId as number) || -1,
|
||||||
|
};
|
||||||
|
this.newOrUpdatedMeeting = null;
|
||||||
|
} else {
|
||||||
|
throw { response: response };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logConsoleAndDb(
|
||||||
|
"Error updating meeting: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const errorMessage = serverMessageForUser(error);
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text:
|
||||||
|
errorMessage ||
|
||||||
|
"Failed to update meeting. Try reloading or submitting again.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onboardMeetingMembersLink(): string {
|
||||||
|
if (this.currentMeeting) {
|
||||||
|
return `/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||||
|
this.currentMeeting?.password || "",
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMembersError(message: string) {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -49,22 +49,22 @@
|
|||||||
<div class="text-sm mb-3">
|
<div class="text-sm mb-3">
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||||
{{
|
{{ issuerInfoObject?.displayName }}
|
||||||
serverUtil.didInfo(issuer, activeDid, allMyDids, allContacts)
|
|
||||||
}}
|
|
||||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
||||||
<button
|
<a
|
||||||
@click="
|
:href="`/did/${issuer}`"
|
||||||
libsUtil.doCopyTwoSecRedo(
|
target="_blank"
|
||||||
issuer,
|
class="text-blue-500"
|
||||||
() => (showDidCopy = !showDidCopy),
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="ml-2 mr-2"
|
|
||||||
>
|
>
|
||||||
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
</button>
|
</a>
|
||||||
<span v-show="showDidCopy">Copied DID</span>
|
</span>
|
||||||
|
<span v-else-if="serverUtil.isHiddenDid(issuer)">
|
||||||
|
<fa
|
||||||
|
icon="info-circle"
|
||||||
|
class="fa-fw text-blue-500 cursor-pointer"
|
||||||
|
@click="openHiddenDidDialog()"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="startTime">
|
<div v-if="startTime">
|
||||||
@@ -76,14 +76,21 @@
|
|||||||
<a
|
<a
|
||||||
:href="getOpenStreetMapUrl()"
|
:href="getOpenStreetMapUrl()"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="underline"
|
class="underline text-blue-500"
|
||||||
>Map View
|
>Map View
|
||||||
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
<fa
|
||||||
|
icon="arrow-up-right-from-square"
|
||||||
|
class="fa-fw text-blue-500"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="url">
|
<div v-if="url">
|
||||||
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
||||||
<a :href="addScheme(url)" target="_blank" class="underline">
|
<a
|
||||||
|
:href="addScheme(url)"
|
||||||
|
target="_blank"
|
||||||
|
class="underline text-blue-500"
|
||||||
|
>
|
||||||
{{ domainForWebsite(this.url) }}
|
{{ domainForWebsite(this.url) }}
|
||||||
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||||
</a>
|
</a>
|
||||||
@@ -477,6 +484,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HiddenDidDialog ref="hiddenDidDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -508,11 +517,13 @@ import {
|
|||||||
} from "@/libs/endorserServer";
|
} from "@/libs/endorserServer";
|
||||||
import * as serverUtil from "@/libs/endorserServer";
|
import * as serverUtil from "@/libs/endorserServer";
|
||||||
import { retrieveAccountDids } from "@/libs/util";
|
import { retrieveAccountDids } from "@/libs/util";
|
||||||
|
import HiddenDidDialog from "@/components/HiddenDidDialog.vue";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
EntityIcon,
|
EntityIcon,
|
||||||
GiftedDialog,
|
GiftedDialog,
|
||||||
|
HiddenDidDialog,
|
||||||
OfferDialog,
|
OfferDialog,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
@@ -524,6 +535,7 @@ export default class ProjectViewView extends Vue {
|
|||||||
|
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
agentDid = "";
|
agentDid = "";
|
||||||
|
agentDidVisibleToDids: Array<string> = [];
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
@@ -540,6 +552,12 @@ export default class ProjectViewView extends Vue {
|
|||||||
imageUrl = "";
|
imageUrl = "";
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
issuer = "";
|
issuer = "";
|
||||||
|
issuerInfoObject: {
|
||||||
|
known: boolean;
|
||||||
|
displayName: string;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
} | null = null;
|
||||||
|
issuerVisibleToDids: Array<string> = [];
|
||||||
latitude = 0;
|
latitude = 0;
|
||||||
longitude = 0;
|
longitude = 0;
|
||||||
name = "";
|
name = "";
|
||||||
@@ -547,7 +565,6 @@ export default class ProjectViewView extends Vue {
|
|||||||
offersHitLimit = false;
|
offersHitLimit = false;
|
||||||
projectId = ""; // handle ID
|
projectId = ""; // handle ID
|
||||||
recentlyCheckedAndUnconfirmableJwts: string[] = [];
|
recentlyCheckedAndUnconfirmableJwts: string[] = [];
|
||||||
showDidCopy = false;
|
|
||||||
startTime = "";
|
startTime = "";
|
||||||
truncatedDesc = "";
|
truncatedDesc = "";
|
||||||
truncateLength = 40;
|
truncateLength = 40;
|
||||||
@@ -625,8 +642,17 @@ export default class ProjectViewView extends Vue {
|
|||||||
startDateTime.toLocaleTimeString();
|
startDateTime.toLocaleTimeString();
|
||||||
}
|
}
|
||||||
this.agentDid = resp.data.claim?.agent?.identifier;
|
this.agentDid = resp.data.claim?.agent?.identifier;
|
||||||
|
this.agentDidVisibleToDids =
|
||||||
|
resp.data.claim?.agent?.identifierVisibleToDids || [];
|
||||||
this.imageUrl = resp.data.claim?.image;
|
this.imageUrl = resp.data.claim?.image;
|
||||||
this.issuer = resp.data.issuer;
|
this.issuer = resp.data.issuer;
|
||||||
|
this.issuerInfoObject = serverUtil.didInfoObject(
|
||||||
|
this.issuer,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
|
||||||
this.name = resp.data.claim?.name || "(no name)";
|
this.name = resp.data.claim?.name || "(no name)";
|
||||||
this.description = resp.data.claim?.description || "(no description)";
|
this.description = resp.data.claim?.description || "(no description)";
|
||||||
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
||||||
@@ -1158,5 +1184,15 @@ export default class ProjectViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openHiddenDidDialog() {
|
||||||
|
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
|
||||||
|
"creator",
|
||||||
|
this.issuerVisibleToDids,
|
||||||
|
this.allContacts,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl m-2">Confirm</h2>
|
<h2 class="text-2xl m-2">Confirm</h2>
|
||||||
<div v-if="loadingConfirms" class="flex justify-center">
|
<div v-if="loadingConfirms" class="flex justify-center">
|
||||||
<fa icon="spinner" class="animate-spin" />
|
<fa icon="spinner" class="fa-spin-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="claimsToConfirm.length === 0">
|
<div v-else-if="claimsToConfirm.length === 0">
|
||||||
There are no claims yet today for you to confirm.
|
There are no claims yet today for you to confirm.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
5000,
|
5000,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Toast
|
Toast
|
||||||
</button>
|
</button>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
5000,
|
5000,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Info
|
Info
|
||||||
</button>
|
</button>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
5000,
|
5000,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Success
|
Success
|
||||||
</button>
|
</button>
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
5000,
|
5000,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Warning
|
Warning
|
||||||
</button>
|
</button>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
5000,
|
5000,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Danger
|
Danger
|
||||||
</button>
|
</button>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Notif ON
|
Notif ON
|
||||||
</button>
|
</button>
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Notif MUTE
|
Notif MUTE
|
||||||
</button>
|
</button>
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Notif OFF
|
Notif OFF
|
||||||
</button>
|
</button>
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
Register Passkey
|
Register Passkey
|
||||||
<button
|
<button
|
||||||
@click="register()"
|
@click="register()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Simplewebauthn
|
Simplewebauthn
|
||||||
</button>
|
</button>
|
||||||
@@ -194,13 +194,13 @@
|
|||||||
Create JWT
|
Create JWT
|
||||||
<button
|
<button
|
||||||
@click="createJwtSimplewebauthn()"
|
@click="createJwtSimplewebauthn()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Simplewebauthn
|
Simplewebauthn
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="createJwtNavigator()"
|
@click="createJwtNavigator()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Navigator
|
Navigator
|
||||||
</button>
|
</button>
|
||||||
@@ -210,19 +210,19 @@
|
|||||||
Verify New JWT
|
Verify New JWT
|
||||||
<button
|
<button
|
||||||
@click="verifySimplewebauthn()"
|
@click="verifySimplewebauthn()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Simplewebauthn
|
Simplewebauthn
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="verifyWebCrypto()"
|
@click="verifyWebCrypto()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
WebCrypto
|
WebCrypto
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="verifyP256()"
|
@click="verifyP256()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
p256 - broken
|
p256 - broken
|
||||||
</button>
|
</button>
|
||||||
@@ -230,11 +230,25 @@
|
|||||||
<div v-else>Verify New JWT -- requires creation first</div>
|
<div v-else>Verify New JWT -- requires creation first</div>
|
||||||
<button
|
<button
|
||||||
@click="verifyMyJwt()"
|
@click="verifyMyJwt()"
|
||||||
class="font-bold uppercase bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
Verify Hard-Coded JWT
|
Verify Hard-Coded JWT
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Encryption & Decryption</h2>
|
||||||
|
See console for more output.
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
@click="testEncryptionDecryption()"
|
||||||
|
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
Run Test
|
||||||
|
</button>
|
||||||
|
Result: {{ encryptionTestResult }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -248,6 +262,7 @@ import { Router } from "vue-router";
|
|||||||
import QuickNav from "@/components/QuickNav.vue";
|
import QuickNav from "@/components/QuickNav.vue";
|
||||||
import { AppString, NotificationIface } from "@/constants/app";
|
import { AppString, NotificationIface } from "@/constants/app";
|
||||||
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
|
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
|
||||||
|
import * as cryptoLib from "@/libs/crypto";
|
||||||
import * as vcLib from "@/libs/crypto/vc";
|
import * as vcLib from "@/libs/crypto/vc";
|
||||||
import {
|
import {
|
||||||
PeerSetup,
|
PeerSetup,
|
||||||
@@ -279,6 +294,9 @@ const TEST_PAYLOAD = {
|
|||||||
export default class Help extends Vue {
|
export default class Help extends Vue {
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
// for encryption/decryption
|
||||||
|
encryptionTestResult?: boolean;
|
||||||
|
|
||||||
// for file import
|
// for file import
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
|
|
||||||
@@ -289,6 +307,8 @@ export default class Help extends Vue {
|
|||||||
peerSetup?: PeerSetup;
|
peerSetup?: PeerSetup;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
|
||||||
|
cryptoLib = cryptoLib;
|
||||||
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const settings = await retrieveSettingsForActiveAccount();
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
this.activeDid = settings.activeDid || "";
|
this.activeDid = settings.activeDid || "";
|
||||||
@@ -363,6 +383,10 @@ export default class Help extends Vue {
|
|||||||
this.credIdHex = account.passkeyCredIdHex;
|
this.credIdHex = account.passkeyCredIdHex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async testEncryptionDecryption() {
|
||||||
|
this.encryptionTestResult = await cryptoLib.testEncryptionDecryption();
|
||||||
|
}
|
||||||
|
|
||||||
public async createJwtSimplewebauthn() {
|
public async createJwtSimplewebauthn() {
|
||||||
const account: AccountKeyInfo | undefined = await retrieveAccountMetadata(
|
const account: AccountKeyInfo | undefined = await retrieveAccountMetadata(
|
||||||
this.activeDid || "",
|
this.activeDid || "",
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ test('Check setting name & sharing info', async ({ page }) => {
|
|||||||
await expect(page.getByText('Set Your Name')).toBeVisible();
|
await expect(page.getByText('Set Your Name')).toBeVisible();
|
||||||
await page.getByRole('textbox').fill('Me Test User');
|
await page.getByRole('textbox').fill('Me Test User');
|
||||||
await page.locator('button:has-text("Save")').click();
|
await page.locator('button:has-text("Save")').click();
|
||||||
await expect(page.getByText('share another way')).toBeVisible();
|
await expect(page.getByText('share some other way')).toBeVisible();
|
||||||
await page.getByRole('button', { name: /share another way/ }).click();
|
await page.getByRole('button', { name: /share some other way/ }).click();
|
||||||
await expect(page.getByRole('button', { name: 'copy to clipboard' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'copy to clipboard' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'copy to clipboard' }).click();
|
await page.getByRole('button', { name: 'copy to clipboard' }).click();
|
||||||
await expect(page.getByText('contact info was copied')).toBeVisible();
|
await expect(page.getByText('contact info was copied')).toBeVisible();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { test, expect } from '@playwright/test';
|
|||||||
import { importUser, createUniqueStringsArray } from './testUtils';
|
import { importUser, createUniqueStringsArray } from './testUtils';
|
||||||
|
|
||||||
test('Create 10 new projects', async ({ page }) => {
|
test('Create 10 new projects', async ({ page }) => {
|
||||||
|
test.setTimeout(40000); // Set timeout longer since it often fails at 30 seconds
|
||||||
|
|
||||||
const projectCount = 10;
|
const projectCount = 10;
|
||||||
|
|
||||||
// Standard texts
|
// Standard texts
|
||||||
|
|||||||
Reference in New Issue
Block a user