Browse Source

add page for one-on-one invites (incomplete)

Trent Larson 2 months ago
parent
commit
cd9f097180
  1. 118
      src/components/InviteDialog.vue
  2. 2
      src/components/UserNameDialog.vue
  3. 10
      src/libs/crypto/vc/index.ts
  4. 35
      src/libs/endorserServer.ts
  5. 2
      src/main.ts
  6. 5
      src/router/index.ts
  7. 45
      src/views/ContactsView.vue
  8. 195
      src/views/InviteOneView.vue
  9. 10
      src/views/NewEditProjectView.vue

118
src/components/InviteDialog.vue

@ -0,0 +1,118 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1>
{{ message }}
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="text"
/>
<!-- Add date selection element -->
Expiration
<input
type="date"
class="block rounded border border-slate-400 mb-4 px-3 py-2"
v-model="expiresAt"
/>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
@click="onClickSaveChanges()"
>
Save
</button>
<!-- SHOW ME instead while processing saving changes -->
<button
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
@Component
export default class InviteDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (text: string, expiresAt: string) => void = () => {};
message = "";
text = "";
title = "";
visible = false;
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 3)
.toISOString()
.substring(0, 10);
async open(
title: string,
message: string,
aCallback: (text: string, expiresAt: string) => void,
) {
this.callback = aCallback;
this.title = title;
this.message = message;
this.visible = true;
}
async onClickSaveChanges() {
if (!this.expiresAt) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Needs Expiration",
text: "You must select an expiration date.",
},
5000,
);
} else {
this.callback(this.text, this.expiresAt);
this.visible = false;
}
}
onClickCancel() {
this.visible = false;
}
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

2
src/components/UserNameDialog.vue

@ -46,7 +46,7 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
export default class UserNameDialog extends Vue { export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (string?) => void = () => {}; callback: (name?: string) => void = () => {};
givenName = ""; givenName = "";
visible = false; visible = false;

10
src/libs/crypto/vc/index.ts

@ -54,16 +54,22 @@ export function isFromPasskey(keyMeta?: KeyMeta): boolean {
export async function createEndorserJwtForKey( export async function createEndorserJwtForKey(
account: KeyMeta, account: KeyMeta,
payload: object, payload: object,
expiresIn?: number,
) { ) {
if (account?.identity) { if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!); const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex; const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string); const signer = await SimpleSigner(privateKeyHex as string);
return didJwt.createJWT(payload, { const options = {
issuer: account.did, issuer: account.did,
signer: signer, signer: signer,
}); expiresIn: undefined as number | undefined,
}
if (expiresIn) {
options.expiresIn = expiresIn;
}
return didJwt.createJWT(payload, options);
} else if (account?.passkeyCredIdHex) { } else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload); return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else { } else {

35
src/libs/endorserServer.ts

@ -239,8 +239,9 @@ export interface RegisterVerifiableCredential {
"@context": string; "@context": string;
"@type": string; "@type": string;
agent: { identifier: string }; agent: { identifier: string };
identifier?: string;
object: string; object: string;
participant: { identifier: string }; participant?: { identifier: string };
} }
// now for some of the error & other wrapper types // now for some of the error & other wrapper types
@ -993,9 +994,10 @@ export async function generateEndorserJwtForAccount(
export async function createEndorserJwtForDid( export async function createEndorserJwtForDid(
issuerDid: string, issuerDid: string,
payload: object, payload: object,
expiresIn?: number,
) { ) {
const account = await getAccount(issuerDid); const account = await getAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload); return createEndorserJwtForKey(account as KeyMeta, payload, expiresIn);
} }
/** /**
@ -1225,19 +1227,24 @@ export async function createEndorserJwtVcFromClaim(
return createEndorserJwtForDid(issuerDid, vcPayload); return createEndorserJwtForDid(issuerDid, vcPayload);
} }
export async function register( export async function createInviteJwt(
activeDid: string, activeDid: string,
apiServer: string, contact?: Contact,
axios: Axios, inviteId?: string,
contact: Contact, expiresIn?: number,
) { ): Promise<string> {
const vcClaim: RegisterVerifiableCredential = { const vcClaim: RegisterVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT, "@context": SCHEMA_ORG_CONTEXT,
"@type": "RegisterAction", "@type": "RegisterAction",
agent: { identifier: activeDid }, agent: { identifier: activeDid },
object: SERVICE_ID, object: SERVICE_ID,
participant: { identifier: contact.did },
}; };
if (contact) {
vcClaim.participant = { identifier: contact.did };
}
if (inviteId) {
vcClaim.identifier = inviteId;
}
// Make a payload for the claim // Make a payload for the claim
const vcPayload = { const vcPayload = {
vc: { vc: {
@ -1247,7 +1254,17 @@ export async function register(
}, },
}; };
// Create a signature using private key of identity // Create a signature using private key of identity
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload); const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn);
return vcJwt;
}
export async function register(
activeDid: string,
apiServer: string,
axios: Axios,
contact: Contact,
): Promise<{ success?: boolean; error?: string }> {
const vcJwt = await createInviteJwt(activeDid, contact);
const url = apiServer + "/api/v2/claim"; const url = apiServer + "/api/v2/claim";
const resp = await axios.post(url, { jwtEncoded: vcJwt }); const resp = await axios.post(url, { jwtEncoded: vcJwt });

2
src/main.ts

@ -39,6 +39,7 @@ import {
faDollar, faDollar,
faEllipsis, faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEnvelopeOpenText,
faEye, faEye,
faEyeSlash, faEyeSlash,
faFileLines, faFileLines,
@ -109,6 +110,7 @@ library.add(
faDollar, faDollar,
faEllipsis, faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEnvelopeOpenText,
faEye, faEye,
faEyeSlash, faEyeSlash,
faFileLines, faFileLines,

5
src/router/index.ts

@ -128,6 +128,11 @@ const routes: Array<RouteRecordRaw> = [
name: "import-derive", name: "import-derive",
component: () => import("../views/ImportDerivedAccountView.vue"), component: () => import("../views/ImportDerivedAccountView.vue"),
}, },
{
path: "/invite-one",
name: "invite-one",
component: () => import("../views/InviteOneView.vue"),
},
{ {
path: "/new-edit-account", path: "/new-edit-account",
name: "new-edit-account", name: "new-edit-account",

45
src/views/ContactsView.vue

@ -23,12 +23,20 @@
<!-- 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
: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"
>
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<router-link <router-link
:to="{ name: 'contact-qr' }" :to="{ name: 'contact-qr' }"
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" 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="qrcode" class="fa-fw text-2xl" /> <fa icon="qrcode" class="fa-fw text-2xl" />
</router-link> </router-link>
<textarea <textarea
type="text" type="text"
placeholder="URL or DID, Name, Public Key, Next Public Key Hash" placeholder="URL or DID, Name, Public Key, Next Public Key Hash"
@ -79,7 +87,9 @@
class="text-md 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 py-1 rounded-md" class="text-md 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 py-1 rounded-md"
@click="toggleShowContactAmounts()" @click="toggleShowContactAmounts()"
> >
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }} {{
showGiveNumbers ? "Hide Given Hours etc" : "Show Given Hours etc"
}}
</button> </button>
</div> </div>
</div> </div>
@ -288,7 +298,7 @@ import { Buffer } from "buffer/";
import { IndexableType } from "dexie"; import { IndexableType } from "dexie";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
@ -377,7 +387,7 @@ export default class ContactsView extends Vue {
(a.name || "").localeCompare(b.name || ""), (a.name || "").localeCompare(b.name || ""),
); );
const importedContactJwt = (this.$route as Router).query[ const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded).query[
"contactJwt" "contactJwt"
] as string; ] as string;
if (importedContactJwt) { if (importedContactJwt) {
@ -736,7 +746,7 @@ export default class ContactsView extends Vue {
type: "confirm", type: "confirm",
title: "Register", title: "Register",
text: "Do you want to register them?", text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => { onCancel: async (stopAsking?: boolean) => {
if (stopAsking) { if (stopAsking) {
await updateDefaultSettings({ await updateDefaultSettings({
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
@ -744,7 +754,7 @@ export default class ContactsView extends Vue {
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
} }
}, },
onNo: async (stopAsking: boolean) => { onNo: async (stopAsking?: boolean) => {
if (stopAsking) { if (stopAsking) {
await updateDefaultSettings({ await updateDefaultSettings({
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
@ -853,9 +863,14 @@ export default class ContactsView extends Vue {
console.error("Error when registering:", error); console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info."; let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError; const serverError = error as AxiosError;
if (serverError) { if (serverError.isAxiosError) {
if (serverError.response?.data?.error?.message) { if (serverError.response?.data
userMessage = serverError.response.data.error.message; && typeof serverError.response.data === 'object'
&& 'error' in serverError.response.data
&& typeof serverError.response.data.error === 'object'
&& serverError.response.data.error !== null
&& 'message' in serverError.response.data.error){
userMessage = serverError.response.data.error.message as string;
} else if (serverError.message) { } else if (serverError.message) {
userMessage = serverError.message; // Info for the user userMessage = serverError.message; // Info for the user
} else { } else {
@ -971,8 +986,8 @@ export default class ContactsView extends Vue {
} }
private showGiftedDialog(giverDid: string, recipientDid: string) { private showGiftedDialog(giverDid: string, recipientDid: string) {
let giver: libsUtil.GiverReceiverInputInfo; let giver: libsUtil.GiverReceiverInputInfo | undefined;
let receiver: libsUtil.GiverReceiverInputInfo; let receiver: libsUtil.GiverReceiverInputInfo | undefined;
if (giverDid) { if (giverDid) {
giver = { giver = {
did: giverDid, did: giverDid,
@ -995,7 +1010,7 @@ export default class ContactsView extends Vue {
newList[recipientDid] = (newList[recipientDid] || 0) + amount; newList[recipientDid] = (newList[recipientDid] || 0) + amount;
this.givenByMeUnconfirmed = newList; this.givenByMeUnconfirmed = newList;
}; };
customTitle = "Given to " + receiver.name; customTitle = "Given to " + (receiver?.name || "Someone Unnamed");
} else { } else {
// must be (recipientDid == this.activeDid) // must be (recipientDid == this.activeDid)
callback = (amount: number) => { callback = (amount: number) => {
@ -1003,14 +1018,14 @@ export default class ContactsView extends Vue {
newList[giverDid] = (newList[giverDid] || 0) + amount; newList[giverDid] = (newList[giverDid] || 0) + amount;
this.givenToMeUnconfirmed = newList; this.givenToMeUnconfirmed = newList;
}; };
customTitle = "Received from " + giver.name; customTitle = "Received from " + (giver?.name || "Someone Unnamed");
} }
(this.$refs.customGivenDialog as GiftedDialog).open( (this.$refs.customGivenDialog as GiftedDialog).open(
giver, giver,
receiver, receiver,
undefined as string, undefined as unknown as string,
customTitle, customTitle,
undefined as string, undefined as unknown as string,
callback, callback,
); );
} }

195
src/views/InviteOneView.vue

@ -0,0 +1,195 @@
<template>
<QuickNav selected="Invite" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light">Invitations</h1>
<!-- New Project -->
<button
v-if="isRegistered"
class="fixed right-6 top-12 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
@click="createInvite()"
>
<fa icon="plus" class="fa-fw"></fa>
</button>
<InviteDialog ref="inviteDialog" />
<!-- Invites Table -->
<div v-if="invites.length" class="mt-6">
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="py-2">ID</th>
<th class="py-2">Notes</th>
<th class="py-2">Expires At</th>
<th class="py-2">Redeemed By</th>
</tr>
</thead>
<tbody>
<tr
v-for="invite in invites"
:key="invite.inviteIdentifier"
class="border-t"
>
<td class="py-2 text-center">
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</td>
<td class="py-2 text-left">{{ invite.notes }}</td>
<td class="py-2 text-center">
{{ invite.expiresAt.substring(0, 10) }}
</td>
<td class="py-2 text-center">
{{ getTruncatedRedeemedBy(invite.redeemedBy) }}
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="mt-6 text-center">No invites found.</p>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import axios from "axios";
import { db, retrieveSettingsForActiveAccount } from "../db";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import InviteDialog from "@/components/InviteDialog.vue";
import { NotificationIface } from "@/constants/app";
import { createInviteJwt, getHeaders } from "@/libs/endorserServer";
interface Invite {
inviteIdentifier: string;
expiresAt: string;
notes: string;
redeemedBy: string | null;
}
@Component({
components: { QuickNav, TopMessage, InviteDialog },
})
export default class InviteOneView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
invites: Invite[] = [];
activeDid: string = "";
apiServer: string = "";
isRegistered: boolean = false;
async mounted() {
try {
await db.open();
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
const headers = await getHeaders(this.activeDid);
const response = await axios.get(
this.apiServer + "/api/userUtil/invite",
{ headers },
);
this.invites = response.data.data;
} catch (error) {
console.error("Error fetching invites:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Load Error",
text: "Got an error loading your invites.",
},
5000,
);
}
}
getTruncatedInviteId(inviteId: string): string {
if (inviteId.length <= 9) return inviteId;
return `${inviteId.slice(0, 3)}...${inviteId.slice(-3)}`;
}
getTruncatedRedeemedBy(redeemedBy: string | null): string {
if (!redeemedBy) return "Not yet redeemed";
if (redeemedBy.length <= 19) return redeemedBy;
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`;
}
async createInvite() {
(this.$refs.inviteDialog as InviteDialog).open(
"Invitation Note",
`These notes are only for your use, to make comments for a link to recall later if redeemed by someone.
Note that this is sent to the server.`,
async (notes, expiresAt) => {
try {
const inviteIdentifier =
Math.random().toString(36).substring(2) +
Math.random().toString(36).substring(2) +
Math.random().toString(36).substring(2);
const headers = await getHeaders(this.activeDid);
if (!expiresAt) {
throw {
response: {
data: { error: "You must select an expiration date." },
},
};
}
const expiresIn =
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000;
const inviteJwt = await createInviteJwt(
this.activeDid,
undefined,
inviteIdentifier,
expiresIn,
);
await axios.post(
this.apiServer + "/api/userUtil/invite",
{ inviteJwt: inviteJwt, notes: notes },
{ headers },
);
this.invites.push({
inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt,
notes: notes,
redeemedBy: null,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error creating invite:", error);
let message = "Got an error creating your invite.";
if (
error.response &&
error.response.data &&
error.response.data.error
) {
message = error.response.data.error;
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Creating Invite",
text: message,
},
5000,
);
}
},
);
}
}
</script>

10
src/views/NewEditProjectView.vue

@ -196,7 +196,7 @@ import { accountFromSeedWords } 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";
import { Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import ImageMethodDialog from "@/components/ImageMethodDialog.vue"; import ImageMethodDialog from "@/components/ImageMethodDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
@ -218,7 +218,7 @@ import { getAccount } from "@/libs/util";
}) })
export default class NewEditProjectView extends Vue { export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) { errNote(message: string) {
this.$notify( this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message }, { group: "alert", type: "danger", title: "Error", text: message },
5000, 5000,
@ -262,7 +262,7 @@ export default class NewEditProjectView extends Vue {
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced; this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route as Router).query["projectId"] || ""; this.projectId = (this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
@ -447,7 +447,7 @@ export default class NewEditProjectView extends Vue {
const projectPath = encodeURIComponent(resp.data.success.handleId); const projectPath = encodeURIComponent(resp.data.success.handleId);
let signedPayload: VerifiedEvent; // sign something to prove ownership of pubkey let signedPayload: VerifiedEvent | undefined; // sign something to prove ownership of pubkey
if (this.sendToTrustroots) { if (this.sendToTrustroots) {
signedPayload = await this.signPayload(); signedPayload = await this.signPayload();
this.sendToNostrPartner( this.sendToNostrPartner(
@ -623,7 +623,7 @@ export default class NewEditProjectView extends Vue {
5000, 5000,
); );
} }
} catch (error) { } catch (error: any) {
console.error(`Error sending to ${serviceName}`, error); console.error(`Error sending to ${serviceName}`, error);
let errorMessage = `There was an error sending to ${serviceName}.`; let errorMessage = `There was an error sending to ${serviceName}.`;
if (error.response?.data?.error?.message) { if (error.response?.data?.error?.message) {

Loading…
Cancel
Save