From e8ba6a63ab43a94bf2d676ad389f87e1815c8734 Mon Sep 17 00:00:00 2001 From: m5r Date: Thu, 9 Jun 2022 00:33:19 +0200 Subject: [PATCH] replace twilio connect with account sid/auth token form --- .../messages/actions/messages.$recipient.tsx | 7 +- .../messages/components/conversation.tsx | 3 +- .../phone-calls/loaders/twilio-token.ts | 11 +- app/features/settings/actions/account.ts | 4 +- app/features/settings/actions/phone.ts | 221 ++++++++++++++++-- .../components/phone/phone-number-form.tsx | 105 +++++---- .../components/phone/twilio-connect.tsx | 76 ++++-- app/features/settings/loaders/phone.ts | 13 +- app/queues/index.ts | 2 + app/queues/set-twilio-api-key.server.ts | 36 +++ app/queues/set-twilio-webhooks.server.ts | 10 +- app/routes/twilio.authorize.ts | 82 ------- app/routes/webhooks/call.ts | 2 +- app/routes/webhooks/message.ts | 2 +- app/utils/auth.server.ts | 19 +- app/utils/twilio.server.ts | 13 +- app/utils/validation.server.ts | 13 ++ .../20220517184134_init/migration.sql | 7 +- prisma/schema.prisma | 19 +- 19 files changed, 437 insertions(+), 208 deletions(-) create mode 100644 app/queues/set-twilio-api-key.server.ts delete mode 100644 app/routes/twilio.authorize.ts diff --git a/app/features/messages/actions/messages.$recipient.tsx b/app/features/messages/actions/messages.$recipient.tsx index 2bfb0fe..1436bb1 100644 --- a/app/features/messages/actions/messages.$recipient.tsx +++ b/app/features/messages/actions/messages.$recipient.tsx @@ -8,10 +8,15 @@ import getTwilioClient, { translateMessageDirection, translateMessageStatus } fr export type NewMessageActionData = {}; const action: ActionFunction = async ({ params, request }) => { - const { phoneNumber, twilioAccount } = await requireLoggedIn(request); + const { phoneNumber, twilio } = await requireLoggedIn(request); + if (!twilio) { + throw new Error("unreachable"); + } + const twilioAccount = await db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }); if (!twilioAccount) { throw new Error("unreachable"); } + const recipient = decodeURIComponent(params.recipient ?? ""); const formData = Object.fromEntries(await request.formData()); const twilioClient = getTwilioClient(twilioAccount); diff --git a/app/features/messages/components/conversation.tsx b/app/features/messages/components/conversation.tsx index 480182d..383f0fa 100644 --- a/app/features/messages/components/conversation.tsx +++ b/app/features/messages/components/conversation.tsx @@ -6,7 +6,7 @@ import { Direction } from "@prisma/client"; import NewMessageArea from "./new-message-area"; import { formatDate, formatTime } from "~/features/core/helpers/date-formatter"; -import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient"; +import { type ConversationLoaderData } from "~/features/messages/loaders/messages.$recipient"; import useSession from "~/features/core/hooks/use-session"; export default function Conversation() { @@ -24,6 +24,7 @@ export default function Conversation() { phoneNumberId: phoneNumber!.id, from: phoneNumber!.number, to: recipient, + recipient, sentAt: new Date(), direction: Direction.Outbound, diff --git a/app/features/phone-calls/loaders/twilio-token.ts b/app/features/phone-calls/loaders/twilio-token.ts index 2d885c2..85fd339 100644 --- a/app/features/phone-calls/loaders/twilio-token.ts +++ b/app/features/phone-calls/loaders/twilio-token.ts @@ -10,7 +10,12 @@ import getTwilioClient from "~/utils/twilio.server"; export type TwilioTokenLoaderData = string; const loader: LoaderFunction = async ({ request }) => { - const { user, organization, twilioAccount } = await requireLoggedIn(request); + const { user, organization, twilio } = await requireLoggedIn(request); + if (!twilio) { + throw new Error("unreachable"); + } + + const twilioAccount = await db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }); if (!twilioAccount || !twilioAccount.twimlAppSid) { throw new Error("unreachable"); } @@ -36,12 +41,12 @@ const loader: LoaderFunction = async ({ request }) => { apiKeySid = apiKey.sid; apiKeySecret = apiKey.secret; await db.twilioAccount.update({ - where: { subAccountSid: twilioAccount.subAccountSid }, + where: { accountSid: twilioAccount.accountSid }, data: { apiKeySid: apiKey.sid, apiKeySecret: encrypt(apiKey.secret) }, }); } - const accessToken = new Twilio.jwt.AccessToken(twilioAccount.subAccountSid, apiKeySid, apiKeySecret, { + const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, apiKeySecret, { identity: `${organization.id}__${user.id}`, ttl: 3600, }); diff --git a/app/features/settings/actions/account.ts b/app/features/settings/actions/account.ts index 3cbadee..0ea46fe 100644 --- a/app/features/settings/actions/account.ts +++ b/app/features/settings/actions/account.ts @@ -13,7 +13,7 @@ import deleteUserQueue from "~/queues/delete-user-data.server"; const action: ActionFunction = async ({ request }) => { const formData = Object.fromEntries(await request.formData()); if (!formData._action) { - const errorMessage = "POST /settings without any _action"; + const errorMessage = "POST /settings/phone without any _action"; logger.error(errorMessage); return badRequest({ errorMessage }); } @@ -26,7 +26,7 @@ const action: ActionFunction = async ({ request }) => { case "updateUser": return updateUser(request, formData); default: - const errorMessage = `POST /settings with an invalid _action=${formData._action}`; + const errorMessage = `POST /settings/phone with an invalid _action=${formData._action}`; logger.error(errorMessage); return badRequest({ errorMessage }); } diff --git a/app/features/settings/actions/phone.ts b/app/features/settings/actions/phone.ts index 897fa3d..6af8954 100644 --- a/app/features/settings/actions/phone.ts +++ b/app/features/settings/actions/phone.ts @@ -1,23 +1,50 @@ -import { type ActionFunction, json } from "@remix-run/node"; +import { type ActionFunction, type Session, json } from "@remix-run/node"; import { badRequest } from "remix-utils"; import { z } from "zod"; +import type { Prisma } from "@prisma/client"; import db from "~/utils/db.server"; -import { type FormError, validate } from "~/utils/validation.server"; +import { type FormActionData, validate } from "~/utils/validation.server"; import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server"; import { commitSession } from "~/utils/session.server"; import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.server"; - -type SetPhoneNumberFailureActionData = { errors: FormError; submitted?: never }; -type SetPhoneNumberSuccessfulActionData = { errors?: never; submitted: true }; -export type SetPhoneNumberActionData = SetPhoneNumberFailureActionData | SetPhoneNumberSuccessfulActionData; +import logger from "~/utils/logger.server"; +import { encrypt } from "~/utils/encryption"; +import getTwilioClient from "~/utils/twilio.server"; +import fetchPhoneCallsQueue from "~/queues/fetch-phone-calls.server"; +import fetchMessagesQueue from "~/queues/fetch-messages.server"; +import setTwilioApiKeyQueue from "~/queues/set-twilio-api-key.server"; const action: ActionFunction = async ({ request }) => { - const { organization } = await requireLoggedIn(request); const formData = Object.fromEntries(await request.formData()); - const validation = validate(bodySchema, formData); + if (!formData._action) { + const errorMessage = "POST /settings/phone without any _action"; + logger.error(errorMessage); + return badRequest({ errorMessage }); + } + + console.log("formData._action", formData._action); + switch (formData._action as Action) { + case "setPhoneNumber": + return setPhoneNumber(request, formData); + case "setTwilioCredentials": + return setTwilioCredentials(request, formData); + case "refreshPhoneNumbers": + return refreshPhoneNumbers(request); + default: + const errorMessage = `POST /settings/phone with an invalid _action=${formData._action}`; + logger.error(errorMessage); + return badRequest({ errorMessage }); + } +}; + +export type SetPhoneNumberActionData = FormActionData; + +async function setPhoneNumber(request: Request, formData: unknown) { + const { organization } = await requireLoggedIn(request); + const validation = validate(validations.setPhoneNumber, formData); if (validation.errors) { - return badRequest({ errors: validation.errors }); + return badRequest({ setPhoneNumber: { errors: validation.errors } }); } try { @@ -43,19 +70,181 @@ const action: ActionFunction = async ({ request }) => { const { session } = await refreshSessionData(request); return json( - { submitted: true }, + { setPhoneNumber: { submitted: true } }, { headers: { "Set-Cookie": await commitSession(session), }, }, ); -}; +} + +export type SetTwilioCredentialsActionData = FormActionData; + +async function setTwilioCredentials(request: Request, formData: unknown) { + const { organization, twilio } = await requireLoggedIn(request); + const validation = validate(validations.setTwilioCredentials, formData); + if (validation.errors) { + return badRequest({ setTwilioCredentials: { errors: validation.errors } }); + } + + const { twilioAccountSid, twilioAuthToken } = validation.data; + const authToken = encrypt(twilioAuthToken); + const twilioClient = getTwilioClient({ accountSid: twilioAccountSid, authToken }); + try { + await twilioClient.api.accounts(twilioAccountSid).fetch(); + } catch (error: any) { + logger.error(error); + + if (error.status !== 401) { + throw error; + } + + let session: Session | undefined; + if (twilio) { + await db.twilioAccount.delete({ where: { accountSid: twilio?.accountSid } }); + session = (await refreshSessionData(request)).session; + } + + return json( + { + setTwilioCredentials: { + errors: { general: "Invalid Account SID or Auth Token" }, + }, + }, + { + headers: session + ? { + "Set-Cookie": await commitSession(session), + } + : {}, + }, + ); + } + + const data: Pick = { + accountSid: twilioAccountSid, + authToken, + }; + const [phoneNumbers] = await Promise.all([ + twilioClient.incomingPhoneNumbers.list(), + setTwilioApiKeyQueue.add(`set twilio api key for accountSid=${twilioAccountSid}`, { + accountSid: twilioAccountSid, + }), + db.twilioAccount.upsert({ + where: { organizationId: organization.id }, + create: { + organization: { + connect: { id: organization.id }, + }, + ...data, + }, + update: data, + }), + ]); + + await Promise.all( + phoneNumbers.map(async (phoneNumber) => { + const phoneNumberId = phoneNumber.sid; + try { + await db.phoneNumber.create({ + data: { + id: phoneNumberId, + organizationId: organization.id, + number: phoneNumber.phoneNumber, + isCurrent: false, + isFetchingCalls: true, + isFetchingMessages: true, + }, + }); + + await Promise.all([ + fetchPhoneCallsQueue.add(`fetch calls of id=${phoneNumberId}`, { + phoneNumberId, + }), + fetchMessagesQueue.add(`fetch messages of id=${phoneNumberId}`, { + phoneNumberId, + }), + ]); + } catch (error: any) { + if (error.code !== "P2002") { + // if it's not a duplicate, it's a real error we need to handle + throw error; + } + } + }), + ); + + const { session } = await refreshSessionData(request); + return json( + { setTwilioCredentials: { submitted: true } }, + { + headers: { + "Set-Cookie": await commitSession(session), + }, + }, + ); +} + +async function refreshPhoneNumbers(request: Request) { + const { organization, twilio } = await requireLoggedIn(request); + if (!twilio) { + throw new Error("unreachable"); + } + const twilioAccount = await db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }); + if (!twilioAccount) { + throw new Error("unreachable"); + } + + const twilioClient = getTwilioClient(twilioAccount); + const phoneNumbers = await twilioClient.incomingPhoneNumbers.list(); + await Promise.all( + phoneNumbers.map(async (phoneNumber) => { + const phoneNumberId = phoneNumber.sid; + try { + await db.phoneNumber.create({ + data: { + id: phoneNumberId, + organizationId: organization.id, + number: phoneNumber.phoneNumber, + isCurrent: false, + isFetchingCalls: true, + isFetchingMessages: true, + }, + }); + + await Promise.all([ + fetchPhoneCallsQueue.add(`fetch calls of id=${phoneNumberId}`, { + phoneNumberId, + }), + fetchMessagesQueue.add(`fetch messages of id=${phoneNumberId}`, { + phoneNumberId, + }), + ]); + } catch (error: any) { + if (error.code !== "P2002") { + // if it's not a duplicate, it's a real error we need to handle + throw error; + } + } + }), + ); + + return null; +} export default action; -const bodySchema = z.object({ - phoneNumberSid: z - .string() - .refine((phoneNumberSid) => phoneNumberSid.startsWith("PN"), "Select a valid phone number"), -}); +type Action = "setPhoneNumber" | "setTwilioCredentials" | "refreshPhoneNumbers"; + +const validations = { + setPhoneNumber: z.object({ + phoneNumberSid: z + .string() + .refine((phoneNumberSid) => phoneNumberSid.startsWith("PN"), "Select a valid phone number"), + }), + setTwilioCredentials: z.object({ + twilioAccountSid: z.string(), + twilioAuthToken: z.string(), + }), +} as const; diff --git a/app/features/settings/components/phone/phone-number-form.tsx b/app/features/settings/components/phone/phone-number-form.tsx index 2adf4b4..e8b7c7a 100644 --- a/app/features/settings/components/phone/phone-number-form.tsx +++ b/app/features/settings/components/phone/phone-number-form.tsx @@ -1,4 +1,5 @@ -import { Form, useActionData, useCatch, useLoaderData, useTransition } from "@remix-run/react"; +import { Form, useActionData, useCatch, useFetcher, useLoaderData, useTransition } from "@remix-run/react"; +import { IoReloadOutline } from "react-icons/io5"; import Button from "../button"; import SettingsSection from "../settings-section"; @@ -6,11 +7,13 @@ import Alert from "~/features/core/components/alert"; import useSession from "~/features/core/hooks/use-session"; import type { PhoneSettingsLoaderData } from "~/features/settings/loaders/phone"; import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone"; +import clsx from "clsx"; export default function PhoneNumberForm() { + const { twilio } = useSession(); + const fetcher = useFetcher(); const transition = useTransition(); - const actionData = useActionData(); - const { twilioAccount } = useSession(); + const actionData = useActionData()?.setPhoneNumber; const availablePhoneNumbers = useLoaderData().phoneNumbers; const isSubmitting = transition.state === "submitting"; @@ -19,54 +22,72 @@ export default function PhoneNumberForm() { const topErrorMessage = errors?.general ?? errors?.phoneNumberSid; const isError = typeof topErrorMessage !== "undefined"; const currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true); - const hasFilledTwilioCredentials = twilioAccount !== null; + const hasFilledTwilioCredentials = twilio !== null; if (!hasFilledTwilioCredentials) { return null; } return ( -
- - - - } +
+ - {isSuccess ? ( -
- -
- ) : null} - - - - - + {isError ? ( +
+ +
+ ) : null} + + {isSuccess ? ( +
+ +
+ ) : null} + + + + + + + +
); } diff --git a/app/features/settings/components/phone/twilio-connect.tsx b/app/features/settings/components/phone/twilio-connect.tsx index 8d89539..dbfb46c 100644 --- a/app/features/settings/components/phone/twilio-connect.tsx +++ b/app/features/settings/components/phone/twilio-connect.tsx @@ -1,38 +1,82 @@ import { useState } from "react"; +import { Form, useActionData, useLoaderData, useTransition } from "@remix-run/react"; import { IoHelpCircle } from "react-icons/io5"; +import type { PhoneSettingsLoaderData } from "~/features/settings/loaders/phone"; +import type { SetTwilioCredentialsActionData } from "~/features/settings/actions/phone"; import HelpModal from "./help-modal"; import SettingsSection from "../settings-section"; import useSession from "~/features/core/hooks/use-session"; +import Alert from "~/features/core/components/alert"; +import LabeledTextField from "~/features/core/components/labeled-text-field"; +import Button from "~/features/settings/components/button"; export default function TwilioConnect() { - const { twilioAccount } = useSession(); + const { twilio } = useSession(); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); + const transition = useTransition(); + const actionData = useActionData()?.setTwilioCredentials; + const { accountSid, authToken } = useLoaderData(); + + const topErrorMessage = actionData?.errors?.general; + const isError = typeof topErrorMessage !== "undefined"; + const isCurrentFormTransition = transition.submission?.formData.get("_action") === "changePassword"; + const isSubmitting = isCurrentFormTransition && transition.state === "submitting"; return ( <> - -
+
+ + + + } + >
- Shellphone needs to connect to your Twilio account to securely use your phone numbers. + Shellphone needs some informations about your Twilio account to securely use your phone numbers.
- {twilioAccount === null ? ( - - Connect Twilio account - - ) : ( + {twilio !== null ? (

✓ Your Twilio account is connected to Shellphone.

- )} -
-
+ ) : null} + + {isError ? ( +
+ +
+ ) : null} + + + + + + +
+ setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} /> diff --git a/app/features/settings/loaders/phone.ts b/app/features/settings/loaders/phone.ts index 1f63290..89c3891 100644 --- a/app/features/settings/loaders/phone.ts +++ b/app/features/settings/loaders/phone.ts @@ -4,14 +4,17 @@ import { type PhoneNumber, Prisma } from "@prisma/client"; import db from "~/utils/db.server"; import { requireLoggedIn } from "~/utils/auth.server"; import logger from "~/utils/logger.server"; +import { decrypt } from "~/utils/encryption"; export type PhoneSettingsLoaderData = { + accountSid?: string; + authToken?: string; phoneNumbers: Pick[]; }; const loader: LoaderFunction = async ({ request }) => { - const { organization, twilioAccount } = await requireLoggedIn(request); - if (!twilioAccount) { + const { organization, twilio } = await requireLoggedIn(request); + if (!twilio) { logger.warn("Twilio account is not connected"); return json({ phoneNumbers: [] }); } @@ -22,6 +25,10 @@ const loader: LoaderFunction = async ({ request }) => { orderBy: { id: Prisma.SortOrder.desc }, }); - return json({ phoneNumbers }); + return json({ + accountSid: twilio.accountSid, + authToken: decrypt(twilio.authToken), + phoneNumbers, + }); }; export default loader; diff --git a/app/queues/index.ts b/app/queues/index.ts index a33ab4a..9d04937 100644 --- a/app/queues/index.ts +++ b/app/queues/index.ts @@ -4,6 +4,7 @@ import insertPhoneCallsQueue from "./insert-phone-calls.server"; import fetchMessagesQueue from "./fetch-messages.server"; import insertMessagesQueue from "./insert-messages.server"; import setTwilioWebhooksQueue from "./set-twilio-webhooks.server"; +import setTwilioApiKeyQueue from "./set-twilio-api-key.server"; export default [ deleteUserDataQueue, @@ -12,4 +13,5 @@ export default [ fetchMessagesQueue, insertMessagesQueue, setTwilioWebhooksQueue, + setTwilioApiKeyQueue, ]; diff --git a/app/queues/set-twilio-api-key.server.ts b/app/queues/set-twilio-api-key.server.ts new file mode 100644 index 0000000..94f6c92 --- /dev/null +++ b/app/queues/set-twilio-api-key.server.ts @@ -0,0 +1,36 @@ +import { Queue } from "~/utils/queue.server"; +import db from "~/utils/db.server"; +import getTwilioClient from "~/utils/twilio.server"; +import { encrypt } from "~/utils/encryption"; + +type Payload = { + accountSid: string; +}; + +export default Queue("set twilio api key", async ({ data }) => { + const accountSid = data.accountSid; + const twilioAccount = await db.twilioAccount.findUnique({ where: { accountSid } }); + if (!twilioAccount) { + return; + } + + const twilioClient = getTwilioClient(twilioAccount); + const friendlyName = "Shellphone API key"; + + await new Promise((resolve) => { + twilioClient.api.keys.each({ done: resolve }, (apiKey) => { + if (apiKey.friendlyName === friendlyName) { + apiKey.remove(); + } + }); + }); + + const apiKey = await twilioClient.newKeys.create({ friendlyName }); + await db.twilioAccount.update({ + where: { accountSid }, + data: { + apiKeySid: apiKey.sid, + apiKeySecret: encrypt(apiKey.secret), + }, + }); +}); diff --git a/app/queues/set-twilio-webhooks.server.ts b/app/queues/set-twilio-webhooks.server.ts index 4096c51..319a23c 100644 --- a/app/queues/set-twilio-webhooks.server.ts +++ b/app/queues/set-twilio-webhooks.server.ts @@ -1,9 +1,10 @@ -import type twilio from "twilio"; +import twilio from "twilio"; import type { ApplicationInstance } from "twilio/lib/rest/api/v2010/account/application"; import { Queue } from "~/utils/queue.server"; import db from "~/utils/db.server"; -import getTwilioClient, { getTwiMLName, smsUrl, voiceUrl } from "~/utils/twilio.server"; +import { getTwiMLName, smsUrl, voiceUrl } from "~/utils/twilio.server"; +import { decrypt } from "~/utils/encryption"; type Payload = { phoneNumberId: string; @@ -18,7 +19,7 @@ export default Queue("set twilio webhooks", async ({ data }) => { organization: { select: { twilioAccount: { - select: { accountSid: true, subAccountSid: true, twimlAppSid: true }, + select: { accountSid: true, twimlAppSid: true, authToken: true }, }, }, }, @@ -29,7 +30,8 @@ export default Queue("set twilio webhooks", async ({ data }) => { } const twilioAccount = phoneNumber.organization.twilioAccount; - const twilioClient = getTwilioClient(twilioAccount); + const authToken = decrypt(twilioAccount.authToken); + const twilioClient = twilio(twilioAccount.accountSid, authToken); const twimlApp = await getTwimlApplication(twilioClient, twilioAccount.twimlAppSid); const twimlAppSid = twimlApp.sid; diff --git a/app/routes/twilio.authorize.ts b/app/routes/twilio.authorize.ts deleted file mode 100644 index 352c7b5..0000000 --- a/app/routes/twilio.authorize.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { type LoaderFunction, redirect } from "@remix-run/node"; - -import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server"; -import { commitSession } from "~/utils/session.server"; -import db from "~/utils/db.server"; -import getTwilioClient from "~/utils/twilio.server"; -import fetchPhoneCallsQueue from "~/queues/fetch-phone-calls.server"; -import fetchMessagesQueue from "~/queues/fetch-messages.server"; -import { encrypt } from "~/utils/encryption"; - -export const loader: LoaderFunction = async ({ request }) => { - const { organization } = await requireLoggedIn(request); - const url = new URL(request.url); - const twilioSubAccountSid = url.searchParams.get("AccountSid"); - if (!twilioSubAccountSid) { - throw new Error("unreachable"); - } - - let twilioClient = getTwilioClient({ accountSid: twilioSubAccountSid, subAccountSid: twilioSubAccountSid }); - const twilioSubAccount = await twilioClient.api.accounts(twilioSubAccountSid).fetch(); - const twilioMainAccountSid = twilioSubAccount.ownerAccountSid; - const twilioMainAccount = await twilioClient.api.accounts(twilioMainAccountSid).fetch(); - console.log("twilioSubAccount", twilioSubAccount); - console.log("twilioAccount", twilioMainAccount); - const data = { - subAccountSid: twilioSubAccount.sid, - subAccountAuthToken: encrypt(twilioSubAccount.authToken), - accountSid: twilioMainAccount.sid, - }; - - const twilioAccount = await db.twilioAccount.upsert({ - where: { organizationId: organization.id }, - create: { - organization: { - connect: { id: organization.id }, - }, - ...data, - }, - update: data, - }); - - twilioClient = getTwilioClient(twilioAccount); - const phoneNumbers = await twilioClient.incomingPhoneNumbers.list(); - await Promise.all( - phoneNumbers.map(async (phoneNumber) => { - const phoneNumberId = phoneNumber.sid; - try { - await db.phoneNumber.create({ - data: { - id: phoneNumberId, - organizationId: organization.id, - number: phoneNumber.phoneNumber, - isCurrent: false, - isFetchingCalls: true, - isFetchingMessages: true, - }, - }); - - await Promise.all([ - fetchPhoneCallsQueue.add(`fetch calls of id=${phoneNumberId}`, { - phoneNumberId, - }), - fetchMessagesQueue.add(`fetch messages of id=${phoneNumberId}`, { - phoneNumberId, - }), - ]); - } catch (error: any) { - if (error.code !== "P2002") { - // if it's not a duplicate, it's a real error we need to handle - throw error; - } - } - }), - ); - - const { session } = await refreshSessionData(request); - return redirect("/settings/phone", { - headers: { - "Set-Cookie": await commitSession(session), - }, - }); -}; diff --git a/app/routes/webhooks/call.ts b/app/routes/webhooks/call.ts index 61f6473..19086ae 100644 --- a/app/routes/webhooks/call.ts +++ b/app/routes/webhooks/call.ts @@ -51,7 +51,7 @@ export const action: ActionFunction = async ({ request }) => { return new Response(null, { status: 402 }); } - const encryptedAuthToken = phoneNumber?.organization.twilioAccount?.subAccountAuthToken; + const encryptedAuthToken = phoneNumber?.organization.twilioAccount?.authToken; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; if ( !phoneNumber || diff --git a/app/routes/webhooks/message.ts b/app/routes/webhooks/message.ts index 04f1318..635f08c 100644 --- a/app/routes/webhooks/message.ts +++ b/app/routes/webhooks/message.ts @@ -57,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => { // if multiple organizations have the same number // find the organization currently using that phone number // maybe we shouldn't let that happen by restricting a phone number to one org? - const encryptedAuthToken = phoneNumber.organization.twilioAccount?.subAccountAuthToken; + const encryptedAuthToken = phoneNumber.organization.twilioAccount?.authToken; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; return twilio.validateRequest(authToken, twilioSignature, smsUrl, body); }); diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 90c88ba..9da56f8 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -9,10 +9,7 @@ import authenticator from "./authenticator.server"; import { AuthenticationError, NotFoundError } from "./errors"; import { commitSession, destroySession, getSession } from "./session.server"; -type SessionTwilioAccount = Pick< - TwilioAccount, - "accountSid" | "subAccountSid" | "subAccountAuthToken" | "apiKeySid" | "apiKeySecret" | "twimlAppSid" ->; +type SessionTwilioAccount = Pick; type SessionOrganization = Pick & { role: MembershipRole; membershipId: string }; type SessionPhoneNumber = Pick; export type SessionUser = Pick; @@ -20,7 +17,7 @@ export type SessionData = { user: SessionUser; organization: SessionOrganization; phoneNumber: SessionPhoneNumber | null; - twilioAccount: SessionTwilioAccount | null; + twilio: SessionTwilioAccount | null; }; const SP = new SecurePassword(); @@ -62,6 +59,7 @@ export async function login({ form }: FormStrategyVerifyParams): Promise { select: { id: true, twilioAccount: { - select: { - accountSid: true, - subAccountSid: true, - subAccountAuthToken: true, - apiKeySid: true, - apiKeySecret: true, - twimlAppSid: true, - }, + select: { accountSid: true, authToken: true }, }, }, }, @@ -214,6 +205,6 @@ async function buildSessionData(id: string): Promise { user: rest, organization, phoneNumber, - twilioAccount, + twilio: twilioAccount, }; } diff --git a/app/utils/twilio.server.ts b/app/utils/twilio.server.ts index b701655..6361129 100644 --- a/app/utils/twilio.server.ts +++ b/app/utils/twilio.server.ts @@ -4,20 +4,17 @@ import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; import { type TwilioAccount, CallStatus, Direction, MessageStatus } from "@prisma/client"; import serverConfig from "~/config/config.server"; +import { decrypt } from "~/utils/encryption"; export default function getTwilioClient({ accountSid, - subAccountSid, - subAccountAuthToken, -}: Pick & - Partial>): twilio.Twilio { - if (!subAccountSid || !accountSid) { + authToken, +}: Pick): twilio.Twilio { + if (!accountSid || !authToken) { throw new Error("unreachable"); } - return twilio(subAccountSid, serverConfig.twilio.authToken, { - accountSid, - }); + return twilio(accountSid, decrypt(authToken)); } export const smsUrl = `https://${serverConfig.app.baseUrl}/webhook/message`; diff --git a/app/utils/validation.server.ts b/app/utils/validation.server.ts index 38245c8..bad2542 100644 --- a/app/utils/validation.server.ts +++ b/app/utils/validation.server.ts @@ -32,3 +32,16 @@ export function validate["_type"]>( errors, }; } + +type FormFailureData, Action extends keyof Validations> = { + errors: FormError; + submitted?: never; +}; +type FormSuccessData = { + errors?: never; + submitted: true; +}; +export type FormActionData, Action extends keyof Validations> = Record< + Action, + FormSuccessData | FormFailureData +>; diff --git a/prisma/migrations/20220517184134_init/migration.sql b/prisma/migrations/20220517184134_init/migration.sql index 233d650..509536b 100644 --- a/prisma/migrations/20220517184134_init/migration.sql +++ b/prisma/migrations/20220517184134_init/migration.sql @@ -21,17 +21,16 @@ CREATE TYPE "CallStatus" AS ENUM ('Queued', 'Ringing', 'InProgress', 'Completed' -- CreateTable CREATE TABLE "TwilioAccount" ( - "subAccountSid" TEXT NOT NULL, + "accountSid" TEXT NOT NULL, "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMPTZ(6) NOT NULL, - "subAccountAuthToken" TEXT NOT NULL, - "accountSid" TEXT NOT NULL, + "authToken" TEXT NOT NULL, "twimlAppSid" TEXT, "apiKeySid" TEXT, "apiKeySecret" TEXT, "organizationId" TEXT NOT NULL, - CONSTRAINT "TwilioAccount_pkey" PRIMARY KEY ("subAccountSid") + CONSTRAINT "TwilioAccount_pkey" PRIMARY KEY ("accountSid") ); -- CreateTable diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b6a9c27..269fde0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,14 +8,13 @@ datasource db { } model TwilioAccount { - subAccountSid String @id - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - subAccountAuthToken String - accountSid String - twimlAppSid String? - apiKeySid String? - apiKeySecret String? + accountSid String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + authToken String + twimlAppSid String? + apiKeySid String? + apiKeySecret String? organizationId String @unique organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@ -157,8 +156,8 @@ model NotificationSubscription { keys_p256dh String keys_auth String - membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) - membershipId String? + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + membershipId String } enum SubscriptionStatus {