finish the loading of an invite RegisterAction when clicking on a link
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
# 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_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
||||
|
||||
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
## [0.3.29-beta]
|
||||
### Added
|
||||
- Invite for a contact to join immediately
|
||||
### Changed
|
||||
- Send signed data to nostr endpoints to verify public key ownership.
|
||||
### Changed in DB or environment
|
||||
|
||||
@@ -59,7 +59,7 @@ npm run test-all
|
||||
```
|
||||
# (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_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
|
||||
|
||||
83
src/components/ContactNameDialog.vue
Normal file
83
src/components/ContactNameDialog.vue
Normal file
@@ -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>
|
||||
@@ -1,10 +1,11 @@
|
||||
<!-- similar to ContactNameDialog -->
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<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
|
||||
you choose to send it to them.
|
||||
This is not sent to servers. It is only shared with people when you send
|
||||
it to them.
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
@@ -21,7 +22,6 @@
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<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"
|
||||
|
||||
@@ -27,6 +27,9 @@ export enum AppString {
|
||||
NO_CONTACT_NAME = "(no name)",
|
||||
}
|
||||
|
||||
export const APP_SERVER =
|
||||
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
|
||||
|
||||
export const DEFAULT_ENDORSER_API_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
||||
AppString.TEST_ENDORSER_API_SERVER;
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function createEndorserJwtForKey(
|
||||
issuer: account.did,
|
||||
signer: signer,
|
||||
expiresIn: undefined as number | undefined,
|
||||
}
|
||||
};
|
||||
if (expiresIn) {
|
||||
options.expiresIn = expiresIn;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ClaimResult {
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
// similar to VerifiableCredentialSubject... maybe rename this
|
||||
export interface GenericVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
"@type": string;
|
||||
@@ -56,8 +57,6 @@ export interface GenericVerifiableCredential {
|
||||
}
|
||||
|
||||
export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
claim: T;
|
||||
claimType?: string;
|
||||
handleId: string;
|
||||
@@ -68,8 +67,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
|
||||
}
|
||||
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
|
||||
{
|
||||
"@context": SCHEMA_ORG_CONTEXT,
|
||||
"@type": "",
|
||||
claim: { "@type": "" },
|
||||
handleId: "",
|
||||
id: "",
|
||||
@@ -223,11 +220,21 @@ export interface ImageRateLimits {
|
||||
}
|
||||
|
||||
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;
|
||||
"@type": string;
|
||||
name: string;
|
||||
description: string;
|
||||
identifier?: string;
|
||||
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
export interface WorldProperties {
|
||||
@@ -235,13 +242,14 @@ export interface WorldProperties {
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
// AKA Registration & RegisterAction
|
||||
export interface RegisterVerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
"@context": typeof SCHEMA_ORG_CONTEXT;
|
||||
"@type": "RegisterAction";
|
||||
agent: { identifier: string };
|
||||
identifier?: string;
|
||||
identifier?: string; // used for invites (when participant ID isn't known)
|
||||
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
|
||||
|
||||
@@ -314,7 +314,7 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
return newId.did;
|
||||
|
||||
@@ -254,10 +254,7 @@
|
||||
<b>{{ endorserLimits.doneRegistrationsThisMonth }} registrations</b>
|
||||
out of <b>{{ endorserLimits.maxRegistrationsPerMonth }}</b> for this
|
||||
month.
|
||||
<i
|
||||
>(You can register nobody on your first day, and after that only one
|
||||
a day in your first month.)</i
|
||||
>
|
||||
<i>(You cannot register anyone else on your first day.)</i>
|
||||
Your registration counter resets at
|
||||
<b class="whitespace-nowrap">
|
||||
{{ readableDate(endorserLimits.nextMonthBeginDateTime) }}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
<fa icon="chevron-left" class="fa-fw" />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
click here to set it for them.
|
||||
</span>
|
||||
</p>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
</div>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
|
||||
<div
|
||||
@click="onCopyUrlToClipboard()"
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
}"
|
||||
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>
|
||||
|
||||
<span class="ml-4 text-sm overflow-hidden"
|
||||
@@ -276,6 +276,7 @@
|
||||
|
||||
<GiftedDialog ref="customGivenDialog" />
|
||||
<OfferDialog ref="customOfferDialog" />
|
||||
<ContactNameDialog ref="contactNameDialog" />
|
||||
|
||||
<div v-if="showLargeIdenticon" class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||
<div
|
||||
@@ -296,6 +297,7 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import { IndexableType } from "dexie";
|
||||
import { JWTPayload } from "did-jwt";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
@@ -304,7 +306,7 @@ import { useClipboard } from "@vueuse/core";
|
||||
import { AppString, NotificationIface } from "@/constants/app";
|
||||
import {
|
||||
db,
|
||||
retrieveSettingsForActiveAccount,
|
||||
retrieveSettingsForActiveAccount, updateAccountSettings,
|
||||
updateDefaultSettings,
|
||||
} from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
@@ -319,16 +321,26 @@ import {
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
UserInfo,
|
||||
VerifiableCredential,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as libsUtil from "@/libs/util";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import OfferDialog from "@/components/OfferDialog.vue";
|
||||
import ContactNameDialog from "@/components/ContactNameDialog.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import {generateSaveAndActivateIdentity} from "@/libs/util";
|
||||
|
||||
@Component({
|
||||
components: { TopMessage, GiftedDialog, EntityIcon, OfferDialog, QuickNav },
|
||||
components: {
|
||||
GiftedDialog,
|
||||
EntityIcon,
|
||||
OfferDialog,
|
||||
QuickNav,
|
||||
ContactNameDialog,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class ContactsView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
@@ -387,11 +399,11 @@ export default class ContactsView extends Vue {
|
||||
(a.name || "").localeCompare(b.name || ""),
|
||||
);
|
||||
|
||||
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded).query[
|
||||
"contactJwt"
|
||||
] as string;
|
||||
// handle a contact sent via URL
|
||||
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded)
|
||||
.query["contactJwt"] as string;
|
||||
if (importedContactJwt) {
|
||||
// really should fully verify
|
||||
// really should fully verify contents
|
||||
const { payload } = decodeEndorserJwt(importedContactJwt);
|
||||
const userInfo = payload["own"] as UserInfo;
|
||||
const newContact = {
|
||||
@@ -404,6 +416,76 @@ export default class ContactsView extends Vue {
|
||||
} as Contact;
|
||||
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) {
|
||||
@@ -864,12 +946,14 @@ export default class ContactsView extends Vue {
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError.isAxiosError) {
|
||||
if (serverError.response?.data
|
||||
&& typeof serverError.response.data === 'object'
|
||||
&& 'error' in serverError.response.data
|
||||
&& typeof serverError.response.data.error === 'object'
|
||||
&& serverError.response.data.error !== null
|
||||
&& 'message' in serverError.response.data.error){
|
||||
if (
|
||||
serverError.response?.data &&
|
||||
typeof serverError.response.data === "object" &&
|
||||
"error" in serverError.response.data &&
|
||||
typeof serverError.response.data.error === "object" &&
|
||||
serverError.response.data.error !== null &&
|
||||
"message" in serverError.response.data.error
|
||||
) {
|
||||
userMessage = serverError.response.data.error.message as string;
|
||||
} else if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
:key="invite.inviteIdentifier"
|
||||
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) }}
|
||||
</td>
|
||||
<td class="py-2 text-left">{{ invite.notes }}</td>
|
||||
@@ -62,19 +65,21 @@
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import axios from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.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";
|
||||
|
||||
interface Invite {
|
||||
inviteIdentifier: string;
|
||||
expiresAt: string;
|
||||
jwt: string;
|
||||
notes: string;
|
||||
redeemedBy: string | null;
|
||||
}
|
||||
@@ -124,11 +129,25 @@ export default class InviteOneView extends Vue {
|
||||
}
|
||||
|
||||
getTruncatedRedeemedBy(redeemedBy: string | null): string {
|
||||
if (!redeemedBy) return "Not yet redeemed";
|
||||
if (!redeemedBy) return "";
|
||||
if (redeemedBy.length <= 19) return redeemedBy;
|
||||
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() {
|
||||
(this.$refs.inviteDialog as InviteDialog).open(
|
||||
"Invitation Note",
|
||||
@@ -148,8 +167,7 @@ export default class InviteOneView extends Vue {
|
||||
},
|
||||
};
|
||||
}
|
||||
const expiresIn =
|
||||
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000;
|
||||
const expiresIn = (new Date(expiresAt).getTime() - Date.now()) / 1000;
|
||||
const inviteJwt = await createInviteJwt(
|
||||
this.activeDid,
|
||||
undefined,
|
||||
@@ -164,6 +182,7 @@ export default class InviteOneView extends Vue {
|
||||
this.invites.push({
|
||||
inviteIdentifier: inviteIdentifier,
|
||||
expiresAt: expiresAt,
|
||||
jwt: inviteJwt,
|
||||
notes: notes,
|
||||
redeemedBy: null,
|
||||
});
|
||||
@@ -176,7 +195,11 @@ export default class InviteOneView extends Vue {
|
||||
error.response.data &&
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -262,7 +262,8 @@ export default class NewEditProjectView extends Vue {
|
||||
this.apiServer = settings.apiServer || "";
|
||||
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.numAccounts === 0) {
|
||||
@@ -623,6 +624,7 @@ export default class NewEditProjectView extends Vue {
|
||||
5000,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error(`Error sending to ${serviceName}`, error);
|
||||
let errorMessage = `There was an error sending to ${serviceName}.`;
|
||||
|
||||
Reference in New Issue
Block a user