forked from trent_larson/crowd-funder-for-time-pwa
add invite-one-accept screen dedicated to accepting invitations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
{{ notifyingNewActivityTime.replace(" ", " ") }}
|
||||
</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,24 +918,6 @@ export default class AccountViewView extends Vue {
|
||||
// Initialize component state with values from the database or defaults
|
||||
await this.initializeState();
|
||||
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) {
|
||||
// this can happen when running automated tests in dev mode because notifications don't work
|
||||
console.error(
|
||||
@@ -957,6 +939,35 @@ export default class AccountViewView extends Vue {
|
||||
-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() {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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…
|
||||
<fa icon="spinner" class="fa-spin-pulse" />
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
138
src/views/InviteOneAcceptView.vue
Normal file
138
src/views/InviteOneAcceptView.vue
Normal 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…
|
||||
</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>
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user