replace twilio connect with account sid/auth token form

This commit is contained in:
m5r 2022-06-09 00:33:19 +02:00
parent c047e169f2
commit e8ba6a63ab
19 changed files with 437 additions and 208 deletions

View File

@ -8,10 +8,15 @@ import getTwilioClient, { translateMessageDirection, translateMessageStatus } fr
export type NewMessageActionData = {}; export type NewMessageActionData = {};
const action: ActionFunction = async ({ params, request }) => { 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) { if (!twilioAccount) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
const recipient = decodeURIComponent(params.recipient ?? ""); const recipient = decodeURIComponent(params.recipient ?? "");
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
const twilioClient = getTwilioClient(twilioAccount); const twilioClient = getTwilioClient(twilioAccount);

View File

@ -6,7 +6,7 @@ import { Direction } from "@prisma/client";
import NewMessageArea from "./new-message-area"; import NewMessageArea from "./new-message-area";
import { formatDate, formatTime } from "~/features/core/helpers/date-formatter"; 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"; import useSession from "~/features/core/hooks/use-session";
export default function Conversation() { export default function Conversation() {
@ -24,6 +24,7 @@ export default function Conversation() {
phoneNumberId: phoneNumber!.id, phoneNumberId: phoneNumber!.id,
from: phoneNumber!.number, from: phoneNumber!.number,
to: recipient, to: recipient,
recipient,
sentAt: new Date(), sentAt: new Date(),
direction: Direction.Outbound, direction: Direction.Outbound,

View File

@ -10,7 +10,12 @@ import getTwilioClient from "~/utils/twilio.server";
export type TwilioTokenLoaderData = string; export type TwilioTokenLoaderData = string;
const loader: LoaderFunction = async ({ request }) => { 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) { if (!twilioAccount || !twilioAccount.twimlAppSid) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
@ -36,12 +41,12 @@ const loader: LoaderFunction = async ({ request }) => {
apiKeySid = apiKey.sid; apiKeySid = apiKey.sid;
apiKeySecret = apiKey.secret; apiKeySecret = apiKey.secret;
await db.twilioAccount.update({ await db.twilioAccount.update({
where: { subAccountSid: twilioAccount.subAccountSid }, where: { accountSid: twilioAccount.accountSid },
data: { apiKeySid: apiKey.sid, apiKeySecret: encrypt(apiKey.secret) }, 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}`, identity: `${organization.id}__${user.id}`,
ttl: 3600, ttl: 3600,
}); });

View File

@ -13,7 +13,7 @@ import deleteUserQueue from "~/queues/delete-user-data.server";
const action: ActionFunction = async ({ request }) => { const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
if (!formData._action) { if (!formData._action) {
const errorMessage = "POST /settings without any _action"; const errorMessage = "POST /settings/phone without any _action";
logger.error(errorMessage); logger.error(errorMessage);
return badRequest({ errorMessage }); return badRequest({ errorMessage });
} }
@ -26,7 +26,7 @@ const action: ActionFunction = async ({ request }) => {
case "updateUser": case "updateUser":
return updateUser(request, formData); return updateUser(request, formData);
default: 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); logger.error(errorMessage);
return badRequest({ errorMessage }); return badRequest({ errorMessage });
} }

View File

@ -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 { badRequest } from "remix-utils";
import { z } from "zod"; import { z } from "zod";
import type { Prisma } from "@prisma/client";
import db from "~/utils/db.server"; 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 { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
import { commitSession } from "~/utils/session.server"; import { commitSession } from "~/utils/session.server";
import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.server"; import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.server";
import logger from "~/utils/logger.server";
type SetPhoneNumberFailureActionData = { errors: FormError<typeof bodySchema>; submitted?: never }; import { encrypt } from "~/utils/encryption";
type SetPhoneNumberSuccessfulActionData = { errors?: never; submitted: true }; import getTwilioClient from "~/utils/twilio.server";
export type SetPhoneNumberActionData = SetPhoneNumberFailureActionData | SetPhoneNumberSuccessfulActionData; 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 action: ActionFunction = async ({ request }) => {
const { organization } = await requireLoggedIn(request);
const formData = Object.fromEntries(await request.formData()); 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<typeof validations, "setPhoneNumber">;
async function setPhoneNumber(request: Request, formData: unknown) {
const { organization } = await requireLoggedIn(request);
const validation = validate(validations.setPhoneNumber, formData);
if (validation.errors) { if (validation.errors) {
return badRequest<SetPhoneNumberActionData>({ errors: validation.errors }); return badRequest<SetPhoneNumberActionData>({ setPhoneNumber: { errors: validation.errors } });
} }
try { try {
@ -43,19 +70,181 @@ const action: ActionFunction = async ({ request }) => {
const { session } = await refreshSessionData(request); const { session } = await refreshSessionData(request);
return json<SetPhoneNumberActionData>( return json<SetPhoneNumberActionData>(
{ submitted: true }, { setPhoneNumber: { submitted: true } },
{ {
headers: { headers: {
"Set-Cookie": await commitSession(session), "Set-Cookie": await commitSession(session),
}, },
}, },
); );
}
export type SetTwilioCredentialsActionData = FormActionData<typeof validations, "setTwilioCredentials">;
async function setTwilioCredentials(request: Request, formData: unknown) {
const { organization, twilio } = await requireLoggedIn(request);
const validation = validate(validations.setTwilioCredentials, formData);
if (validation.errors) {
return badRequest<SetTwilioCredentialsActionData>({ 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<SetTwilioCredentialsActionData>(
{
setTwilioCredentials: {
errors: { general: "Invalid Account SID or Auth Token" },
},
},
{
headers: session
? {
"Set-Cookie": await commitSession(session),
}
: {},
},
);
}
const data: Pick<Prisma.TwilioAccountUpsertArgs["create"], "accountSid" | "authToken"> = {
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<SetTwilioCredentialsActionData>(
{ 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; export default action;
const bodySchema = z.object({ type Action = "setPhoneNumber" | "setTwilioCredentials" | "refreshPhoneNumbers";
const validations = {
setPhoneNumber: z.object({
phoneNumberSid: z phoneNumberSid: z
.string() .string()
.refine((phoneNumberSid) => phoneNumberSid.startsWith("PN"), "Select a valid phone number"), .refine((phoneNumberSid) => phoneNumberSid.startsWith("PN"), "Select a valid phone number"),
}); }),
setTwilioCredentials: z.object({
twilioAccountSid: z.string(),
twilioAuthToken: z.string(),
}),
} as const;

View File

@ -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 Button from "../button";
import SettingsSection from "../settings-section"; 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 useSession from "~/features/core/hooks/use-session";
import type { PhoneSettingsLoaderData } from "~/features/settings/loaders/phone"; import type { PhoneSettingsLoaderData } from "~/features/settings/loaders/phone";
import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone"; import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone";
import clsx from "clsx";
export default function PhoneNumberForm() { export default function PhoneNumberForm() {
const { twilio } = useSession();
const fetcher = useFetcher();
const transition = useTransition(); const transition = useTransition();
const actionData = useActionData<SetPhoneNumberActionData>(); const actionData = useActionData<SetPhoneNumberActionData>()?.setPhoneNumber;
const { twilioAccount } = useSession();
const availablePhoneNumbers = useLoaderData<PhoneSettingsLoaderData>().phoneNumbers; const availablePhoneNumbers = useLoaderData<PhoneSettingsLoaderData>().phoneNumbers;
const isSubmitting = transition.state === "submitting"; const isSubmitting = transition.state === "submitting";
@ -19,13 +22,24 @@ export default function PhoneNumberForm() {
const topErrorMessage = errors?.general ?? errors?.phoneNumberSid; const topErrorMessage = errors?.general ?? errors?.phoneNumberSid;
const isError = typeof topErrorMessage !== "undefined"; const isError = typeof topErrorMessage !== "undefined";
const currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true); const currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true);
const hasFilledTwilioCredentials = twilioAccount !== null; const hasFilledTwilioCredentials = twilio !== null;
if (!hasFilledTwilioCredentials) { if (!hasFilledTwilioCredentials) {
return null; return null;
} }
return ( return (
<section className="relative">
<button
className={clsx("absolute top-2 right-2 z-10", { "animate-spin": fetcher.submission })}
onClick={() => fetcher.submit({ _action: "refreshPhoneNumbers" }, { method: "post" })}
disabled={!!fetcher.submission}
title="Refresh the list of phone numbers from Twilio"
aria-label="Refresh the list of phone numbers from Twilio"
>
<IoReloadOutline className="w-5 h-5 text-primary-700" aria-hidden="true" />
</button>
<Form method="post" className="flex flex-col gap-6"> <Form method="post" className="flex flex-col gap-6">
<SettingsSection <SettingsSection
className="relative" className="relative"
@ -45,7 +59,11 @@ export default function PhoneNumberForm() {
{isSuccess ? ( {isSuccess ? (
<div className="mb-8"> <div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" /> <Alert
title="Saved successfully"
message="Your changes have been saved."
variant="success"
/>
</div> </div>
) : null} ) : null}
@ -65,8 +83,11 @@ export default function PhoneNumberForm() {
</option> </option>
))} ))}
</select> </select>
<input type="hidden" name="_action" value="setPhoneNumber" />
</SettingsSection> </SettingsSection>
</Form> </Form>
</section>
); );
} }

View File

@ -1,38 +1,82 @@
import { useState } from "react"; import { useState } from "react";
import { Form, useActionData, useLoaderData, useTransition } from "@remix-run/react";
import { IoHelpCircle } from "react-icons/io5"; 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 HelpModal from "./help-modal";
import SettingsSection from "../settings-section"; import SettingsSection from "../settings-section";
import useSession from "~/features/core/hooks/use-session"; 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() { export default function TwilioConnect() {
const { twilioAccount } = useSession(); const { twilio } = useSession();
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
const transition = useTransition();
const actionData = useActionData<SetTwilioCredentialsActionData>()?.setTwilioCredentials;
const { accountSid, authToken } = useLoaderData<PhoneSettingsLoaderData>();
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 ( return (
<> <>
<SettingsSection className="flex flex-col relative"> <Form method="post">
<section> <SettingsSection
className="flex flex-col relative"
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button tabIndex={3} variant="default" type="submit" isDisabled={isSubmitting}>
Save
</Button>
</div>
}
>
<button onClick={() => setIsHelpModalOpen(true)} className="absolute top-2 right-2"> <button onClick={() => setIsHelpModalOpen(true)} className="absolute top-2 right-2">
<IoHelpCircle className="w-6 h-6 text-primary-700" /> <IoHelpCircle className="w-6 h-6 text-primary-700" />
</button> </button>
<article className="mb-6"> <article className="mb-6">
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.
</article> </article>
{twilioAccount === null ? ( {twilio !== null ? (
<a
href="https://www.twilio.com/authorize/CN01675d385a9ee79e6aa58adf54abe3b3"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2 bg-primary-600 hover:bg-primary-700 focus:ring-primary-500"
>
Connect Twilio account
</a>
) : (
<p className="text-green-700"> Your Twilio account is connected to Shellphone.</p> <p className="text-green-700"> Your Twilio account is connected to Shellphone.</p>
)} ) : null}
</section>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
</div>
) : null}
<LabeledTextField
name="twilioAccountSid"
label="Account SID"
type="text"
tabIndex={1}
error={actionData?.errors?.twilioAccountSid}
disabled={isSubmitting}
defaultValue={accountSid}
/>
<LabeledTextField
name="twilioAuthToken"
label="Auth Token"
type="password"
tabIndex={2}
error={actionData?.errors?.twilioAuthToken}
disabled={isSubmitting}
autoComplete="off"
defaultValue={authToken}
/>
<input type="hidden" name="_action" value="setTwilioCredentials" />
</SettingsSection> </SettingsSection>
</Form>
<HelpModal closeModal={() => setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} /> <HelpModal closeModal={() => setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} />
</> </>

View File

@ -4,14 +4,17 @@ import { type PhoneNumber, Prisma } from "@prisma/client";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server"; import { requireLoggedIn } from "~/utils/auth.server";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
import { decrypt } from "~/utils/encryption";
export type PhoneSettingsLoaderData = { export type PhoneSettingsLoaderData = {
accountSid?: string;
authToken?: string;
phoneNumbers: Pick<PhoneNumber, "id" | "number" | "isCurrent">[]; phoneNumbers: Pick<PhoneNumber, "id" | "number" | "isCurrent">[];
}; };
const loader: LoaderFunction = async ({ request }) => { const loader: LoaderFunction = async ({ request }) => {
const { organization, twilioAccount } = await requireLoggedIn(request); const { organization, twilio } = await requireLoggedIn(request);
if (!twilioAccount) { if (!twilio) {
logger.warn("Twilio account is not connected"); logger.warn("Twilio account is not connected");
return json<PhoneSettingsLoaderData>({ phoneNumbers: [] }); return json<PhoneSettingsLoaderData>({ phoneNumbers: [] });
} }
@ -22,6 +25,10 @@ const loader: LoaderFunction = async ({ request }) => {
orderBy: { id: Prisma.SortOrder.desc }, orderBy: { id: Prisma.SortOrder.desc },
}); });
return json<PhoneSettingsLoaderData>({ phoneNumbers }); return json<PhoneSettingsLoaderData>({
accountSid: twilio.accountSid,
authToken: decrypt(twilio.authToken),
phoneNumbers,
});
}; };
export default loader; export default loader;

View File

@ -4,6 +4,7 @@ import insertPhoneCallsQueue from "./insert-phone-calls.server";
import fetchMessagesQueue from "./fetch-messages.server"; import fetchMessagesQueue from "./fetch-messages.server";
import insertMessagesQueue from "./insert-messages.server"; import insertMessagesQueue from "./insert-messages.server";
import setTwilioWebhooksQueue from "./set-twilio-webhooks.server"; import setTwilioWebhooksQueue from "./set-twilio-webhooks.server";
import setTwilioApiKeyQueue from "./set-twilio-api-key.server";
export default [ export default [
deleteUserDataQueue, deleteUserDataQueue,
@ -12,4 +13,5 @@ export default [
fetchMessagesQueue, fetchMessagesQueue,
insertMessagesQueue, insertMessagesQueue,
setTwilioWebhooksQueue, setTwilioWebhooksQueue,
setTwilioApiKeyQueue,
]; ];

View File

@ -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<Payload>("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),
},
});
});

View File

@ -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 type { ApplicationInstance } from "twilio/lib/rest/api/v2010/account/application";
import { Queue } from "~/utils/queue.server"; import { Queue } from "~/utils/queue.server";
import db from "~/utils/db.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 = { type Payload = {
phoneNumberId: string; phoneNumberId: string;
@ -18,7 +19,7 @@ export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
organization: { organization: {
select: { select: {
twilioAccount: { twilioAccount: {
select: { accountSid: true, subAccountSid: true, twimlAppSid: true }, select: { accountSid: true, twimlAppSid: true, authToken: true },
}, },
}, },
}, },
@ -29,7 +30,8 @@ export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
} }
const twilioAccount = phoneNumber.organization.twilioAccount; 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 twimlApp = await getTwimlApplication(twilioClient, twilioAccount.twimlAppSid);
const twimlAppSid = twimlApp.sid; const twimlAppSid = twimlApp.sid;

View File

@ -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),
},
});
};

View File

@ -51,7 +51,7 @@ export const action: ActionFunction = async ({ request }) => {
return new Response(null, { status: 402 }); return new Response(null, { status: 402 });
} }
const encryptedAuthToken = phoneNumber?.organization.twilioAccount?.subAccountAuthToken; const encryptedAuthToken = phoneNumber?.organization.twilioAccount?.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
if ( if (
!phoneNumber || !phoneNumber ||

View File

@ -57,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => {
// if multiple organizations have the same number // if multiple organizations have the same number
// find the organization currently using that phone number // find the organization currently using that phone number
// maybe we shouldn't let that happen by restricting a phone number to one org? // 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) : ""; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
return twilio.validateRequest(authToken, twilioSignature, smsUrl, body); return twilio.validateRequest(authToken, twilioSignature, smsUrl, body);
}); });

View File

@ -9,10 +9,7 @@ import authenticator from "./authenticator.server";
import { AuthenticationError, NotFoundError } from "./errors"; import { AuthenticationError, NotFoundError } from "./errors";
import { commitSession, destroySession, getSession } from "./session.server"; import { commitSession, destroySession, getSession } from "./session.server";
type SessionTwilioAccount = Pick< type SessionTwilioAccount = Pick<TwilioAccount, "accountSid" | "authToken">;
TwilioAccount,
"accountSid" | "subAccountSid" | "subAccountAuthToken" | "apiKeySid" | "apiKeySecret" | "twimlAppSid"
>;
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole; membershipId: string }; type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole; membershipId: string };
type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">; type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">; export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">;
@ -20,7 +17,7 @@ export type SessionData = {
user: SessionUser; user: SessionUser;
organization: SessionOrganization; organization: SessionOrganization;
phoneNumber: SessionPhoneNumber | null; phoneNumber: SessionPhoneNumber | null;
twilioAccount: SessionTwilioAccount | null; twilio: SessionTwilioAccount | null;
}; };
const SP = new SecurePassword(); const SP = new SecurePassword();
@ -62,6 +59,7 @@ export async function login({ form }: FormStrategyVerifyParams): Promise<Session
try { try {
return await buildSessionData(user.id); return await buildSessionData(user.id);
} catch (error: any) { } catch (error: any) {
logger.error(error);
if (error instanceof AuthenticationError) { if (error instanceof AuthenticationError) {
throw error; throw error;
} }
@ -178,14 +176,7 @@ async function buildSessionData(id: string): Promise<SessionData> {
select: { select: {
id: true, id: true,
twilioAccount: { twilioAccount: {
select: { select: { accountSid: true, authToken: true },
accountSid: true,
subAccountSid: true,
subAccountAuthToken: true,
apiKeySid: true,
apiKeySecret: true,
twimlAppSid: true,
},
}, },
}, },
}, },
@ -214,6 +205,6 @@ async function buildSessionData(id: string): Promise<SessionData> {
user: rest, user: rest,
organization, organization,
phoneNumber, phoneNumber,
twilioAccount, twilio: twilioAccount,
}; };
} }

View File

@ -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 { type TwilioAccount, CallStatus, Direction, MessageStatus } from "@prisma/client";
import serverConfig from "~/config/config.server"; import serverConfig from "~/config/config.server";
import { decrypt } from "~/utils/encryption";
export default function getTwilioClient({ export default function getTwilioClient({
accountSid, accountSid,
subAccountSid, authToken,
subAccountAuthToken, }: Pick<TwilioAccount, "accountSid" | "authToken">): twilio.Twilio {
}: Pick<TwilioAccount, "accountSid" | "subAccountSid"> & if (!accountSid || !authToken) {
Partial<Pick<TwilioAccount, "subAccountAuthToken">>): twilio.Twilio {
if (!subAccountSid || !accountSid) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
return twilio(subAccountSid, serverConfig.twilio.authToken, { return twilio(accountSid, decrypt(authToken));
accountSid,
});
} }
export const smsUrl = `https://${serverConfig.app.baseUrl}/webhook/message`; export const smsUrl = `https://${serverConfig.app.baseUrl}/webhook/message`;

View File

@ -32,3 +32,16 @@ export function validate<Data, Schema = z.Schema<Data>["_type"]>(
errors, errors,
}; };
} }
type FormFailureData<Validations extends Record<string, z.Schema>, Action extends keyof Validations> = {
errors: FormError<Validations[Action]>;
submitted?: never;
};
type FormSuccessData = {
errors?: never;
submitted: true;
};
export type FormActionData<Validations extends Record<string, z.Schema>, Action extends keyof Validations> = Record<
Action,
FormSuccessData | FormFailureData<Validations, Action>
>;

View File

@ -21,17 +21,16 @@ CREATE TYPE "CallStatus" AS ENUM ('Queued', 'Ringing', 'InProgress', 'Completed'
-- CreateTable -- CreateTable
CREATE TABLE "TwilioAccount" ( CREATE TABLE "TwilioAccount" (
"subAccountSid" TEXT NOT NULL, "accountSid" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL, "updatedAt" TIMESTAMPTZ(6) NOT NULL,
"subAccountAuthToken" TEXT NOT NULL, "authToken" TEXT NOT NULL,
"accountSid" TEXT NOT NULL,
"twimlAppSid" TEXT, "twimlAppSid" TEXT,
"apiKeySid" TEXT, "apiKeySid" TEXT,
"apiKeySecret" TEXT, "apiKeySecret" TEXT,
"organizationId" TEXT NOT NULL, "organizationId" TEXT NOT NULL,
CONSTRAINT "TwilioAccount_pkey" PRIMARY KEY ("subAccountSid") CONSTRAINT "TwilioAccount_pkey" PRIMARY KEY ("accountSid")
); );
-- CreateTable -- CreateTable

View File

@ -8,11 +8,10 @@ datasource db {
} }
model TwilioAccount { model TwilioAccount {
subAccountSid String @id accountSid String @id
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)
subAccountAuthToken String authToken String
accountSid String
twimlAppSid String? twimlAppSid String?
apiKeySid String? apiKeySid String?
apiKeySecret String? apiKeySecret String?
@ -157,8 +156,8 @@ model NotificationSubscription {
keys_p256dh String keys_p256dh String
keys_auth String keys_auth String
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
membershipId String? membershipId String
} }
enum SubscriptionStatus { enum SubscriptionStatus {