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
# 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:
* 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
```

23
src/App.vue

@ -309,28 +309,6 @@
</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>
</Notification>
</div>
@ -398,6 +376,7 @@ export default class App extends Vue {
return true;
}
// clone in order to get only the properties and allow stringify to work
const serverSubscription = {
...subscription,
};

2
src/libs/endorserServer.ts

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

9
src/main.ts

@ -172,11 +172,14 @@ function setupGlobalErrorHandler(app: VueApp) {
info: string,
) => {
console.error(
"Ouch! Global Error Handler. Info:",
info,
"Ouch! Global Error Handler.",
"Error:",
err,
"Instance:",
"- Error toString:",
err.toString(),
"- Info:",
info,
"- Instance:",
instance,
);
// 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",
component: () => import("../views/InviteOneView.vue"),
},
{
path: "/invite-one-accept/:jwt?",
name: "InviteOneAcceptView",
component: () => import("@/views/InviteOneAcceptView.vue"),
},
{
path: "/new-activity",
name: "new-activity",

45
src/views/AccountViewView.vue

@ -243,7 +243,7 @@
{{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div>
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications">
Troubleshoot your notification setup.
Troubleshoot your notifications.
</router-link>
</div>
<PushNotificationPermission ref="pushNotificationPermission" />
@ -918,9 +918,29 @@ export default class AccountViewView extends Vue {
// Initialize component state with values from the database or defaults
await this.initializeState();
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.
*/
@ -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.
*/
} 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.",
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.",
},
-1,
3000,
);
}
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
}
beforeUnmount() {

21
src/views/ContactsView.vue

@ -440,7 +440,7 @@ export default class ContactsView extends Vue {
this.$notify(
{
group: "alert",
type: "warning",
type: "danger",
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.",
},
@ -474,29 +474,36 @@ export default class ContactsView extends Vue {
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
// (similar code is in InviteOneAcceptView.vue)
const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential;
(this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Invited You?",
"",
(name) => {
// not doing await on purpose, so that they always see the onboarding
this.addContact({
async (name) => {
await this.addContact({
did: registration.vc.credentialSubject.agent.identifier,
name: name,
registered: true,
});
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo();
},
() => {
// not doing await on purpose, so that they always see the onboarding
this.addContact({
async () => {
// on cancel, will still add the contact
await this.addContact({
did: registration.vc.credentialSubject.agent.identifier,
name: "(person who invited you)",
registered: true,
});
// wait for a second before continuing so they see the user-added message
await new Promise((resolve) => setTimeout(resolve, 1000));
this.showOnboardingInfo();
},
);

3
src/views/HomeView.vue

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

Loading…
Cancel
Save