set your twilio things from settings

This commit is contained in:
m5r 2021-10-16 01:25:13 +02:00
parent 3cc6f35071
commit 2afd3554b3
18 changed files with 242 additions and 483 deletions

View File

@ -1,17 +1,21 @@
import { useSession, useQuery } from "blitz"; import { useSession, useQuery } from "blitz";
import getCurrentUser from "../../users/queries/get-current-user"; import getCurrentUser from "../../users/queries/get-current-user";
import getCurrentPhoneNumber from "../../phone-numbers/queries/get-current-phone-number";
export default function useCurrentUser() { export default function useCurrentUser() {
const session = useSession(); const session = useSession();
const [user] = useQuery(getCurrentUser, null, { enabled: Boolean(session.userId) }); const [user, userQuery] = useQuery(getCurrentUser, null, { enabled: Boolean(session.userId) });
const organization = user?.memberships[0]!.organization; const organization = user?.memberships[0]!.organization;
const hasFilledTwilioCredentials = Boolean(organization?.twilioAccountSid && organization?.twilioAuthToken);
const [phoneNumber] = useQuery(getCurrentPhoneNumber, {}, { enabled: hasFilledTwilioCredentials });
return { return {
user, user,
organization, organization,
hasFilledTwilioCredentials: Boolean( hasFilledTwilioCredentials,
organization && organization.twilioAccountSid && organization.twilioAuthToken, hasPhoneNumber: Boolean(phoneNumber),
),
hasActiveSubscription: organization && organization.subscriptions.length > 0, hasActiveSubscription: organization && organization.subscriptions.length > 0,
refetch: userQuery.refetch,
}; };
} }

View File

@ -1,7 +1,6 @@
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import dynamic from "next/dynamic";
import type { BlitzPage } from "blitz"; import type { BlitzPage } from "blitz";
import { Routes } from "blitz"; import { Routes, dynamic } from "blitz";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import Layout from "app/core/layouts/layout"; import Layout from "app/core/layouts/layout";
@ -13,7 +12,7 @@ import useCurrentUser from "app/core/hooks/use-current-user";
import PageTitle from "../../core/components/page-title"; import PageTitle from "../../core/components/page-title";
const Messages: BlitzPage = () => { const Messages: BlitzPage = () => {
const { hasFilledTwilioCredentials } = useCurrentUser(); const { hasFilledTwilioCredentials, hasPhoneNumber } = useCurrentUser();
const { subscription, subscribe } = useNotifications(); const { subscription, subscribe } = useNotifications();
const setIsOpen = useAtom(bottomSheetOpenAtom)[1]; const setIsOpen = useAtom(bottomSheetOpenAtom)[1];
@ -23,7 +22,7 @@ const Messages: BlitzPage = () => {
} }
}, [subscribe, subscription]); }, [subscribe, subscription]);
if (!hasFilledTwilioCredentials) { if (!hasFilledTwilioCredentials || !hasPhoneNumber) {
return ( return (
<> <>
<MissingTwilioCredentials /> <MissingTwilioCredentials />

View File

@ -1,104 +0,0 @@
import type { FunctionComponent } from "react";
import { Link } from "blitz";
import { IoCheckmark } from "react-icons/io5";
import clsx from "clsx";
type StepLink = {
href: string;
label: string;
};
type Props = {
currentStep: 1 | 2 | 3;
previous?: StepLink;
next?: StepLink;
};
const steps = ["Welcome", "Twilio Credentials", "Pick a plan"] as const;
const OnboardingLayout: FunctionComponent<Props> = ({ children, currentStep, previous, next }) => {
return (
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
<div className="min-h-screen text-center block p-0">
{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block align-middle h-screen">&#8203;</span>
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">
{steps[currentStep - 1]}
</h3>
<section className="px-6 pt-6 pb-12">{children}</section>
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
{next ? (
<Link href={next.href}>
<a className="max-w-[240px] text-center mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
{next.label}
</a>
</Link>
) : null}
{previous ? (
<Link href={previous.href}>
<a className="max-w-[240px] text-center mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
{previous.label}
</a>
</Link>
) : null}
<ol className="flex items-center">
{steps.map((step, stepIdx) => {
const isComplete = currentStep > stepIdx + 1;
const isCurrent = stepIdx + 1 === currentStep;
return (
<li
key={step}
className={clsx(
stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "",
"relative",
)}
>
{isComplete ? (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-primary-600" />
</div>
<a className="relative w-8 h-8 flex items-center justify-center bg-primary-600 rounded-full">
<IoCheckmark className="w-5 h-5 text-white" />
<span className="sr-only">{step}</span>
</a>
</>
) : isCurrent ? (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-gray-200" />
</div>
<a className="relative w-8 h-8 flex items-center justify-center bg-white border-2 border-primary-600 rounded-full">
<span className="h-2.5 w-2.5 bg-primary-600 rounded-full" />
<span className="sr-only">{step}</span>
</a>
</>
) : (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-gray-200" />
</div>
<a className="group relative w-8 h-8 flex items-center justify-center bg-white border-2 border-gray-300 rounded-full">
<span className="h-2.5 w-2.5 bg-transparent rounded-full" />
<span className="sr-only">{step}</span>
</a>
</>
)}
</li>
);
})}
</ol>
</nav>
</div>
</div>
</div>
);
};
export default OnboardingLayout;

View File

@ -1,54 +0,0 @@
import type { BlitzPage, GetServerSideProps } from "blitz";
import { getSession, Routes } from "blitz";
import OnboardingLayout from "../../components/onboarding-layout";
import useCurrentUser from "../../../core/hooks/use-current-user";
import db from "../../../../db";
const StepOne: BlitzPage = () => {
useCurrentUser(); // preload for step two
return (
<div className="flex flex-col space-y-4 items-center">
<h2>Welcome to Shellphone</h2>
<span className="text-center">
We&#39;ll help you connect your Twilio phone number to our service and set up your virtual phone!
</span>
</div>
);
};
StepOne.getLayout = (page) => (
<OnboardingLayout currentStep={1} next={{ href: Routes.StepTwo().pathname, label: "Connect Twilio to Shellphone" }}>
{page}
</OnboardingLayout>
);
StepOne.authenticate = { redirectTo: Routes.SignIn() };
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const session = await getSession(req, res);
if (!session.userId) {
await session.$revoke();
return {
redirect: {
destination: Routes.LandingPage().pathname,
permanent: false,
},
};
}
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
if (phoneNumber) {
return {
redirect: {
destination: Routes.Messages().pathname,
permanent: false,
},
};
}
return { props: {} };
};
export default StepOne;

View File

@ -1,145 +0,0 @@
import type { BlitzPage, GetServerSideProps } from "blitz";
import { Routes, getSession, useRouter, useMutation } from "blitz";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import clsx from "clsx";
import twilio from "twilio";
import db from "../../../../db";
import OnboardingLayout from "../../components/onboarding-layout";
import setPhoneNumber from "../../mutations/set-phone-number";
type PhoneNumber = {
phoneNumber: string;
sid: string;
};
type Props = {
availablePhoneNumbers: PhoneNumber[];
};
type Form = {
phoneNumberSid: string;
};
const StepThree: BlitzPage<Props> = ({ availablePhoneNumbers }) => {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
const router = useRouter();
const [setPhoneNumberMutation] = useMutation(setPhoneNumber);
useEffect(() => {
if (availablePhoneNumbers[0]) {
setValue("phoneNumberSid", availablePhoneNumbers[0].sid);
}
});
const onSubmit = handleSubmit(async ({ phoneNumberSid }) => {
if (isSubmitting) {
return;
}
await setPhoneNumberMutation({ phoneNumberSid });
await router.push(Routes.Messages());
});
return (
<div className="flex flex-col space-y-4 items-center">
<form onSubmit={onSubmit}>
<label htmlFor="phoneNumberSid" className="block text-sm font-medium text-gray-700">
Phone number
</label>
<select
id="phoneNumberSid"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
{...register("phoneNumberSid")}
>
{availablePhoneNumbers.map(({ sid, phoneNumber }) => (
<option value={sid} key={sid}>
{phoneNumber}
</option>
))}
</select>
<button
type="submit"
className={clsx(
"max-w-[240px] mt-6 mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed",
)}
>
Save
</button>
</form>
</div>
);
};
StepThree.getLayout = (page) => (
<OnboardingLayout currentStep={3} previous={{ href: Routes.StepTwo().pathname, label: "Back" }}>
{page}
</OnboardingLayout>
);
StepThree.authenticate = { redirectTo: Routes.SignIn() };
export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }) => {
const session = await getSession(req, res);
if (!session.userId) {
await session.$revoke();
return {
redirect: {
destination: Routes.LandingPage().pathname,
permanent: false,
},
};
}
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
if (phoneNumber) {
return {
redirect: {
destination: Routes.Messages().pathname,
permanent: false,
},
};
}
const organization = await db.organization.findFirst({ where: { id: session.orgId } });
if (!organization) {
return {
redirect: {
destination: Routes.StepOne().pathname,
permanent: false,
},
};
}
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
return {
redirect: {
destination: Routes.StepTwo().pathname,
permanent: false,
},
};
}
const incomingPhoneNumbers = await twilio(
organization.twilioAccountSid,
organization.twilioAuthToken,
).incomingPhoneNumbers.list();
const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }));
return {
props: {
availablePhoneNumbers: phoneNumbers,
},
};
};
export default StepThree;

View File

@ -1,154 +0,0 @@
import type { FunctionComponent } from "react";
import { Suspense, useEffect, useState } from "react";
import type { BlitzPage, GetServerSideProps } from "blitz";
import { getSession, Routes, useMutation, useRouter } from "blitz";
import clsx from "clsx";
import { useForm } from "react-hook-form";
import { IoHelpCircle } from "react-icons/io5";
import db from "db";
import setTwilioApiFields from "../../mutations/set-twilio-api-fields";
import OnboardingLayout from "../../components/onboarding-layout";
import HelpModal from "../../components/help-modal";
import useCurrentUser from "../../../core/hooks/use-current-user";
type Form = {
twilioAccountSid: string;
twilioAuthToken: string;
};
const StepTwo: BlitzPage = () => {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
const router = useRouter();
const { organization } = useCurrentUser();
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields);
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
useEffect(() => {
setValue("twilioAuthToken", organization?.twilioAuthToken ?? "");
setValue("twilioAccountSid", organization?.twilioAccountSid ?? "");
}, [setValue, organization?.twilioAuthToken, organization?.twilioAccountSid]);
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
if (isSubmitting) {
return;
}
await setTwilioApiFieldsMutation({
twilioAccountSid,
twilioAuthToken,
});
await router.push(Routes.StepThree());
});
return (
<>
<div className="flex flex-col space-y-4 items-center relative">
<button onClick={() => setIsHelpModalOpen(true)} className="absolute top-0 right-0">
<IoHelpCircle className="w-6 h-6 text-primary-700" />
</button>
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<article>
Shellphone needs some informations about your Twilio account to securely use your phone numbers.
</article>
<div className="w-full">
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
Twilio Account SID
</label>
<input
type="text"
id="twilioAccountSid"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAccountSid", { required: true })}
/>
</div>
<div className="w-full">
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
Twilio Auth Token
</label>
<input
type="text"
id="twilioAuthToken"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAuthToken", { required: true })}
/>
</div>
<button
type="submit"
className={clsx(
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed",
)}
>
Save
</button>
</form>
</div>
<HelpModal closeModal={() => setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} />
</>
);
};
StepTwo.getLayout = (page) => {
return (
<Suspense fallback="Silence, ca pousse">
<StepTwoLayout>{page}</StepTwoLayout>
</Suspense>
);
};
const StepTwoLayout: FunctionComponent = ({ children }) => {
const { organization } = useCurrentUser();
const initialAuthToken = organization?.twilioAuthToken ?? "";
const initialAccountSid = organization?.twilioAccountSid ?? "";
const hasTwilioCredentials = initialAccountSid.length > 0 && initialAuthToken.length > 0;
return (
<OnboardingLayout
currentStep={2}
next={hasTwilioCredentials ? { href: Routes.StepThree().pathname, label: "Next" } : undefined}
previous={{ href: Routes.StepOne().pathname, label: "Back" }}
>
{children}
</OnboardingLayout>
);
};
StepTwo.authenticate = { redirectTo: Routes.SignIn() };
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const session = await getSession(req, res);
if (!session.userId) {
await session.$revoke();
return {
redirect: {
destination: Routes.LandingPage().pathname,
permanent: false,
},
};
}
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
if (phoneNumber) {
return {
redirect: {
destination: Routes.Messages().pathname,
permanent: false,
},
};
}
return { props: {} };
};
export default StepTwo;

View File

@ -33,14 +33,14 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
<button <button
ref={openSettingsButtonRef} ref={openSettingsButtonRef}
type="button" type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
onClick={() => router.push(Routes.PhoneSettings())} onClick={() => router.push(Routes.PhoneSettings())}
> >
Take me there Take me there
</button> </button>
<button <button
type="button" type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm" className="md:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
onClick={closeModal} onClick={closeModal}
> >
I got it, thanks! I got it, thanks!

View File

@ -9,9 +9,9 @@ import useCurrentUser from "app/core/hooks/use-current-user";
import PageTitle from "../../core/components/page-title"; import PageTitle from "../../core/components/page-title";
const PhoneCalls: BlitzPage = () => { const PhoneCalls: BlitzPage = () => {
const { hasFilledTwilioCredentials } = useCurrentUser(); const { hasFilledTwilioCredentials, hasPhoneNumber } = useCurrentUser();
if (!hasFilledTwilioCredentials) { if (!hasFilledTwilioCredentials || !hasPhoneNumber) {
return ( return (
<> <>
<MissingTwilioCredentials /> <MissingTwilioCredentials />

View File

@ -15,7 +15,7 @@ import useCurrentUser from "app/core/hooks/use-current-user";
import KeypadErrorModal from "../components/keypad-error-modal"; import KeypadErrorModal from "../components/keypad-error-modal";
const KeypadPage: BlitzPage = () => { const KeypadPage: BlitzPage = () => {
const { hasFilledTwilioCredentials, hasActiveSubscription } = useCurrentUser(); const { hasFilledTwilioCredentials, hasPhoneNumber, hasActiveSubscription } = useCurrentUser();
const router = useRouter(); const router = useRouter();
const [isKeypadErrorModalOpen, setIsKeypadErrorModalOpen] = useState(false); const [isKeypadErrorModalOpen, setIsKeypadErrorModalOpen] = useState(false);
const [phoneCalls] = usePhoneCalls(); const [phoneCalls] = usePhoneCalls();
@ -85,7 +85,7 @@ const KeypadPage: BlitzPage = () => {
<Keypad onDigitPressProps={onDigitPressProps} onZeroPressProps={onZeroPressProps}> <Keypad onDigitPressProps={onDigitPressProps} onZeroPressProps={onZeroPressProps}>
<button <button
onClick={async () => { onClick={async () => {
if (!hasFilledTwilioCredentials) { if (!hasFilledTwilioCredentials || !hasPhoneNumber) {
setIsKeypadErrorModalOpen(true); setIsKeypadErrorModalOpen(true);
return; return;
} }

View File

@ -2,8 +2,8 @@ import { Queue } from "quirrel/blitz";
import type twilio from "twilio"; import type 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 db from "../../../../db"; import db from "db";
import getTwilioClient, { getTwiMLName, smsUrl, voiceUrl } from "../../../../integrations/twilio"; import getTwilioClient, { getTwiMLName, smsUrl, voiceUrl } from "integrations/twilio";
type Payload = { type Payload = {
organizationId: string; organizationId: string;

View File

@ -9,7 +9,7 @@ import type { Metadata } from "integrations/paddle";
import { translateSubscriptionStatus } from "integrations/paddle"; import { translateSubscriptionStatus } from "integrations/paddle";
import fetchMessagesQueue from "../../../messages/api/queue/fetch-messages"; import fetchMessagesQueue from "../../../messages/api/queue/fetch-messages";
import fetchCallsQueue from "../../../phone-calls/api/queue/fetch-calls"; import fetchCallsQueue from "../../../phone-calls/api/queue/fetch-calls";
import setTwilioWebhooks from "../../../onboarding/api/queue/set-twilio-webhooks"; import setTwilioWebhooks from "./set-twilio-webhooks";
const logger = appLogger.child({ queue: "subscription-created" }); const logger = appLogger.child({ queue: "subscription-created" });

View File

@ -15,7 +15,7 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
<div className="md:flex md:items-start"> <div className="md:flex md:items-start">
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left"> <div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
<ModalTitle>Need help finding your Twilio credentials?</ModalTitle> <ModalTitle>Need help finding your Twilio credentials?</ModalTitle>
<div className="mt-2 text-sm text-gray-500"> <div className="mt-6 space-y-3 text-gray-500">
<p> <p>
You can check out our{" "} You can check out our{" "}
<a className="underline" href="https://docs.shellphone.app/guide/getting-started"> <a className="underline" href="https://docs.shellphone.app/guide/getting-started">
@ -44,10 +44,10 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
<button <button
ref={modalCloseButtonRef} ref={modalCloseButtonRef}
type="button" type="button"
className="transition-colors duration-150 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto md:text-sm" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
onClick={closeModal} onClick={closeModal}
> >
I got it, thanks! Noted, thanks the help!
</button> </button>
</div> </div>
</Modal> </Modal>

View File

@ -0,0 +1,76 @@
import { useEffect } from "react";
import { useMutation, useQuery } from "blitz";
import { useForm } from "react-hook-form";
import setPhoneNumber from "../../mutations/set-phone-number";
import getAvailablePhoneNumbers from "../../queries/get-available-phone-numbers";
import useCurrentUser from "app/core/hooks/use-current-user";
import useUserPhoneNumber from "app/core/hooks/use-current-phone-number";
import Button from "../button";
import SettingsSection from "../settings-section";
type Form = {
phoneNumberSid: string;
};
export default function PhoneNumberForm() {
const { hasFilledTwilioCredentials } = useCurrentUser();
const currentPhoneNumber = useUserPhoneNumber();
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
const [setPhoneNumberMutation] = useMutation(setPhoneNumber);
const [availablePhoneNumbers] = useQuery(getAvailablePhoneNumbers, {}, { enabled: hasFilledTwilioCredentials });
useEffect(() => {
if (currentPhoneNumber) {
setValue("phoneNumberSid", currentPhoneNumber.id);
}
}, [currentPhoneNumber]);
const onSubmit = handleSubmit(async ({ phoneNumberSid }) => {
if (isSubmitting) {
return;
}
await setPhoneNumberMutation({ phoneNumberSid });
});
if (!hasFilledTwilioCredentials) {
return null;
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<SettingsSection
className="relative"
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
<label htmlFor="phoneNumberSid" className="block text-sm font-medium text-gray-700">
Phone number
</label>
<select
id="phoneNumberSid"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
{...register("phoneNumberSid")}
>
<option value="none" />
{availablePhoneNumbers?.map(({ sid, phoneNumber }) => (
<option value={sid} key={sid}>
{phoneNumber}
</option>
))}
</select>
</SettingsSection>
</form>
);
}

View File

@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import { IoHelpCircle } from "react-icons/io5";
import setTwilioApiFields from "../../mutations/set-twilio-api-fields";
import useCurrentUser from "app/core/hooks/use-current-user";
import HelpModal from "./help-modal";
import Button from "../button";
import SettingsSection from "../settings-section";
type Form = {
twilioAccountSid: string;
twilioAuthToken: string;
};
export default function TwilioApiForm() {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>();
const { organization, refetch } = useCurrentUser();
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields);
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
useEffect(() => {
setValue("twilioAuthToken", organization?.twilioAuthToken ?? "");
setValue("twilioAccountSid", organization?.twilioAccountSid ?? "");
}, [setValue, organization?.twilioAuthToken, organization?.twilioAccountSid]);
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
if (isSubmitting) {
return;
}
await setTwilioApiFieldsMutation({ twilioAccountSid, twilioAuthToken });
await refetch();
});
return (
<>
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<SettingsSection
className="relative"
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
}
>
<button onClick={() => setIsHelpModalOpen(true)} className="absolute top-2 right-2">
<IoHelpCircle className="w-6 h-6 text-primary-700" />
</button>
<article>
Shellphone needs some informations about your Twilio account to securely use your phone numbers.
</article>
<div className="w-full">
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
Twilio Account SID
</label>
<input
type="text"
id="twilioAccountSid"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAccountSid", { required: false })}
/>
</div>
<div className="w-full">
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
Twilio Auth Token
</label>
<input
type="text"
id="twilioAuthToken"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAuthToken", { required: false })}
/>
</div>
</SettingsSection>
</form>
<HelpModal closeModal={() => setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} />
</>
);
}

View File

@ -2,8 +2,8 @@ import { resolver } from "blitz";
import { z } from "zod"; import { z } from "zod";
import twilio from "twilio"; import twilio from "twilio";
import db from "../../../db"; import db from "db";
import getCurrentUser from "../../users/queries/get-current-user"; import getCurrentUser from "app/users/queries/get-current-user";
import setTwilioWebhooks from "../api/queue/set-twilio-webhooks"; import setTwilioWebhooks from "../api/queue/set-twilio-webhooks";
const Body = z.object({ const Body = z.object({

View File

@ -1,8 +1,8 @@
import { resolver } from "blitz"; import { resolver } from "blitz";
import { z } from "zod"; import { z } from "zod";
import db from "../../../db"; import db from "db";
import getCurrentUser from "../../users/queries/get-current-user"; import getCurrentUser from "app/users/queries/get-current-user";
const Body = z.object({ const Body = z.object({
twilioAccountSid: z.string(), twilioAccountSid: z.string(),
@ -26,5 +26,17 @@ export default resolver.pipe(
twilioAuthToken: twilioAuthToken, twilioAuthToken: twilioAuthToken,
}, },
}); });
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId } });
if (phoneNumber) {
await db.phoneNumber.delete({
where: {
organizationId_id: {
organizationId,
id: phoneNumber.id,
},
},
});
}
}, },
); );

View File

@ -1,12 +1,26 @@
import { Suspense } from "react";
import type { BlitzPage } from "blitz"; import type { BlitzPage } from "blitz";
import { Routes } from "blitz"; import { Routes, dynamic } from "blitz";
import SettingsLayout from "../../components/settings-layout"; import SettingsLayout from "../../components/settings-layout";
import PhoneNumberForm from "../../components/phone/phone-number-form";
const PhoneSettings: BlitzPage = () => { const PhoneSettings: BlitzPage = () => {
return <div>Coming soon</div>; return (
<div className="flex flex-col space-y-6">
<Suspense fallback="Loading...">
<TwilioApiForm />
<PhoneNumberForm />
</Suspense>
</div>
);
}; };
const TwilioApiForm = dynamic(() => import("../../components/phone/twilio-api-form"), {
ssr: false,
loading: () => null,
});
PhoneSettings.getLayout = (page) => <SettingsLayout>{page}</SettingsLayout>; PhoneSettings.getLayout = (page) => <SettingsLayout>{page}</SettingsLayout>;
PhoneSettings.authenticate = { redirectTo: Routes.SignIn() }; PhoneSettings.authenticate = { redirectTo: Routes.SignIn() };

View File

@ -0,0 +1,21 @@
import { NotFoundError, resolver } from "blitz";
import db from "db";
import twilio from "twilio";
export default resolver.pipe(resolver.authorize(), async (_ = null, { session }) => {
if (!session.orgId) {
throw new NotFoundError();
}
const organization = await db.organization.findFirst({ where: { id: session.orgId } });
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken) {
throw new NotFoundError();
}
const incomingPhoneNumbers = await twilio(
organization.twilioAccountSid,
organization.twilioAuthToken,
).incomingPhoneNumbers.list();
return incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }));
});