make app usable without account, remove extra stuff

This commit is contained in:
m5r 2023-04-29 18:30:07 +02:00
parent cb35455722
commit 03ae466c66
128 changed files with 617 additions and 14061 deletions

View File

@ -1,12 +1,9 @@
node_modules
.env
/.idea
/cypress/videos
/cypress/screenshots
/coverage
# build artifacts
/.cache
/public/build
/build
/server/index.js
/server/index.js

View File

@ -25,40 +25,3 @@ jobs:
node-version: 16
- run: npm ci
- run: npx tsc
deploy_development:
if: github.ref == 'refs/heads/master'
needs: [lint, typecheck]
name: Deploy development environment
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: superfly/flyctl-actions@master
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
with:
args: "deploy --strategy rolling -c ./fly.dev.toml"
- uses: appleboy/discord-action@master
with:
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
args: "https://dev.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
deploy_production:
if: github.ref == 'refs/heads/production'
needs: [lint, typecheck]
name: Deploy production environment
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: superfly/flyctl-actions@master
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
with:
args: "deploy --strategy rolling"
- uses: appleboy/discord-action@master
with:
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
args: "https://www.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"

4
.gitignore vendored
View File

@ -11,7 +11,3 @@ node_modules
/.idea
.env
/cypress/videos
/cypress/screenshots
/coverage

View File

@ -1 +0,0 @@
export default {};

View File

@ -40,22 +40,6 @@ invariant(
typeof process.env.WEB_PUSH_VAPID_PUBLIC_KEY === "string",
`Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`,
);
invariant(
typeof process.env.MAILCHIMP_API_KEY === "string",
`Please define the "MAILCHIMP_API_KEY" environment variable`,
);
invariant(
typeof process.env.MAILCHIMP_AUDIENCE_ID === "string",
`Please define the "MAILCHIMP_AUDIENCE_ID" environment variable`,
);
invariant(
typeof process.env.DISCORD_WEBHOOK_ID === "string",
`Please define the "DISCORD_WEBHOOK_ID" environment variable`,
);
invariant(
typeof process.env.DISCORD_WEBHOOK_TOKEN === "string",
`Please define the "DISCORD_WEBHOOK_TOKEN" environment variable`,
);
export default {
app: {
@ -66,24 +50,11 @@ export default {
},
aws: {
region: process.env.AWS_REGION,
ses: {
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
fromEmail: process.env.AWS_SES_FROM_EMAIL,
},
s3: {
accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_S3_ACCESS_KEY_SECRET,
},
},
discord: {
webhookId: process.env.DISCORD_WEBHOOK_ID,
webhookToken: process.env.DISCORD_WEBHOOK_TOKEN,
},
mailchimp: {
apiKey: process.env.MAILCHIMP_API_KEY,
audienceId: process.env.MAILCHIMP_AUDIENCE_ID,
},
redis: {
url: process.env.REDIS_URL,
password: process.env.REDIS_PASSWORD,

View File

@ -1,4 +0,0 @@
import { CronJob } from "~/utils/queue.server";
import backup from "~/utils/backup-db.server";
export default CronJob("daily db backup", () => backup("daily"), "0 0 * * *");

View File

@ -1,3 +0,0 @@
import registerPurgeExpiredSession from "./purge-expired-sessions";
export default [registerPurgeExpiredSession];

View File

@ -1,4 +0,0 @@
import { CronJob } from "~/utils/queue.server";
import backup from "~/utils/backup-db.server";
export default CronJob("monthly db backup", () => backup("monthly"), "0 0 1 * *");

View File

@ -1,14 +0,0 @@
import db from "~/utils/db.server";
import { CronJob } from "~/utils/queue.server";
export default CronJob(
"purge expired sessions",
async () => {
await db.session.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
},
"0 0 * * *",
);

View File

@ -1,4 +0,0 @@
import { CronJob } from "~/utils/queue.server";
import backup from "~/utils/backup-db.server";
export default CronJob("weekly db backup", () => backup("weekly"), "0 0 * * 0");

View File

@ -1,63 +0,0 @@
import { type ActionFunction, json } from "@remix-run/node";
import { type User, TokenType } from "@prisma/client";
import db from "~/utils/db.server";
import { type FormError, validate } from "~/utils/validation.server";
import { sendForgotPasswordEmail } from "~/mailers/forgot-password-mailer.server";
import { generateToken, hashToken } from "~/utils/token.server";
import { ForgotPassword } from "../validations";
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
type ForgotPasswordFailureActionData = { errors: FormError<typeof ForgotPassword>; submitted?: never };
type ForgotPasswordSuccessfulActionData = { errors?: never; submitted: true };
export type ForgotPasswordActionData = ForgotPasswordFailureActionData | ForgotPasswordSuccessfulActionData;
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
const validation = validate(ForgotPassword, formData);
if (validation.errors) {
return json<ForgotPasswordFailureActionData>({ errors: validation.errors });
}
const { email } = validation.data;
const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
// always wait the same amount of time so attackers can't tell the difference whether a user is found
await Promise.all([updatePassword(user), new Promise((resolve) => setTimeout(resolve, 750))]);
// return the same result whether a password reset email was sent or not
return json<ForgotPasswordSuccessfulActionData>({ submitted: true });
};
export default action;
async function updatePassword(user: User | null) {
const membership = await db.membership.findFirst({ where: { userId: user?.id } });
if (!user || !membership) {
return;
}
const token = generateToken();
const hashedToken = hashToken(token);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS);
await db.token.deleteMany({ where: { type: TokenType.RESET_PASSWORD, userId: user.id } });
await db.token.create({
data: {
user: { connect: { id: user.id } },
membership: { connect: { id: membership.id } },
type: TokenType.RESET_PASSWORD,
expiresAt,
hashedToken,
sentTo: user.email,
},
});
await sendForgotPasswordEmail({
to: user.email,
token,
userName: user.fullName,
});
}

View File

@ -1,59 +0,0 @@
import { type ActionFunction, json } from "@remix-run/node";
import { GlobalRole, MembershipRole } from "@prisma/client";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { authenticate, hashPassword } from "~/utils/auth.server";
import { type FormError, validate } from "~/utils/validation.server";
import { Register } from "../validations";
export type RegisterActionData = {
errors: FormError<typeof Register>;
};
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
const validation = validate(Register, formData);
if (validation.errors) {
return json<RegisterActionData>({ errors: validation.errors });
}
const { fullName, email, password } = validation.data;
const hashedPassword = await hashPassword(password.trim());
try {
await db.user.create({
data: {
fullName: fullName.trim(),
email: email.toLowerCase().trim(),
hashedPassword,
role: GlobalRole.CUSTOMER,
memberships: {
create: {
role: MembershipRole.OWNER,
organization: {
create: {}
},
},
},
},
});
} catch (error: any) {
logger.error(error);
if (error.code === "P2002") {
if (error.meta.target[0] === "email") {
return json<RegisterActionData>({
errors: { general: "An account with this email address already exists" },
});
}
}
return json<RegisterActionData>({
errors: { general: `An unexpected error happened${error.code ? `\nCode: ${error.code}` : ""}` },
});
}
return authenticate({ email, password, request, failureRedirect: "/register" });
};
export default action;

View File

@ -1,56 +0,0 @@
import { type ActionFunction, json, redirect } from "@remix-run/node";
import { TokenType } from "@prisma/client";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { type FormError, validate } from "~/utils/validation.server";
import { authenticate, hashPassword } from "~/utils/auth.server";
import { ResetPasswordError } from "~/utils/errors";
import { hashToken } from "~/utils/token.server";
import { ResetPassword } from "../validations";
export type ResetPasswordActionData = { errors: FormError<typeof ResetPassword> };
const action: ActionFunction = async ({ request }) => {
const searchParams = new URL(request.url).searchParams;
const token = searchParams.get("token");
if (!token) {
return redirect("/forgot-password");
}
const formData = Object.fromEntries(await request.formData());
const validation = validate(ResetPassword, { ...formData, token });
if (validation.errors) {
return json<ResetPasswordActionData>({ errors: validation.errors });
}
const hashedToken = hashToken(token);
const savedToken = await db.token.findFirst({
where: { hashedToken, type: TokenType.RESET_PASSWORD },
include: { user: true },
});
if (!savedToken) {
logger.warn(`No token found with hashedToken=${hashedToken}`);
throw new ResetPasswordError();
}
await db.token.delete({ where: { id: savedToken.id } });
if (savedToken.expiresAt < new Date()) {
logger.warn(`Token with hashedToken=${hashedToken} is expired since ${savedToken.expiresAt.toUTCString()}`);
throw new ResetPasswordError();
}
const password = validation.data.password.trim();
const hashedPassword = await hashPassword(password);
const { email } = await db.user.update({
where: { id: savedToken.userId },
data: { hashedPassword },
});
await db.session.deleteMany({ where: { userId: savedToken.userId } });
return authenticate({ email, password, request });
};
export default action;

View File

@ -1,23 +0,0 @@
import { type ActionFunction, json } from "@remix-run/node";
import { SignIn } from "../validations";
import { type FormError, validate } from "~/utils/validation.server";
import { authenticate } from "~/utils/auth.server";
export type SignInActionData = { errors: FormError<typeof SignIn> };
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.clone().formData());
const validation = validate(SignIn, formData);
if (validation.errors) {
return json<SignInActionData>({ errors: validation.errors });
}
const searchParams = new URL(request.url).searchParams;
const redirectTo = searchParams.get("redirectTo");
const successRedirect = redirectTo ? decodeURIComponent(redirectTo) : null;
const { email, password } = validation.data;
return authenticate({ email, password, request, successRedirect });
};
export default action;

View File

@ -1,11 +0,0 @@
import type { LoaderFunction } from "@remix-run/node";
import { requireLoggedOut } from "~/utils/auth.server";
const loader: LoaderFunction = async ({ request }) => {
await requireLoggedOut(request);
return null;
};
export default loader;

View File

@ -1,25 +0,0 @@
import { type LoaderFunction, json } from "@remix-run/node";
import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server";
import { commitSession, getSession } from "~/utils/session.server";
export type RegisterLoaderData = { errors: { general: string } } | null;
const loader: LoaderFunction = async ({ request }) => {
const session = await getSession(request);
const errorMessage = getErrorMessage(session);
if (errorMessage) {
return json<RegisterLoaderData>(
{ errors: { general: errorMessage } },
{
headers: { "Set-Cookie": await commitSession(session) },
},
);
}
await requireLoggedOut(request);
return null;
};
export default loader;

View File

@ -1,23 +0,0 @@
import { type LoaderFunction, redirect } from "@remix-run/node";
import { requireLoggedOut } from "~/utils/auth.server";
import { commitSession, getSession } from "~/utils/session.server";
const loader: LoaderFunction = async ({ request }) => {
const session = await getSession(request);
const searchParams = new URL(request.url).searchParams;
const token = searchParams.get("token");
if (!token) {
return redirect("/forgot-password");
}
await requireLoggedOut(request);
return new Response(null, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
};
export default loader;

View File

@ -1,25 +0,0 @@
import { type LoaderFunction, json } from "@remix-run/node";
import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server";
import { commitSession, getSession } from "~/utils/session.server";
export type SignInLoaderData = { errors: { general: string } } | null;
const loader: LoaderFunction = async ({ request }) => {
const session = await getSession(request);
const errorMessage = getErrorMessage(session);
if (errorMessage) {
return json<SignInLoaderData>(
{ errors: { general: errorMessage } },
{
headers: { "Set-Cookie": await commitSession(session) },
},
);
}
await requireLoggedOut(request);
return null;
};
export default loader;

View File

@ -1,49 +0,0 @@
import { Form, useActionData, useTransition } from "@remix-run/react";
import type { ForgotPasswordActionData } from "../actions/forgot-password";
import LabeledTextField from "~/features/core/components/labeled-text-field";
import Button from "~/features/core/components/button";
export default function ForgotPasswordPage() {
const actionData = useActionData<ForgotPasswordActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Forgot your password?
</h2>
</header>
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
{actionData?.submitted ? (
<p className="text-center">
If your email is in our system, you will receive instructions to reset your password shortly.
</p>
) : (
<>
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={1}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={2}
className="w-full flex justify-center py-2 px-4 text-base font-medium"
>
Send reset password link
</Button>
</>
)}
</Form>
</section>
);
}

View File

@ -1,75 +0,0 @@
import { Form, Link, useActionData, useLoaderData, useTransition } from "@remix-run/react";
import type { RegisterActionData } from "../actions/register";
import type { RegisterLoaderData } from "../loaders/register";
import LabeledTextField from "~/features/core/components/labeled-text-field";
import Alert from "~/features/core/components/alert";
import Button from "~/features/core/components/button";
export default function RegisterPage() {
const loaderData = useLoaderData<RegisterLoaderData>();
const actionData = useActionData<RegisterActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
const topErrorMessage = loaderData?.errors?.general || actionData?.errors?.general;
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
<Link
to="/sign-in"
prefetch="intent"
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
>
Already have an account?
</Link>
</p>
</header>
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
{topErrorMessage ? (
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
<Alert title="Oops, there was an issue" message={topErrorMessage!} variant="error" />
</div>
) : null}
<LabeledTextField
name="fullName"
type="text"
label="Full name"
disabled={isSubmitting}
error={actionData?.errors?.fullName}
tabIndex={1}
/>
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={2}
/>
<LabeledTextField
name="password"
type="password"
label="Password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={3}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={4}
>
Register
</Button>
</Form>
</section>
);
}

View File

@ -1,55 +0,0 @@
import { Form, useActionData, useSearchParams, useTransition } from "@remix-run/react";
import clsx from "clsx";
import type { ResetPasswordActionData } from "../actions/reset-password";
import LabeledTextField from "~/features/core/components/labeled-text-field";
export default function ForgotPasswordPage() {
const [searchParams] = useSearchParams();
const actionData = useActionData<ResetPasswordActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Set a new password</h2>
</header>
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
<LabeledTextField
name="password"
label="New Password"
type="password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={1}
/>
<LabeledTextField
name="passwordConfirmation"
label="Confirm New Password"
type="password"
disabled={isSubmitting}
error={actionData?.errors?.passwordConfirmation}
tabIndex={2}
/>
<button
type="submit"
disabled={transition.state === "submitting"}
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": isSubmitting,
"bg-primary-600 hover:bg-primary-700": !isSubmitting,
},
)}
tabIndex={3}
>
Reset password
</button>
</Form>
</section>
);
}

View File

@ -1,71 +0,0 @@
import { Form, Link, useActionData, useLoaderData, useSearchParams, useTransition } from "@remix-run/react";
import type { SignInActionData } from "../actions/sign-in";
import type { SignInLoaderData } from "../loaders/sign-in";
import LabeledTextField from "~/features/core/components/labeled-text-field";
import Alert from "~/features/core/components/alert";
import Button from "~/features/core/components/button";
export default function SignInPage() {
const [searchParams] = useSearchParams();
const loaderData = useLoaderData<SignInLoaderData>();
const actionData = useActionData<SignInActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Welcome back!</h2>
{/*<p className="mt-2 text-center text-sm leading-5 text-gray-600">
Need an account?&nbsp;
<Link
to="/register"
prefetch="intent"
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
>
Create yours for free
</Link>
</p>*/}
</header>
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
{loaderData?.errors ? (
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
<Alert title="Oops, there was an issue" message={loaderData.errors.general} variant="error" />
</div>
) : null}
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={1}
/>
<LabeledTextField
name="password"
type="password"
label="Password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={2}
sideLabel={
<Link
to="/forgot-password"
prefetch="intent"
className="font-medium text-primary-600 hover:text-primary-500 transition ease-in-out duration-150"
>
Forgot your password?
</Link>
}
/>
<Button type="submit" disabled={transition.state === "submitting"} tabIndex={3}>
Sign in
</Button>
</Form>
</section>
);
}

View File

@ -1,40 +0,0 @@
import { z } from "zod";
export const password = z.string().min(10).max(100);
export const Register = z.object({
fullName: z.string().nonempty(),
email: z.string().email(),
password,
});
export const SignIn = z.object({
email: z.string().email(),
password,
});
export const ForgotPassword = z.object({
email: z.string().email(),
});
export const ResetPassword = z
.object({
password: password,
passwordConfirmation: password,
token: z.string(),
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords don't match",
path: ["passwordConfirmation"], // set the path of the error
});
export const AcceptInvitation = z.object({
fullName: z.string(),
email: z.string().email(),
password,
token: z.string(),
});
export const AcceptAuthedInvitation = z.object({
token: z.string(),
});

View File

@ -5,7 +5,7 @@ import { z } from "zod";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { validate } from "~/utils/validation.server";
import { requireLoggedIn } from "~/utils/auth.server";
import { getSession } from "~/utils/session.server";
const action: ActionFunction = async ({ request }) => {
const formData = await request.clone().formData();
@ -31,7 +31,6 @@ const action: ActionFunction = async ({ request }) => {
export default action;
async function subscribe(request: Request) {
const { organization } = await requireLoggedIn(request);
const formData = await request.formData();
const body = {
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
@ -42,17 +41,16 @@ async function subscribe(request: Request) {
}
const { subscription } = validation.data;
const membership = await db.membership.findFirst({
where: { id: organization.membershipId },
});
if (!membership) {
return notFound("Phone number not found");
const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) {
throw new Error("unreachable");
}
try {
await db.notificationSubscription.create({
data: {
membershipId: membership.id,
twilioAccountSid: twilio.accountSid,
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys_p256dh: subscription.keys.p256dh,

View File

@ -1,26 +0,0 @@
import type { ButtonHTMLAttributes, FunctionComponent } from "react";
import { useTransition } from "@remix-run/react";
import clsx from "clsx";
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
const Button: FunctionComponent<Props> = ({ children, ...props }) => {
const transition = useTransition();
return (
<button
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": transition.state === "submitting",
"bg-primary-600 hover:bg-primary-700": transition.state !== "submitting",
},
)}
{...props}
>
{children}
</button>
);
}
export default Button;

View File

@ -1,35 +0,0 @@
import { useNavigate } from "@remix-run/react";
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
export default function InactiveSubscription() {
const navigate = useNavigate();
return (
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
<span className="hidden md:inline-block md:align-middle md:h-screen">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all md:my-8 md:align-middle md:max-w-lg md:w-full md:p-6">
<div className="text-center my-auto p-4">
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
You don&#39;t have any active subscription
</h3>
<p className="mt-1 text-sm text-gray-500 max-w-sm mx-auto break-normal whitespace-normal">
You need an active subscription to use this feature.
<br />
Head over to your settings to pick a plan.
</p>
<div className="mt-6">
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => navigate("/settings/billing")}
>
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Choose a plan
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,68 +0,0 @@
import { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { HiCheck as CheckIcon, HiSelector as SelectorIcon } from "react-icons/hi";
import clsx from "clsx";
type Option = { name: string; value: string };
type Props = {
options: Option[];
onChange: (selectedValue: Option) => void;
value: Option;
};
export default function Select({ options, onChange, value }: Props) {
return (
<Listbox value={value} onChange={onChange}>
<div className="relative mt-1">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 sm:text-sm">
<span className="block truncate">{value.name}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{options.map((option, index) => (
<Listbox.Option
key={`option-${option}-${index}`}
className={({ active }) =>
clsx(
"cursor-default select-none relative py-2 pl-10 pr-4",
active ? "text-amber-900 bg-amber-100" : "text-gray-900",
)
}
value={option}
>
{({ selected, active }) => (
<>
<span
className={clsx("block truncate", selected ? "font-medium" : "font-normal")}
>
{option.name}
</span>
{selected ? (
<span
className={clsx(
"absolute inset-y-0 left-0 flex items-center pl-3",
active ? "text-amber-600" : "text-amber-600",
)}
>
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
}

View File

@ -1,15 +0,0 @@
.ring {
display: inline-block;
width: 50px;
height: 50px;
border: 3px solid rgba(0, 0, 0, 0.15);
border-radius: 50%;
border-top-color: currentColor;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@ -1,15 +0,0 @@
import type { LinksFunction } from "@remix-run/node";
import styles from "./spinner.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
];
export default function Spinner() {
return (
<div className="h-full flex">
<div className="ring m-auto text-primary-400" />
</div>
);
}

View File

@ -2,21 +2,20 @@ import type { LoaderFunction } from "@remix-run/node";
import { json } from "superjson-remix";
import { Prisma } from "@prisma/client";
import { requireLoggedIn } from "~/utils/auth.server";
import db from "~/utils/db.server";
import { getSession } from "~/utils/session.server";
export type KeypadLoaderData = {
hasOngoingSubscription: boolean;
hasPhoneNumber: boolean;
lastRecipientCalled?: string;
};
const loader: LoaderFunction = async ({ request }) => {
const { twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
const phoneNumber = await db.phoneNumber.findUnique({
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
});
const hasOngoingSubscription = true; // TODO
const hasPhoneNumber = Boolean(phoneNumber);
const lastCall =
phoneNumber &&
@ -26,7 +25,6 @@ const loader: LoaderFunction = async ({ request }) => {
}));
return json<KeypadLoaderData>(
{
hasOngoingSubscription,
hasPhoneNumber,
lastRecipientCalled: lastCall?.recipient,
},

View File

@ -2,19 +2,21 @@ import { type ActionFunction } from "@remix-run/node";
import { json } from "superjson-remix";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import getTwilioClient, { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.server";
import { getSession } from "~/utils/session.server";
export type NewMessageActionData = {};
type NewMessageActionData = {};
const action: ActionFunction = async ({ params, request }) => {
const { twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) {
throw new Error("unreachable");
}
const [phoneNumber, twilioAccount] = await Promise.all([
db.phoneNumber.findUnique({
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid ?? "", isCurrent: true } },
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid, isCurrent: true } },
}),
db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }),
]);

View File

@ -4,8 +4,8 @@ import { parsePhoneNumber } from "awesome-phonenumber";
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import { redirect } from "@remix-run/node";
import { getSession } from "~/utils/session.server";
type ConversationType = {
recipient: string;
@ -19,7 +19,8 @@ export type ConversationLoaderData = {
};
const loader: LoaderFunction = async ({ request, params }) => {
const { twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) {
return redirect("/messages");
}

View File

@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import { getSession } from "~/utils/session.server";
export type MessagesLoaderData = {
hasPhoneNumber: boolean;
@ -19,7 +19,8 @@ type Conversation = {
};
const loader: LoaderFunction = async ({ request }) => {
const { twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
const phoneNumber = await db.phoneNumber.findUnique({
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
});

View File

@ -10,20 +10,14 @@ import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
import type { PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
export default function PhoneCallsList() {
const { hasOngoingSubscription, isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
const { isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
if (!hasOngoingSubscription) {
if (!phoneCalls || phoneCalls.length === 0) {
return null;
}
} else {
if (isFetchingCalls || !phoneCalls) {
return <PhoneInitLoader />;
}
if (isFetchingCalls || !phoneCalls) {
return <PhoneInitLoader />;
}
if (phoneCalls.length === 0) {
return hasOngoingSubscription ? <EmptyCalls /> : null;
}
if (phoneCalls.length === 0) {
return <EmptyCalls />;
}
return (

View File

@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
import { type PhoneCall, Prisma } from "@prisma/client";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import { getSession } from "~/utils/session.server";
type PhoneCallMeta = {
formattedPhoneNumber: string;
@ -12,7 +12,6 @@ type PhoneCallMeta = {
};
export type PhoneCallsLoaderData = {
hasOngoingSubscription: boolean;
hasPhoneNumber: boolean;
} & (
| {
@ -26,15 +25,14 @@ export type PhoneCallsLoaderData = {
);
const loader: LoaderFunction = async ({ request }) => {
const { twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
const phoneNumber = await db.phoneNumber.findUnique({
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
});
const hasPhoneNumber = Boolean(phoneNumber);
const hasOngoingSubscription = true; // TODO
if (!phoneNumber || phoneNumber.isFetchingCalls) {
return json<PhoneCallsLoaderData>({
hasOngoingSubscription,
hasPhoneNumber,
isFetchingCalls: phoneNumber?.isFetchingCalls ?? false,
});
@ -46,7 +44,6 @@ const loader: LoaderFunction = async ({ request }) => {
});
return json<PhoneCallsLoaderData>(
{
hasOngoingSubscription,
hasPhoneNumber,
phoneCalls: phoneCalls.map((phoneCall) => ({
...phoneCall,

View File

@ -1,17 +1,17 @@
import { type LoaderFunction } from "@remix-run/node";
import Twilio from "twilio";
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
import { decrypt, encrypt } from "~/utils/encryption";
import db from "~/utils/db.server";
import { commitSession } from "~/utils/session.server";
import { getSession } from "~/utils/session.server";
import getTwilioClient from "~/utils/twilio.server";
import logger from "~/utils/logger.server";
export type TwilioTokenLoaderData = string;
const loader: LoaderFunction = async ({ request }) => {
const { user, twilio } = await requireLoggedIn(request);
const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) {
logger.warn("Twilio account is not connected");
return null;
@ -26,7 +26,6 @@ const loader: LoaderFunction = async ({ request }) => {
}
const twilioClient = getTwilioClient(twilioAccount);
let shouldRefreshSession = false;
let { apiKeySid, apiKeySecret } = twilioAccount;
if (apiKeySid && apiKeySecret) {
try {
@ -41,7 +40,6 @@ const loader: LoaderFunction = async ({ request }) => {
}
}
if (!apiKeySid || !apiKeySecret) {
shouldRefreshSession = true;
const apiKey = await twilioClient.newKeys.create({ friendlyName: "Shellphone" });
apiKeySid = apiKey.sid;
apiKeySecret = encrypt(apiKey.secret);
@ -52,7 +50,7 @@ const loader: LoaderFunction = async ({ request }) => {
}
const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, decrypt(apiKeySecret), {
identity: `${twilio.accountSid}__${user.id}`,
identity: `shellphone__${twilio.accountSid}`,
ttl: 3600,
});
const grant = new Twilio.jwt.AccessToken.VoiceGrant({
@ -62,11 +60,6 @@ const loader: LoaderFunction = async ({ request }) => {
accessToken.addGrant(grant);
const headers = new Headers({ "Content-Type": "text/plain" });
if (shouldRefreshSession) {
const { session } = await refreshSessionData(request);
headers.set("Set-Cookie", await commitSession(session));
}
return new Response(accessToken.toJwt(), { headers });
};

View File

@ -1,23 +0,0 @@
import { type ActionFunction, json } from "@remix-run/node";
import { addSubscriber } from "~/utils/mailchimp.server";
import { executeWebhook } from "~/utils/discord.server";
export type JoinWaitlistActionData = { submitted: true };
const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const email = formData.get("email");
if (!formData.get("email") || typeof email !== "string") {
throw new Error("Something wrong happened");
}
// await addSubscriber(email);
const res = await executeWebhook(email);
console.log(res.status);
console.log(await res.text());
return json<JoinWaitlistActionData>({ submitted: true });
};
export default action;

View File

@ -1,41 +0,0 @@
import type { ButtonHTMLAttributes } from "react";
import clsx from "clsx";
const baseStyles = {
solid: "group inline-flex items-center justify-center rounded-full py-2 px-4 text-sm font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2",
outline: "group inline-flex ring-1 items-center justify-center rounded-full py-2 px-4 text-sm focus:outline-none",
};
const variantStyles = {
solid: {
slate: "bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900",
primary:
"bg-primary-600 text-white hover:text-slate-100 hover:bg-primary-500 active:bg-primary-800 active:text-primary-100 focus-visible:outline-primary-600",
white: "bg-white text-slate-900 hover:bg-primary-50 active:bg-primary-200 active:text-slate-600 focus-visible:outline-white",
},
outline: {
slate: "ring-slate-200 text-slate-700 hover:text-slate-900 hover:ring-slate-300 active:bg-slate-100 active:text-slate-600 focus-visible:outline-primary-600 focus-visible:ring-slate-300",
white: "ring-slate-700 text-white hover:ring-slate-500 active:ring-slate-700 active:text-slate-400 focus-visible:outline-white",
},
};
type Props = ButtonHTMLAttributes<HTMLButtonElement> &
(
| {
variant: "solid";
color: "slate" | "primary" | "white";
}
| {
variant: "outline";
color: "slate" | "white";
}
) & {
className?: string;
};
export default function Button({ variant, color, className, ...props }: Props) {
// @ts-ignore
const fullClassName = clsx(baseStyles[variant], variantStyles[variant][color], className);
return <button className={fullClassName} {...props} />;
}

View File

@ -1,67 +0,0 @@
import { Form, useActionData } from "@remix-run/react";
import type { JoinWaitlistActionData } from "~/features/public-area/actions";
import Button from "./button";
import Container from "./container";
import { TextField } from "./fields";
import Alert from "~/features/core/components/alert";
import backgroundImage from "../images/background-call-to-action.webp";
export default function CallToAction() {
const actionData = useActionData<JoinWaitlistActionData>();
return (
<section id="get-started-today" className="relative overflow-hidden bg-blue-600 py-32"