add invite-one-accept screen dedicated to accepting invitations

This commit is contained in:
2024-12-13 13:27:22 -07:00
parent b657dc343a
commit 156950c7f0
11 changed files with 222 additions and 66 deletions

View File

@@ -1,3 +1,5 @@
# I tried and failed to set things here with vue-cli-service but # I tried and failed to set things here with vue-cli-service but
# things may be more reliable with vite so let's try again. # things may be more reliable with vite so let's try again.
VITE_APP_SERVER=http://localhost:8080

View File

@@ -58,9 +58,12 @@ npm run test-all
* Run the correct build: * Run the correct build:
* Staging * Staging
(Let's replace this with a .env.development or .env.staging file.)
The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
``` ```
# (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
``` ```

View File

@@ -309,28 +309,6 @@
</div> </div>
</div> </div>
</div> </div>
<div
v-else
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">
<p class="text-lg mb-4">
Something has gone very wrong. We'd appreciate if you'd
contact us and let us know how you got here. Thank you!
</p>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
Close
</button>
</div>
</div>
</div>
</div> </div>
</Notification> </Notification>
</div> </div>
@@ -398,6 +376,7 @@ export default class App extends Vue {
return true; return true;
} }
// clone in order to get only the properties and allow stringify to work
const serverSubscription = { const serverSubscription = {
...subscription, ...subscription,
}; };

View File

@@ -1070,6 +1070,7 @@ export async function generateEndorserJwtForAccount(
contactInfo.own.profileImageUrl = profileImageUrl; contactInfo.own.profileImageUrl = profileImageUrl;
} }
// Add the next key -- but note that it makes the QR more detailed
if (includeNextKeyIfDerived && account?.mnemonic && account?.derivationPath) { if (includeNextKeyIfDerived && account?.mnemonic && account?.derivationPath) {
const newDerivPath = nextDerivationPath(account.derivationPath as string); const newDerivPath = nextDerivationPath(account.derivationPath as string);
const nextPublicHex = deriveAddress( const nextPublicHex = deriveAddress(
@@ -1082,6 +1083,7 @@ export async function generateEndorserJwtForAccount(
Buffer.from(nextPublicEncKeyHash).toString("base64"); Buffer.from(nextPublicEncKeyHash).toString("base64");
contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64; contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
} }
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo); const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;

View File

@@ -172,11 +172,14 @@ function setupGlobalErrorHandler(app: VueApp) {
info: string, info: string,
) => { ) => {
console.error( console.error(
"Ouch! Global Error Handler. Info:", "Ouch! Global Error Handler.",
info,
"Error:", "Error:",
err, err,
"Instance:", "- Error toString:",
err.toString(),
"- Info:",
info,
"- Instance:",
instance, instance,
); );
// Want to show a nice notiwind notification but can't figure out how. // Want to show a nice notiwind notification but can't figure out how.

View File

@@ -144,6 +144,11 @@ const routes: Array<RouteRecordRaw> = [
name: "invite-one", name: "invite-one",
component: () => import("../views/InviteOneView.vue"), component: () => import("../views/InviteOneView.vue"),
}, },
{
path: "/invite-one-accept/:jwt?",
name: "InviteOneAcceptView",
component: () => import("@/views/InviteOneAcceptView.vue"),
},
{ {
path: "/new-activity", path: "/new-activity",
name: "new-activity", name: "new-activity",

View File

@@ -243,7 +243,7 @@
{{ notifyingNewActivityTime.replace(" ", "&nbsp;") }} {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div> </div>
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications"> <router-link class="pl-4 text-sm text-blue-500" to="/help-notifications">
Troubleshoot your notification setup. Troubleshoot your notifications.
</router-link> </router-link>
</div> </div>
<PushNotificationPermission ref="pushNotificationPermission" /> <PushNotificationPermission ref="pushNotificationPermission" />
@@ -918,24 +918,6 @@ export default class AccountViewView extends Vue {
// Initialize component state with values from the database or defaults // Initialize component state with values from the database or defaults
await this.initializeState(); await this.initializeState();
await this.processIdentity(); await this.processIdentity();
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
/**
* Beware! I've seen where this "ready" never resolves.
*/
const registration = await navigator.serviceWorker?.ready;
this.subscription = await registration.pushManager.getSubscription();
if (!this.subscription) {
if (this.notifyingNewActivity || this.notifyingReminder) {
// the app thought there was a subscription but there isn't, so fix the settings
this.turnOffNotifyingFlags();
}
}
// console.log("Got to the end of 'mounted' call in AccountViewView.");
/**
* Beware! I've seen where we never get to this point because "ready" never resolves.
*/
} catch (error) { } catch (error) {
// this can happen when running automated tests in dev mode because notifications don't work // this can happen when running automated tests in dev mode because notifications don't work
console.error( console.error(
@@ -957,6 +939,35 @@ export default class AccountViewView extends Vue {
-1, -1,
); );
} }
try {
/**
* Beware! I've seen where this "ready" never resolves.
*/
const registration = await navigator.serviceWorker?.ready;
this.subscription = await registration.pushManager.getSubscription();
if (!this.subscription) {
if (this.notifyingNewActivity || this.notifyingReminder) {
// the app thought there was a subscription but there isn't, so fix the settings
this.turnOffNotifyingFlags();
}
}
// console.log("Got to the end of 'mounted' call in AccountViewView.");
/**
* Beware! I've seen where we never get to this point because "ready" never resolves.
*/
} catch (error) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Cannot Set Notifications",
text: "This browser does not support notifications. Try Chrome or Safari, or other suggestions on the 'Troubleshoot your notifications' page.",
},
3000,
);
}
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
} }
beforeUnmount() { beforeUnmount() {

View File

@@ -440,7 +440,7 @@ export default class ContactsView extends Vue {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "warning", type: "danger",
title: "Blank Invite", title: "Blank Invite",
text: "The invite was not included, which can happen when your iOS device cuts off the link. Try pasting the full link into a browser.", text: "The invite was not included, which can happen when your iOS device cuts off the link. Try pasting the full link into a browser.",
}, },
@@ -474,29 +474,36 @@ export default class ContactsView extends Vue {
3000, 3000,
); );
// wait for a second before continuing so they see the registration message
await new Promise((resolve) => setTimeout(resolve, 1000));
// now add the inviter as a contact // now add the inviter as a contact
// (similar code is in InviteOneAcceptView.vue)
const payload: JWTPayload = const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload; decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential; const registration = payload as VerifiableCredential;
(this.$refs.contactNameDialog as ContactNameDialog).open( (this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Invited You?", "Who Invited You?",
"", "",
(name) => { async (name) => {
// not doing await on purpose, so that they always see the onboarding await this.addContact({
this.addContact({
did: registration.vc.credentialSubject.agent.identifier, did: registration.vc.credentialSubject.agent.identifier,
name: name, name: name,
registered: true, registered: true,
}); });
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo(); this.showOnboardingInfo();
}, },
() => { async () => {
// not doing await on purpose, so that they always see the onboarding // on cancel, will still add the contact
this.addContact({ await this.addContact({
did: registration.vc.credentialSubject.agent.identifier, did: registration.vc.credentialSubject.agent.identifier,
name: "(person who invited you)", name: "(person who invited you)",
registered: true, registered: true,
}); });
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo(); this.showOnboardingInfo();
}, },
); );

View File

@@ -73,7 +73,8 @@
<div class="mb-8"> <div class="mb-8">
<div v-if="isCreatingIdentifier"> <div v-if="isCreatingIdentifier">
<p class="text-slate-500 text-center italic mt-4 mb-4"> <p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip; <fa icon="spinner" class="fa-spin-pulse" />
Loading&hellip;
</p> </p>
</div> </div>

View File

@@ -0,0 +1,138 @@
<template>
<QuickNav selected="Invite" />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div v-if="acceptInput" class="text-center mt-4">
<p>That invitation did not work.</p>
<p class="mt-2">
Go back to your invite message and copy the entire text, then paste it
here.
</p>
<input
v-model="inputJwt"
type="text"
placeholder="Paste invitation..."
class="mt-4 border-2 border-gray-300 p-2 rounded"
/>
<button
@click="() => processInvite(inputJwt, true)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Submit
</button>
</div>
<div
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
Loading&hellip;
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { decodeEndorserJwt } from "@/libs/crypto/vc";
import { generateSaveAndActivateIdentity } from "@/libs/util";
@Component({ components: { QuickNav } })
export default class InviteOneAcceptView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
acceptInput: boolean = false;
activeDid: string = "";
apiServer: string = "";
checkingInvite: boolean = true;
inputJwt: string = "";
async mounted() {
this.checkingInvite = true;
await db.open();
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
}
const jwt = window.location.pathname.substring(
"/invite-one-accept/".length,
);
await this.processInvite(jwt, false);
this.checkingInvite = false;
}
// process the invite JWT and/or text message containing the URL with the JWT
async processInvite(jwtInput: string, notifyOnFailure: boolean) {
this.checkingInvite = true;
try {
let jwt: string = jwtInput ?? "";
// parse the string: extract the URL or JWT if surrounded by spaces
// and then extract the JWT from the URL
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?"
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch && internalMatch[1]) {
jwt = internalMatch[1];
}
} else {
// extract the JWT (which starts with "ey") if it is surrounded by other input
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch && spaceMatch[1]) {
jwt = spaceMatch[1];
}
}
if (!jwt) {
if (notifyOnFailure) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Missing invite",
text: "There was no invite. Paste the entire text that has the link.",
},
5000,
);
}
this.acceptInput = true;
} else {
//const payload: JWTPayload =
decodeEndorserJwt(jwt);
// That's good enough for an initial check.
// Send them to the contacts page to finish, with inviteJwt in the query string.
(this.$router as Router).push({
name: "contacts",
query: { inviteJwt: jwt },
});
}
} catch (error) {
console.error("Error accepting invite:", error);
if (notifyOnFailure) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing that invite.",
},
3000,
);
}
this.acceptInput = true;
}
this.checkingInvite = false;
}
}
</script>

View File

@@ -99,7 +99,7 @@
{{ invite.notes }} {{ invite.notes }}
</td> </td>
<td class="text-center"> <td class="text-center">
{{ invite.expiresAt.substring(0, 10) }} {{ invite.redeemedAt ? "" : invite.expiresAt.substring(0, 10) }}
</td> </td>
<td class="text-center"> <td class="text-center">
{{ invite.redeemedAt?.substring(0, 10) }} {{ invite.redeemedAt?.substring(0, 10) }}
@@ -137,8 +137,9 @@ import ContactNameDialog from "@/components/ContactNameDialog.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 InviteDialog from "@/components/InviteDialog.vue"; import InviteDialog from "@/components/InviteDialog.vue";
import { APP_SERVER, NotificationIface } from "@/constants/app"; import { APP_SERVER, AppString, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db"; import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { createInviteJwt, getHeaders } from "@/libs/endorserServer"; import { createInviteJwt, getHeaders } from "@/libs/endorserServer";
interface Invite { interface Invite {
@@ -159,7 +160,7 @@ export default class InviteOneView extends Vue {
invites: Invite[] = []; invites: Invite[] = [];
activeDid: string = ""; activeDid: string = "";
apiServer: string = ""; apiServer: string = "";
contactsRedeemed = {}; contactsRedeemed: { [key: string]: Contact } = {};
isRegistered: boolean = false; isRegistered: boolean = false;
showAppleWarning = false; showAppleWarning = false;
@@ -178,12 +179,12 @@ export default class InviteOneView extends Vue {
); );
this.invites = response.data.data; this.invites = response.data.data;
const baseContacts = await db.contacts.toArray(); const baseContacts: Contact[] = await db.contacts.toArray();
for (const invite of this.invites) { for (const invite of this.invites) {
const contact = baseContacts.find( const contact = baseContacts.find(
(contact) => contact.did === invite.redeemedBy, (contact) => contact.did === invite.redeemedBy,
); );
if (contact) { if (contact && invite.redeemedBy) {
this.contactsRedeemed[invite.redeemedBy] = contact; this.contactsRedeemed[invite.redeemedBy] = contact;
} }
} }
@@ -209,14 +210,16 @@ export default class InviteOneView extends Vue {
getTruncatedRedeemedBy(redeemedBy: string | null): string { getTruncatedRedeemedBy(redeemedBy: string | null): string {
if (!redeemedBy) return ""; if (!redeemedBy) return "";
if (this.contactsRedeemed[redeemedBy]) { if (this.contactsRedeemed[redeemedBy]) {
return this.contactsRedeemed[redeemedBy].name; return (
this.contactsRedeemed[redeemedBy].name || AppString.NO_CONTACT_NAME
);
} }
if (redeemedBy.length <= 19) return redeemedBy; if (redeemedBy.length <= 19) return redeemedBy;
return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`; return `${redeemedBy.slice(0, 13)}...${redeemedBy.slice(-3)}`;
} }
inviteLink(jwt: string): string { inviteLink(jwt: string): string {
return APP_SERVER + "/contacts?inviteJwt=" + jwt; return APP_SERVER + "/invite-one-accept/" + jwt;
} }
copyInviteAndNotify(inviteId: string, jwt: string) { copyInviteAndNotify(inviteId: string, jwt: string) {
@@ -251,7 +254,8 @@ export default class InviteOneView extends Vue {
); );
} }
lookForErrorAndNotify(error, title: string, defaultMessage: string) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
lookForErrorAndNotify(error: any, title: string, defaultMessage: string) {
console.error(title, "-", error); console.error(title, "-", error);
let message = defaultMessage; let message = defaultMessage;
if (error.response && error.response.data && error.response.data.error) { if (error.response && error.response.data && error.response.data.error) {
@@ -301,14 +305,15 @@ export default class InviteOneView extends Vue {
{ inviteJwt: inviteJwt, notes: notes }, { inviteJwt: inviteJwt, notes: notes },
{ headers }, { headers },
); );
this.invites.push({ const newInvite = {
inviteIdentifier: inviteIdentifier, inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt, expiresAt: expiresAt,
jwt: inviteJwt, jwt: inviteJwt,
notes: notes, notes: notes,
redeemedAt: null, redeemedAt: null,
redeemedBy: null, redeemedBy: null,
}); };
this.invites = [newInvite, ...this.invites];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
this.lookForErrorAndNotify( this.lookForErrorAndNotify(
@@ -321,7 +326,7 @@ export default class InviteOneView extends Vue {
); );
} }
addNewContact(did) { addNewContact(did: string) {
(this.$refs.contactNameDialog as ContactNameDialog).open( (this.$refs.contactNameDialog as ContactNameDialog).open(
"To Whom Did You Send The Invite?", "To Whom Did You Send The Invite?",
"Their name will be added to your contact list.", "Their name will be added to your contact list.",