Browse Source

add invite-one-accept screen dedicated to accepting invitations

split_build_process
Trent Larson 1 month ago
parent
commit
0a314934b8
  1. 2
      .env.development
  2. 7
      README.md
  3. 23
      src/App.vue
  4. 2
      src/libs/endorserServer.ts
  5. 9
      src/main.ts
  6. 5
      src/router/index.ts
  7. 45
      src/views/AccountViewView.vue
  8. 21
      src/views/ContactsView.vue
  9. 3
      src/views/HomeView.vue
  10. 138
      src/views/InviteOneAcceptView.vue
  11. 29
      src/views/InviteOneView.vue

2
.env.development

@ -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

7
README.md

@ -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
``` ```

23
src/App.vue

@ -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,
}; };

2
src/libs/endorserServer.ts

@ -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;

9
src/main.ts

@ -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.

5
src/router/index.ts

@ -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",

45
src/views/AccountViewView.vue

@ -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,9 +918,29 @@ 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();
} catch (error) {
// this can happen when running automated tests in dev mode because notifications don't work
console.error(
"Telling user to clear cache at page create because:",
error,
);
// this sometimes gives different information on the error
console.error(
"To repeat with concatenated error: telling user to clear cache at page create because: " +
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Profile",
text: "See the Help page about errors with your personal data.",
},
-1,
);
}
this.passkeyExpirationDescription = tokenExpiryTimeDescription(); try {
/** /**
* Beware! I've seen where this "ready" never resolves. * Beware! I've seen where this "ready" never resolves.
*/ */
@ -937,26 +957,17 @@ export default class AccountViewView extends Vue {
* Beware! I've seen where we never get to this point because "ready" never resolves. * 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
console.error(
"Telling user to clear cache at page create because:",
error,
);
// this sometimes gives different information on the error
console.error(
"To repeat with concatenated error: telling user to clear cache at page create because: " +
error,
);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "warning",
title: "Error Loading Profile", title: "Cannot Set Notifications",
text: "See the Help page about errors with your personal data.", text: "This browser does not support notifications. Try Chrome or Safari, or other suggestions on the 'Troubleshoot your notifications' page.",
}, },
-1, 3000,
); );
} }
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
} }
beforeUnmount() { beforeUnmount() {

21
src/views/ContactsView.vue

@ -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();
}, },
); );

3
src/views/HomeView.vue

@ -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>

138
src/views/InviteOneAcceptView.vue

@ -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>

29
src/views/InviteOneView.vue

@ -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.",

Loading…
Cancel
Save