Browse Source

finish the loading of an invite RegisterAction when clicking on a link

master
Trent Larson 2 months ago
parent
commit
3b5983fa7c
  1. 1
      .env.production
  2. 2
      CHANGELOG.md
  3. 2
      README.md
  4. 83
      src/components/ContactNameDialog.vue
  5. 6
      src/components/UserNameDialog.vue
  6. 3
      src/constants/app.ts
  7. 2
      src/libs/crypto/vc/index.ts
  8. 30
      src/libs/endorserServer.ts
  9. 2
      src/libs/util.ts
  10. 5
      src/views/AccountViewView.vue
  11. 4
      src/views/ContactQRScanShowView.vue
  12. 110
      src/views/ContactsView.vue
  13. 37
      src/views/InviteOneView.vue
  14. 4
      src/views/NewEditProjectView.vue

1
.env.production

@ -1,4 +1,5 @@
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue. # Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
VITE_APP_SERVER=https://timesafari.app
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

2
CHANGELOG.md

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.3.29-beta] ## [0.3.29-beta]
### Added
- Invite for a contact to join immediately
### Changed ### Changed
- Send signed data to nostr endpoints to verify public key ownership. - Send signed data to nostr endpoints to verify public key ownership.
### Changed in DB or environment ### Changed in DB or environment

2
README.md

@ -59,7 +59,7 @@ npm run test-all
``` ```
# (Let's replace this with a .env.development or .env.staging file.) # (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. # 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_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
``` ```
* Production * Production

83
src/components/ContactNameDialog.vue

@ -0,0 +1,83 @@
<!-- similar to ContactNameDialog -->
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Inviter's Name</h1>
Note that their name is only stored on this device.
<input
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="newText"
/>
<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>
<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";
@Component
export default class UserNameDialog extends Vue {
callback: (name?: string) => void = () => {};
newText = "";
visible = false;
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
this.visible = true;
}
async onClickSaveChanges() {
this.visible = false;
this.callback(this.newText);
}
onClickCancel() {
this.visible = false;
this.callback();
}
}
</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>

6
src/components/UserNameDialog.vue

@ -1,10 +1,11 @@
<!-- similar to ContactNameDialog -->
<template> <template>
<div v-if="visible" class="dialog-overlay"> <div v-if="visible" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1> <h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1>
Note that this is not sent to servers. It is only shared with people when This is not sent to servers. It is only shared with people when you send
you choose to send it to them. it to them.
<input <input
type="text" type="text"
placeholder="Name" placeholder="Name"
@ -21,7 +22,6 @@
> >
Save Save
</button> </button>
<!-- SHOW ME instead while processing saving changes -->
<button <button
type="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" 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"

3
src/constants/app.ts

@ -27,6 +27,9 @@ export enum AppString {
NO_CONTACT_NAME = "(no name)", NO_CONTACT_NAME = "(no name)",
} }
export const APP_SERVER =
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
export const DEFAULT_ENDORSER_API_SERVER = export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER || import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER; AppString.TEST_ENDORSER_API_SERVER;

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

@ -65,7 +65,7 @@ export async function createEndorserJwtForKey(
issuer: account.did, issuer: account.did,
signer: signer, signer: signer,
expiresIn: undefined as number | undefined, expiresIn: undefined as number | undefined,
} };
if (expiresIn) { if (expiresIn) {
options.expiresIn = expiresIn; options.expiresIn = expiresIn;
} }

30
src/libs/endorserServer.ts

@ -49,6 +49,7 @@ export interface ClaimResult {
error: { code: string; message: string }; error: { code: string; message: string };
} }
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded, eg. in an Agree
"@type": string; "@type": string;
@ -56,8 +57,6 @@ export interface GenericVerifiableCredential {
} }
export interface GenericCredWrapper<T extends GenericVerifiableCredential> { export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
"@context": string;
"@type": string;
claim: T; claim: T;
claimType?: string; claimType?: string;
handleId: string; handleId: string;
@ -68,8 +67,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
} }
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> = export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{ {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
claim: { "@type": "" }, claim: { "@type": "" },
handleId: "", handleId: "",
id: "", id: "",
@ -223,11 +220,21 @@ export interface ImageRateLimits {
} }
export interface VerifiableCredential { export interface VerifiableCredential {
exp?: number;
iat: number;
iss: string;
vc: {
"@context": string[];
type: string[];
credentialSubject: VerifiableCredentialSubject;
};
}
// similar to GenericVerifiableCredential... maybe replace that one
export interface VerifiableCredentialSubject {
"@context": string; "@context": string;
"@type": string; "@type": string;
name: string; [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
description: string;
identifier?: string;
} }
export interface WorldProperties { export interface WorldProperties {
@ -235,13 +242,14 @@ export interface WorldProperties {
endTime?: string; endTime?: string;
} }
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential { export interface RegisterVerifiableCredential {
"@context": string; "@context": typeof SCHEMA_ORG_CONTEXT;
"@type": string; "@type": "RegisterAction";
agent: { identifier: string }; agent: { identifier: string };
identifier?: string; identifier?: string; // used for invites (when participant ID isn't known)
object: string; object: string;
participant?: { identifier: string }; participant?: { identifier: string }; // used when person is known (not an invite)
} }
// now for some of the error & other wrapper types // now for some of the error & other wrapper types

2
src/libs/util.ts

@ -314,7 +314,7 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
}); });
await updateDefaultSettings({ activeDid: newId.did }); await updateDefaultSettings({ activeDid: newId.did });
console.log("Updated default settings in util"); //console.log("Updated default settings in util");
await updateAccountSettings(newId.did, { isRegistered: false }); await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did; return newId.did;

5
src/views/AccountViewView.vue

@ -254,10 +254,7 @@
<b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b> <b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
month. month.
<i <i>(You cannot register anyone else on your first day.)</i>
>(You can register nobody on your first day, and after that only one
a day in your first month.)</i
>
Your registration counter resets at Your registration counter resets at
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
{{ readableDate(endorserLimits.nextMonthBeginDateTime) }} {{ readableDate(endorserLimits.nextMonthBeginDateTime) }}

4
src/views/ContactQRScanShowView.vue

@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()" @click="$router.back()"
> >
<fa icon="chevron-left" class="fa-fw"></fa> <fa icon="chevron-left" class="fa-fw" />
</h1> </h1>
</div> </div>
@ -34,8 +34,8 @@
click here to set it for them. click here to set it for them.
</span> </span>
</p> </p>
<UserNameDialog ref="userNameDialog" />
</div> </div>
<UserNameDialog ref="userNameDialog" />
<div <div
@click="onCopyUrlToClipboard()" @click="onCopyUrlToClipboard()"

110
src/views/ContactsView.vue

@ -168,7 +168,7 @@
}" }"
title="See more about this person" title="See more about this person"
> >
<fa icon="circle-info" class="text-blue-500 ml-4" /> <fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</router-link> </router-link>
<span class="ml-4 text-sm overflow-hidden" <span class="ml-4 text-sm overflow-hidden"
@ -276,6 +276,7 @@
<GiftedDialog ref="customGivenDialog" /> <GiftedDialog ref="customGivenDialog" />
<OfferDialog ref="customOfferDialog" /> <OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
<div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full"> <div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full">
<div <div
@ -296,6 +297,7 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/"; import { Buffer } from "buffer/";
import { IndexableType } from "dexie"; import { IndexableType } from "dexie";
import { JWTPayload } from "did-jwt";
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 { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@ -304,7 +306,7 @@ import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
import { import {
db, db,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount, updateAccountSettings,
updateDefaultSettings, updateDefaultSettings,
} from "@/db/index"; } from "@/db/index";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
@ -319,16 +321,26 @@ import {
register, register,
setVisibilityUtil, setVisibilityUtil,
UserInfo, UserInfo,
VerifiableCredential,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue";
import ContactNameDialog from "@/components/ContactNameDialog.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import {generateSaveAndActivateIdentity} from "@/libs/util";
@Component({ @Component({
components: { TopMessage, GiftedDialog, EntityIcon, OfferDialog, QuickNav }, components: {
GiftedDialog,
EntityIcon,
OfferDialog,
QuickNav,
ContactNameDialog,
TopMessage,
},
}) })
export default class ContactsView extends Vue { export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -387,11 +399,11 @@ export default class ContactsView extends Vue {
(a.name || "").localeCompare(b.name || ""), (a.name || "").localeCompare(b.name || ""),
); );
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded).query[ // handle a contact sent via URL
"contactJwt" const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded)
] as string; .query["contactJwt"] as string;
if (importedContactJwt) { if (importedContactJwt) {
// really should fully verify // really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt); const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo; const userInfo = payload["own"] as UserInfo;
const newContact = { const newContact = {
@ -404,6 +416,76 @@ export default class ContactsView extends Vue {
} as Contact; } as Contact;
this.addContact(newContact); this.addContact(newContact);
} }
// handle an invite JWT sent via URL
const importedInviteJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["inviteJwt"] as string;
if (importedInviteJwt) {
// make sure user is created
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
}
// send invite directly to server, with auth for this user
const headers = await getHeaders(this.activeDid);
try {
const response = await this.axios.post(
this.apiServer + "/api/v2/claim",
{ jwtEncoded: importedInviteJwt },
{ headers },
);
if (response.status != 201) {
throw { error: { response: response } };
}
await updateAccountSettings(this.activeDid, { isRegistered: true });
this.isRegistered = true;
this.$notify(
{
group: "alert",
type: "success",
title: "Registered",
text: "You are now registered.",
},
3000,
);
// now add the inviter as a contact
const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential;
(this.$refs.contactNameDialog as ContactNameDialog).open((name) =>
this.addContact({
did: registration.vc.credentialSubject.agent.identifier,
name: name, // may be undefined if they cancel
registered: true,
}),
);
} catch (error) {
console.error("Error redeeming invite:", error);
let message = "Got an error sending the invite.";
if (
error.response &&
error.response.data &&
error.response.data.error
) {
if (error.response.data.error.message) {
message = error.response.data.error.message;
} else {
message = error.response.data.error;
}
} else if (error.message) {
message = error.message;
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Error with Invite",
text: message,
},
5000,
);
}
}
} }
private danger(message: string, title: string = "Error", timeout = 5000) { private danger(message: string, title: string = "Error", timeout = 5000) {
@ -864,12 +946,14 @@ export default class ContactsView extends Vue {
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.isAxiosError) { if (serverError.isAxiosError) {
if (serverError.response?.data if (
&& typeof serverError.response.data === 'object' serverError.response?.data &&
&& 'error' in serverError.response.data typeof serverError.response.data === "object" &&
&& typeof serverError.response.data.error === 'object' "error" in serverError.response.data &&
&& serverError.response.data.error !== null typeof serverError.response.data.error === "object" &&
&& 'message' in serverError.response.data.error){ serverError.response.data.error !== null &&
"message" in serverError.response.data.error
) {
userMessage = serverError.response.data.error.message as string; 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

37
src/views/InviteOneView.vue

@ -44,7 +44,10 @@
:key="invite.inviteIdentifier" :key="invite.inviteIdentifier"
class="border-t" class="border-t"
> >
<td class="py-2 text-center"> <td
class="py-2 text-center text-blue-500"
@click="copyInviteAndNotify(invite.jwt)"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }} {{ getTruncatedInviteId(invite.inviteIdentifier) }}
</td> </td>
<td class="py-2 text-left">{{ invite.notes }}</td> <td class="py-2 text-left">{{ invite.notes }}</td>
@ -62,19 +65,21 @@
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import axios from "axios"; import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { db, retrieveSettingsForActiveAccount } from "../db"; import { db, retrieveSettingsForActiveAccount } from "../db";
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 { NotificationIface } from "@/constants/app"; import { APP_SERVER, NotificationIface } from "@/constants/app";
import { createInviteJwt, getHeaders } from "@/libs/endorserServer"; import { createInviteJwt, getHeaders } from "@/libs/endorserServer";
interface Invite { interface Invite {
inviteIdentifier: string; inviteIdentifier: string;
expiresAt: string; expiresAt: string;
jwt: string;
notes: string; notes: string;
redeemedBy: string | null; redeemedBy: string | null;
} }
@ -124,11 +129,25 @@ export default class InviteOneView extends Vue {
} }
getTruncatedRedeemedBy(redeemedBy: string | null): string { getTruncatedRedeemedBy(redeemedBy: string | null): string {
if (!redeemedBy) return "Not yet redeemed"; if (!redeemedBy) return "";
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)}`;
} }
copyInviteAndNotify(jwt: string) {
const link = APP_SERVER + "/contacts?inviteJwt=" + jwt;
useClipboard().copy(link);
this.$notify(
{
group: "alert",
type: "success",
title: "Copied",
text: "Invitation link is copied to clipboard.",
},
3000,
);
}
async createInvite() { async createInvite() {
(this.$refs.inviteDialog as InviteDialog).open( (this.$refs.inviteDialog as InviteDialog).open(
"Invitation Note", "Invitation Note",
@ -148,8 +167,7 @@ export default class InviteOneView extends Vue {
}, },
}; };
} }
const expiresIn = const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000;
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000;
const inviteJwt = await createInviteJwt( const inviteJwt = await createInviteJwt(
this.activeDid, this.activeDid,
undefined, undefined,
@ -164,6 +182,7 @@ export default class InviteOneView extends Vue {
this.invites.push({ this.invites.push({
inviteIdentifier: inviteIdentifier, inviteIdentifier: inviteIdentifier,
expiresAt: expiresAt, expiresAt: expiresAt,
jwt: inviteJwt,
notes: notes, notes: notes,
redeemedBy: null, redeemedBy: null,
}); });
@ -176,7 +195,11 @@ export default class InviteOneView extends Vue {
error.response.data && error.response.data &&
error.response.data.error error.response.data.error
) { ) {
message = error.response.data.error; if (error.response.data.error.message) {
message = error.response.data.error.message;
} else {
message = error.response.data.error;
}
} }
this.$notify( this.$notify(
{ {

4
src/views/NewEditProjectView.vue

@ -262,7 +262,8 @@ 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 RouteLocationNormalizedLoaded).query["projectId"] || ""; this.projectId =
(this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
if (this.projectId) { if (this.projectId) {
if (this.numAccounts === 0) { if (this.numAccounts === 0) {
@ -623,6 +624,7 @@ export default class NewEditProjectView extends Vue {
5000, 5000,
); );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } 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}.`;

Loading…
Cancel
Save