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,9 +1,6 @@
node_modules node_modules
.env .env
/.idea /.idea
/cypress/videos
/cypress/screenshots
/coverage
# build artifacts # build artifacts
/.cache /.cache

View File

@ -25,40 +25,3 @@ jobs:
node-version: 16 node-version: 16
- run: npm ci - run: npm ci
- run: npx tsc - 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 /.idea
.env .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", typeof process.env.WEB_PUSH_VAPID_PUBLIC_KEY === "string",
`Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`, `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 { export default {
app: { app: {
@ -66,24 +50,11 @@ export default {
}, },
aws: { aws: {
region: process.env.AWS_REGION, 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: { s3: {
accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID, accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_S3_ACCESS_KEY_SECRET, 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: { redis: {
url: process.env.REDIS_URL, url: process.env.REDIS_URL,
password: process.env.REDIS_PASSWORD, 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 db from "~/utils/db.server";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
import { validate } from "~/utils/validation.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 action: ActionFunction = async ({ request }) => {
const formData = await request.clone().formData(); const formData = await request.clone().formData();
@ -31,7 +31,6 @@ const action: ActionFunction = async ({ request }) => {
export default action; export default action;
async function subscribe(request: Request) { async function subscribe(request: Request) {
const { organization } = await requireLoggedIn(request);
const formData = await request.formData(); const formData = await request.formData();
const body = { const body = {
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"), subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
@ -42,17 +41,16 @@ async function subscribe(request: Request) {
} }
const { subscription } = validation.data; const { subscription } = validation.data;
const membership = await db.membership.findFirst({ const session = await getSession(request);
where: { id: organization.membershipId }, const twilio = session.get("twilio");
}); if (!twilio) {
if (!membership) { throw new Error("unreachable");
return notFound("Phone number not found");
} }
try { try {
await db.notificationSubscription.create({ await db.notificationSubscription.create({
data: { data: {
membershipId: membership.id, twilioAccountSid: twilio.accountSid,
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime, expirationTime: subscription.expirationTime,
keys_p256dh: subscription.keys.p256dh, 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 { json } from "superjson-remix";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { requireLoggedIn } from "~/utils/auth.server";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import { getSession } from "~/utils/session.server";
export type KeypadLoaderData = { export type KeypadLoaderData = {
hasOngoingSubscription: boolean;
hasPhoneNumber: boolean; hasPhoneNumber: boolean;
lastRecipientCalled?: string; lastRecipientCalled?: string;
}; };
const loader: LoaderFunction = async ({ request }) => { 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({ const phoneNumber = await db.phoneNumber.findUnique({
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } }, where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
}); });
const hasOngoingSubscription = true; // TODO
const hasPhoneNumber = Boolean(phoneNumber); const hasPhoneNumber = Boolean(phoneNumber);
const lastCall = const lastCall =
phoneNumber && phoneNumber &&
@ -26,7 +25,6 @@ const loader: LoaderFunction = async ({ request }) => {
})); }));
return json<KeypadLoaderData>( return json<KeypadLoaderData>(
{ {
hasOngoingSubscription,
hasPhoneNumber, hasPhoneNumber,
lastRecipientCalled: lastCall?.recipient, lastRecipientCalled: lastCall?.recipient,
}, },

View File

@ -2,19 +2,21 @@ import { type ActionFunction } from "@remix-run/node";
import { json } from "superjson-remix"; import { json } from "superjson-remix";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import getTwilioClient, { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.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 action: ActionFunction = async ({ params, request }) => {
const { twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) { if (!twilio) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
const [phoneNumber, twilioAccount] = await Promise.all([ const [phoneNumber, twilioAccount] = await Promise.all([
db.phoneNumber.findUnique({ 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 } }), 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 { type Message, 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 { redirect } from "@remix-run/node"; import { redirect } from "@remix-run/node";
import { getSession } from "~/utils/session.server";
type ConversationType = { type ConversationType = {
recipient: string; recipient: string;
@ -19,7 +19,8 @@ export type ConversationLoaderData = {
}; };
const loader: LoaderFunction = async ({ request, params }) => { const loader: LoaderFunction = async ({ request, params }) => {
const { twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) { if (!twilio) {
return redirect("/messages"); return redirect("/messages");
} }

View File

@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
import { type Message, type PhoneNumber, Prisma } from "@prisma/client"; import { type Message, 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 { getSession } from "~/utils/session.server";
export type MessagesLoaderData = { export type MessagesLoaderData = {
hasPhoneNumber: boolean; hasPhoneNumber: boolean;
@ -19,7 +19,8 @@ type Conversation = {
}; };
const loader: LoaderFunction = async ({ request }) => { 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({ const phoneNumber = await db.phoneNumber.findUnique({
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } }, 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"; import type { PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
export default function PhoneCallsList() { export default function PhoneCallsList() {
const { hasOngoingSubscription, isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>(); const { isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
if (!hasOngoingSubscription) { if (isFetchingCalls || !phoneCalls) {
if (!phoneCalls || phoneCalls.length === 0) { return <PhoneInitLoader />;
return null; }
}
} else {
if (isFetchingCalls || !phoneCalls) {
return <PhoneInitLoader />;
}
if (phoneCalls.length === 0) { if (phoneCalls.length === 0) {
return hasOngoingSubscription ? <EmptyCalls /> : null; return <EmptyCalls />;
}
} }
return ( return (

View File

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

View File

@ -1,17 +1,17 @@
import { type LoaderFunction } from "@remix-run/node"; import { type LoaderFunction } from "@remix-run/node";
import Twilio from "twilio"; import Twilio from "twilio";
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
import { decrypt, encrypt } from "~/utils/encryption"; import { decrypt, encrypt } from "~/utils/encryption";
import db from "~/utils/db.server"; 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 getTwilioClient from "~/utils/twilio.server";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
export type TwilioTokenLoaderData = string; export type TwilioTokenLoaderData = string;
const loader: LoaderFunction = async ({ request }) => { const loader: LoaderFunction = async ({ request }) => {
const { user, twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) { if (!twilio) {
logger.warn("Twilio account is not connected"); logger.warn("Twilio account is not connected");
return null; return null;
@ -26,7 +26,6 @@ const loader: LoaderFunction = async ({ request }) => {
} }
const twilioClient = getTwilioClient(twilioAccount); const twilioClient = getTwilioClient(twilioAccount);
let shouldRefreshSession = false;
let { apiKeySid, apiKeySecret } = twilioAccount; let { apiKeySid, apiKeySecret } = twilioAccount;
if (apiKeySid && apiKeySecret) { if (apiKeySid && apiKeySecret) {
try { try {
@ -41,7 +40,6 @@ const loader: LoaderFunction = async ({ request }) => {
} }
} }
if (!apiKeySid || !apiKeySecret) { if (!apiKeySid || !apiKeySecret) {
shouldRefreshSession = true;
const apiKey = await twilioClient.newKeys.create({ friendlyName: "Shellphone" }); const apiKey = await twilioClient.newKeys.create({ friendlyName: "Shellphone" });
apiKeySid = apiKey.sid; apiKeySid = apiKey.sid;
apiKeySecret = encrypt(apiKey.secret); apiKeySecret = encrypt(apiKey.secret);
@ -52,7 +50,7 @@ const loader: LoaderFunction = async ({ request }) => {
} }
const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, decrypt(apiKeySecret), { const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, decrypt(apiKeySecret), {
identity: `${twilio.accountSid}__${user.id}`, identity: `shellphone__${twilio.accountSid}`,
ttl: 3600, ttl: 3600,
}); });
const grant = new Twilio.jwt.AccessToken.VoiceGrant({ const grant = new Twilio.jwt.AccessToken.VoiceGrant({
@ -62,11 +60,6 @@ const loader: LoaderFunction = async ({ request }) => {
accessToken.addGrant(grant); accessToken.addGrant(grant);
const headers = new Headers({ "Content-Type": "text/plain" }); 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 }); 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">
<img
className="absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2"
src={backgroundImage}
alt=""
width={2347}
height={1244}
/>
<Container className="relative">
<div className="mx-auto max-w-lg text-center">
<h2 className="font-mackinac font-bold text-3xl tracking-tight text-white sm:text-4xl">
Request access
</h2>
<p className="mt-4 text-lg tracking-tight text-white">
Shellphone is currently invite-only but we onboard new users on a regular basis. Enter your
email address to join the waitlist and receive important updates in your inbox.
</p>
</div>
<Form
method="post"
className="max-w-2xl mx-auto flex flex-col space-y-4 items-center mt-10 sm:flex-row sm:space-y-0 sm:space-x-4"
>
{actionData?.submitted ? (
<div className="m-auto">
<Alert
title="You made it!"
message="You&#39;re on the list, we will be in touch soon"
variant="success"
/>
</div>
) : (
<>
<TextField
id="email"
name="email"
type="email"
autoComplete="email"
className="w-full"
placeholder="Enter your email address"
required
/>
<Button type="submit" variant="solid" color="white" className="w-40">
<span>Join waitlist</span>
</Button>
</>
)}
</Form>
</Container>
</section>
);
}

View File

@ -1,10 +0,0 @@
import type { HTMLAttributes } from "react";
import clsx from "clsx";
type Props = HTMLAttributes<HTMLDivElement> & {
className?: string;
};
export default function Container({ className, ...props }: Props) {
return <div className={clsx("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8", className)} {...props} />;
}

View File

@ -1,142 +0,0 @@
import type { FunctionComponent, PropsWithChildren } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import clsx from "clsx";
import Container from "./container";
import backgroundImage from "../images/background-faqs.webp";
export default function Faqs() {
return (
<section id="faq" aria-labelledby="faq-title" className="relative overflow-hidden bg-slate-50 py-20 sm:py-32">
<img
className="absolute top-0 left-1/2 max-w-none translate-x-[-30%] -translate-y-1/4"
src={backgroundImage}
alt=""
width={1558}
height={946}
/>
<Container className="relative">
<div className="mx-auto max-w-2xl lg:mx-0">
<h2
id="faq-title"
className="font-mackinac font-bold text-3xl tracking-tight text-slate-900 sm:text-4xl"
>
Frequently asked questions
</h2>
</div>
<ul className="mt-16 grid grid-cols-1 max-w-3xl mx-auto pl-12 lg:mx-0">
<Accordion title="How does it work?">
Shellphone is your go-to app to use your phone number over the internet. It integrates
seamlessly with Twilio to provide the best experience for your personal cloud phone.
</Accordion>
<Accordion title="What do I need to use Shellphone?">
Shellphone is still in its early stages and we&#39;re working hard to make it as easy-to-use as
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
Shellphone.
</Accordion>
<Accordion title="Why would I use this over an eSIM?">
Chances are you&#39;re currently using an eSIM-compatible device. eSIMs are a reasonable way of
using a phone number internationally but they are still subject to some irky limitations. For
example, you can only use an eSIM on one device at a time and you are still subject to
exorbitant rates from your carrier.
</Accordion>
<Accordion title="Does it work with 2FA messages?">
Some banks and online services refuse to send two-factor authentication messages to a virtual
phone number and we do not have a solution around this yet. Moreover, Twilio does not support
receiving incoming SMS from external Alphanumeric Sender IDs is to protect accounts getting
bombarded from spam messages from these IDs which are used to send one-way SMS.
<br />
With that said, we have successfully received 2FA messages from many services including WhatsApp
and Uber. We recognize this is a common problem for people who want to switch to a virtual phone
number and we are doing our best to find a long-term solution to receive 2FA messages.
</Accordion>
<span className="block border-t border-gray-200" aria-hidden="true" />
</ul>
</Container>
</section>
);
}
function FAQs() {
return (
<section className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="py-12 md:py-20">
<div className="max-w-3xl mx-auto text-center pb-20">
<h2 className="h2 font-mackinac">Questions & Answers</h2>
</div>
<ul className="max-w-3xl mx-auto pl-12">
<Accordion title="How does it work?">
Shellphone is your go-to app to use your phone number over the internet. It integrates
seamlessly with Twilio to provide the best experience for your personal cloud phone.
</Accordion>
<Accordion title="What do I need to use Shellphone?">
Shellphone is still in its early stages and we&#39;re working hard to make it as easy-to-use as
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
Shellphone.
</Accordion>
<Accordion title="Why would I use this over an eSIM?">
Chances are you&#39;re currently using an eSIM-compatible device. eSIMs are a reasonable way of
using a phone number internationally but they are still subject to some irky limitations. For
example, you can only use an eSIM on one device at a time and you are still subject to
exorbitant rates from your carrier.
</Accordion>
<span className="block border-t border-gray-200" aria-hidden="true" />
</ul>
</div>
</section>
);
}
const Accordion: FunctionComponent<PropsWithChildren<{ title: string }>> = ({ title, children }) => {
return (
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button className="flex items-center w-full text-lg font-medium text-left py-5 border-t border-gray-200">
<svg
className="w-4 h-4 fill-current text-rebeccapurple-500 flex-shrink-0 mr-8 -ml-12"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<rect
y="7"
width="16"
height="2"
rx="1"
className={clsx("transform origin-center transition duration-200 ease-out", {
"rotate-180": open,
})}
/>
<rect
y="7"
width="16"
height="2"
rx="1"
className={clsx("transform origin-center transition duration-200 ease-out", {
"rotate-90": !open,
"rotate-180": open,
})}
/>
</svg>
<span>{title}</span>
</Disclosure.Button>
<Transition
enter="transition duration-300 ease-in-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className="text-gray-600 overflow-hidden">
<p className="pb-5">{children}</p>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
};

View File

@ -1,27 +0,0 @@
import type { InputHTMLAttributes, HTMLAttributes, PropsWithChildren } from "react";
const formClasses =
"block w-full appearance-none rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-blue-500 sm:text-sm";
function Label({ id, children }: PropsWithChildren<HTMLAttributes<HTMLLabelElement>>) {
return (
<label htmlFor={id} className="mb-3 block text-sm font-medium text-gray-700">
{children}
</label>
);
}
export function TextField({
id,
label,
type = "text",
className = "",
...props
}: InputHTMLAttributes<HTMLInputElement> & { label?: string }) {
return (
<div className={className}>
{label && <Label id={id}>{label}</Label>}
<input id={id} type={type} {...props} className={formClasses} />
</div>
);
}

View File

@ -1,34 +0,0 @@
import { Link } from "@remix-run/react";
import Button from "./button";
import Container from "./container";
import Logo from "./logo";
import NavLink from "./nav-link";
export default function Header() {
return (
<header className="py-10">
<Container>
<nav className="relative z-50 flex justify-between">
<div className="flex items-center md:gap-x-12">
<Link to="/" aria-label="Home">
<Logo />
</Link>
</div>
<div className="flex items-center gap-x-5 md:gap-x-8">
<NavLink href="/sign-in">Have an account?</NavLink>
<Button
variant="solid"
color="primary"
onClick={() => {
document.querySelector("#get-started-today")?.scrollIntoView({ behavior: "smooth" });
}}
>
<span>Request access</span>
</Button>
</div>
</nav>
</Container>
</header>
);
}

View File

@ -1,38 +0,0 @@
import Button from "./button";
import Container from "./container";
/*
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
justify-content: center;
margin-top: -120px;
*/
export default function Hero() {
return (
<Container className="pt-20 pb-16 text-center lg:pt-32 landing-hero">
<h1 className="mx-auto max-w-4xl font-mackinac font-heading text-5xl font-medium tracking-tight text-[#24185B] sm:text-7xl">
<span className="background-primary bg-clip-text decoration-clone text-transparent">
Calling your bank from abroad
</span>{" "}
just got{" "}
<span className="relative whitespace-nowrap">
<svg
aria-hidden="true"
viewBox="0 0 418 42"
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-rebeccapurple-300/70"
preserveAspectRatio="none"
>
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
</svg>
<span className="relative">easier</span>
</span>{" "}
!
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
Coming soon, the personal cloud phone for digital nomads! Take your phone number anywhere you go 🌏
</p>
</Container>
);
}

View File

@ -1,3 +0,0 @@
export default function Logo() {
return <img className="w-10 h-10" src="/shellphone.webp" alt="Shellphone logo" />;
}

View File

@ -1,13 +0,0 @@
import type { PropsWithChildren } from "react";
import { Link } from "@remix-run/react";
export default function NavLink({ href, children }: PropsWithChildren<{ href: string }>) {
return (
<Link
to={href}
className="inline-block rounded-lg py-1 px-2 text-sm text-slate-700 hover:bg-slate-100 hover:text-slate-900"
>
{children}
</Link>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,17 +0,0 @@
import Header from "../components/header";
import Hero from "../components/hero";
import CallToAction from "../components/call-to-action";
import Faqs from "../components/faqs";
export default function IndexPage() {
return (
<section className="flex h-full flex-col">
<Header />
<main>
<Hero />
<CallToAction />
<Faqs />
</main>
</section>
);
}

View File

@ -1,130 +0,0 @@
import { type ActionFunction, json, redirect } from "@remix-run/node";
import { badRequest } from "remix-utils";
import { z } from "zod";
import SecurePassword from "secure-password";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { hashPassword, requireLoggedIn, verifyPassword } from "~/utils/auth.server";
import { type FormError, validate } from "~/utils/validation.server";
import { destroySession, getSession } from "~/utils/session.server";
import deleteUserQueue from "~/queues/delete-user-data.server";
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
if (!formData._action) {
const errorMessage = "POST /settings/phone without any _action";
logger.error(errorMessage);
return badRequest({ errorMessage });
}
switch (formData._action as Action) {
case "deleteUser":
return deleteUser(request);
case "changePassword":
return changePassword(request, formData);
case "updateUser":
return updateUser(request, formData);
default:
const errorMessage = `POST /settings/phone with an invalid _action=${formData._action}`;
logger.error(errorMessage);
return badRequest({ errorMessage });
}
};
export default action;
async function deleteUser(request: Request) {
const {
user: { id },
} = await requireLoggedIn(request);
await db.user.update({
where: { id },
data: { hashedPassword: "pending deletion" },
});
await deleteUserQueue.add(`delete user ${id}`, { userId: id });
return redirect("/", {
headers: {
"Set-Cookie": await destroySession(await getSession(request)),
},
});
}
type ChangePasswordFailureActionData = { errors: FormError<typeof validations.changePassword>; submitted?: never };
type ChangePasswordSuccessfulActionData = { errors?: never; submitted: true };
export type ChangePasswordActionData = {
changePassword: ChangePasswordFailureActionData | ChangePasswordSuccessfulActionData;
};
async function changePassword(request: Request, formData: unknown) {
const validation = validate(validations.changePassword, formData);
if (validation.errors) {
return json<ChangePasswordActionData>({
changePassword: { errors: validation.errors },
});
}
const {
user: { id },
} = await requireLoggedIn(request);
const user = await db.user.findUnique({ where: { id } });
const { currentPassword, newPassword } = validation.data;
const verificationResult = await verifyPassword(user!.hashedPassword!, currentPassword);
if ([SecurePassword.INVALID, SecurePassword.INVALID_UNRECOGNIZED_HASH, false].includes(verificationResult)) {
return json<ChangePasswordActionData>({
changePassword: { errors: { currentPassword: "Current password is incorrect" } },
});
}
const hashedPassword = await hashPassword(newPassword.trim());
await db.user.update({
where: { id: user!.id },
data: { hashedPassword },
});
return json<ChangePasswordActionData>({
changePassword: { submitted: true },
});
}
type UpdateUserFailureActionData = { errors: FormError<typeof validations.updateUser>; submitted?: never };
type UpdateUserSuccessfulActionData = { errors?: never; submitted: true };
export type UpdateUserActionData = {
updateUser: UpdateUserFailureActionData | UpdateUserSuccessfulActionData;
};
async function updateUser(request: Request, formData: unknown) {
const validation = validate(validations.updateUser, formData);
if (validation.errors) {
return json<UpdateUserActionData>({
updateUser: { errors: validation.errors },
});
}
const { user } = await requireLoggedIn(request);
const { email, fullName } = validation.data;
await db.user.update({
where: { id: user.id },
data: { email, fullName },
});
return json<UpdateUserActionData>({
updateUser: { submitted: true },
});
}
type Action = "deleteUser" | "updateUser" | "changePassword";
const validations = {
deleteUser: null,
changePassword: z.object({
currentPassword: z.string(),
newPassword: z.string().min(10).max(100),
}),
updateUser: z.object({
fullName: z.string(),
email: z.string(),
}),
} as const;

View File

@ -5,8 +5,7 @@ import type { Prisma } from "@prisma/client";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import { type FormActionData, validate } from "~/utils/validation.server"; import { type FormActionData, validate } from "~/utils/validation.server";
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server"; import { commitSession, getSession } 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"; import logger from "~/utils/logger.server";
import { encrypt } from "~/utils/encryption"; import { encrypt } from "~/utils/encryption";
@ -40,7 +39,8 @@ const action: ActionFunction = async ({ request }) => {
export type SetPhoneNumberActionData = FormActionData<typeof validations, "setPhoneNumber">; export type SetPhoneNumberActionData = FormActionData<typeof validations, "setPhoneNumber">;
async function setPhoneNumber(request: Request, formData: unknown) { async function setPhoneNumber(request: Request, formData: unknown) {
const { organization, twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) { if (!twilio) {
return badRequest<SetPhoneNumberActionData>({ return badRequest<SetPhoneNumberActionData>({
setPhoneNumber: { setPhoneNumber: {
@ -72,7 +72,6 @@ async function setPhoneNumber(request: Request, formData: unknown) {
}); });
await setTwilioWebhooksQueue.add(`set twilio webhooks for phoneNumberId=${validation.data.phoneNumberSid}`, { await setTwilioWebhooksQueue.add(`set twilio webhooks for phoneNumberId=${validation.data.phoneNumberSid}`, {
phoneNumberId: validation.data.phoneNumberSid, phoneNumberId: validation.data.phoneNumberSid,
organizationId: organization.id,
}); });
return json<SetPhoneNumberActionData>({ setPhoneNumber: { submitted: true } }); return json<SetPhoneNumberActionData>({ setPhoneNumber: { submitted: true } });
@ -81,7 +80,8 @@ async function setPhoneNumber(request: Request, formData: unknown) {
export type SetTwilioCredentialsActionData = FormActionData<typeof validations, "setTwilioCredentials">; export type SetTwilioCredentialsActionData = FormActionData<typeof validations, "setTwilioCredentials">;
async function setTwilioCredentials(request: Request, formData: unknown) { async function setTwilioCredentials(request: Request, formData: unknown) {
const { organization, twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
const validation = validate(validations.setTwilioCredentials, formData); const validation = validate(validations.setTwilioCredentials, formData);
if (validation.errors) { if (validation.errors) {
return badRequest<SetTwilioCredentialsActionData>({ setTwilioCredentials: { errors: validation.errors } }); return badRequest<SetTwilioCredentialsActionData>({ setTwilioCredentials: { errors: validation.errors } });
@ -99,10 +99,10 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
throw error; throw error;
} }
let session: Session | undefined;
if (twilio) { if (twilio) {
console.log("fail");
await db.twilioAccount.delete({ where: { accountSid: twilio?.accountSid } }); await db.twilioAccount.delete({ where: { accountSid: twilio?.accountSid } });
session = (await refreshSessionData(request)).session; session.unset("twilio");
} }
return json<SetTwilioCredentialsActionData>( return json<SetTwilioCredentialsActionData>(
@ -112,11 +112,9 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
}, },
}, },
{ {
headers: session headers: {
? { "Set-Cookie": await commitSession(session),
"Set-Cookie": await commitSession(session), },
}
: {},
}, },
); );
} }
@ -128,13 +126,8 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
const [phoneNumbers] = await Promise.all([ const [phoneNumbers] = await Promise.all([
twilioClient.incomingPhoneNumbers.list(), twilioClient.incomingPhoneNumbers.list(),
db.twilioAccount.upsert({ db.twilioAccount.upsert({
where: { organizationId: organization.id }, where: { accountSid: twilioAccountSid },
create: { create: data,
organization: {
connect: { id: organization.id },
},
...data,
},
update: data, update: data,
}), }),
]); ]);
@ -143,11 +136,11 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
accountSid: twilioAccountSid, accountSid: twilioAccountSid,
}); });
await Promise.all( await Promise.all(
phoneNumbers.map(async (phoneNumber) => { phoneNumbers.map(async (phoneNumber, index) => {
const phoneNumberId = phoneNumber.sid; const phoneNumberId = phoneNumber.sid;
logger.info(`Importing phone number with id=${phoneNumberId}`); logger.info(`Importing phone number with id=${phoneNumberId}`);
try { try {
await db.phoneNumber.create({ await db.phoneNumber.createMany({
data: { data: {
id: phoneNumberId, id: phoneNumberId,
twilioAccountSid, twilioAccountSid,
@ -156,6 +149,7 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
isFetchingCalls: true, isFetchingCalls: true,
isFetchingMessages: true, isFetchingMessages: true,
}, },
skipDuplicates: true,
}); });
await Promise.all([ await Promise.all([
@ -177,19 +171,25 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
}), }),
); );
const { session } = await refreshSessionData(request); session.set("twilio", { accountSid: twilioAccountSid, authToken });
console.log("{ accountSid: twilioAccountSid, authToken }", { accountSid: twilioAccountSid, authToken });
console.log("session", session.get("twilio"), session.data);
const setCookie = await commitSession(session);
console.log("set twilio in session", setCookie);
return json<SetTwilioCredentialsActionData>( return json<SetTwilioCredentialsActionData>(
{ setTwilioCredentials: { submitted: true } }, { setTwilioCredentials: { submitted: true } },
{ {
headers: { headers: {
"Set-Cookie": await commitSession(session), "Set-Cookie": setCookie,
}, },
}, },
); );
} }
async function refreshPhoneNumbers(request: Request) { async function refreshPhoneNumbers(request: Request) {
const { twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) { if (!twilio) {
throw new Error("unreachable"); throw new Error("unreachable");
} }

View File

@ -1,91 +0,0 @@
import { useRef, useState } from "react";
import { Form, useTransition } from "@remix-run/react";
import clsx from "clsx";
import Button from "../button";
import SettingsSection from "../settings-section";
import Modal, { ModalTitle } from "~/features/core/components/modal";
export default function DangerZone() {
const transition = useTransition();
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "deleteUser";
const isDeletingUser = isCurrentFormTransition && transition.state === "submitting";
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
const closeModal = () => {
if (isDeletingUser) {
return;
}
setIsConfirmationModalOpen(false);
};
return (
<SettingsSection className="border border-red-300">
<div className="flex justify-between items-center flex-row space-x-2">
<p>
Once you delete your account, all of its data will be permanently deleted and any ongoing
subscription will be cancelled.
</p>
<span className="text-base font-medium">
<Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}>
Delete my account
</Button>
</span>
</div>
<Modal initialFocus={modalCancelButtonRef} isOpen={isConfirmationModalOpen} onClose={closeModal}>
<div className="md:flex md:items-start">
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
<ModalTitle>Delete my account</ModalTitle>
<div className="mt-2 text-sm text-gray-500">
<p>
Are you sure you want to delete your account? Your subscription will be cancelled and
your data permanently deleted.
</p>
<p>
You are free to create a new account with the same email address if you ever wish to
come back.
</p>
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<Form method="post">
<button
type="submit"
className={clsx(
"transition-colors duration-150 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-red-500 md:ml-3 md:w-auto md:text-sm",
{
"bg-red-400 cursor-not-allowed": isDeletingUser,
"bg-red-600 hover:bg-red-700": !isDeletingUser,
},
)}
disabled={isDeletingUser}
>
Delete my account
</button>
<input type="hidden" name="_action" value="deleteUser" />
</Form>
<button
ref={modalCancelButtonRef}
type="button"
className={clsx(
"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",
{
"bg-gray-50 cursor-not-allowed": isDeletingUser,
"hover:bg-gray-50": !isDeletingUser,
},
)}
onClick={closeModal}
disabled={isDeletingUser}
>
Cancel
</button>
</div>
</Modal>
</SettingsSection>
);
}

View File

@ -1,85 +0,0 @@
import type { FunctionComponent } from "react";
import { Form, useActionData, useTransition } from "@remix-run/react";
import type { UpdateUserActionData } from "~/features/settings/actions/account";
import useSession from "~/features/core/hooks/use-session";
import Alert from "~/features/core/components/alert";
import Button from "../button";
import SettingsSection from "../settings-section";
const ProfileInformations: FunctionComponent = () => {
const { user } = useSession();
const transition = useTransition();
const actionData = useActionData<UpdateUserActionData>()?.updateUser;
const errors = actionData?.errors;
const topErrorMessage = errors?.general;
const isError = typeof topErrorMessage !== "undefined";
const isSuccess = actionData?.submitted;
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "updateUser";
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
return (
<Form method="post">
<SettingsSection
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}>
Save
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
</div>
) : null}
{isSuccess && (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
)}
<div className="col-span-3 sm:col-span-2">
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
Full name
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="fullName"
name="fullName"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
defaultValue={user.fullName}
required
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
Email address
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="email"
name="email"
type="email"
tabIndex={2}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
defaultValue={user.email}
required
/>
</div>
</div>
<input type="hidden" name="_action" value="updateUser" />
</SettingsSection>
</Form>
);
};
export default ProfileInformations;

View File

@ -1,69 +0,0 @@
import type { FunctionComponent } from "react";
import { Form, useActionData, useTransition } from "@remix-run/react";
import type { ChangePasswordActionData } from "~/features/settings/actions/account";
import Alert from "~/features/core/components/alert";
import LabeledTextField from "~/features/core/components/labeled-text-field";
import Button from "../button";
import SettingsSection from "../settings-section";
const UpdatePassword: FunctionComponent = () => {
const transition = useTransition();
const actionData = useActionData<ChangePasswordActionData>()?.changePassword;
const topErrorMessage = actionData?.errors?.general;
const isError = typeof topErrorMessage !== "undefined";
const isSuccess = actionData?.submitted;
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "changePassword";
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
return (
<Form method="post">
<SettingsSection
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}>
Save
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
</div>
) : null}
{isSuccess ? (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<LabeledTextField
name="currentPassword"
label="Current password"
type="password"
tabIndex={3}
error={actionData?.errors?.currentPassword}
disabled={isSubmitting}
autoComplete="current-password"
/>
<LabeledTextField
name="newPassword"
label="New password"
type="password"
tabIndex={4}
error={actionData?.errors?.newPassword}
disabled={isSubmitting}
autoComplete="new-password"
/>
<input type="hidden" name="_action" value="changePassword" />
</SettingsSection>
</Form>
);
};
export default UpdatePassword;

View File

@ -1,172 +0,0 @@
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
import clsx from "clsx";
import usePaymentsHistory from "../../hooks/use-payments-history";
export default function BillingHistory() {
const {
payments,
count,
skip,
pagesNumber,
currentPage,
lastPage,
hasPreviousPage,
hasNextPage,
goToPreviousPage,
goToNextPage,
setPage,
} = usePaymentsHistory();
if (payments.length === 0) {
return null;
}
return (
<section className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
<div className="px-4 sm:px-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Billing history</h2>
</div>
<div className="mt-6 flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="overflow-hidden border-t border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Amount
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
scope="col"
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
<span className="sr-only">View receipt</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{payments.map((payment) => (
<tr key={payment.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<time>{new Date(payment.payout_date).toDateString()}</time>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{Intl.NumberFormat(undefined, {
style: "currency",
currency: payment.currency,
currencyDisplay: "narrowSymbol",
}).format(payment.amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{payment.is_paid === 1 ? "Paid" : "Upcoming"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{typeof payment.receipt_url !== "undefined" ? (
<a
href={payment.receipt_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-900"
>
View receipt
</a>
) : null}
</td>
</tr>
))}
</tbody>
</table>
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={goToPreviousPage}
className={clsx(
"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
!hasPreviousPage && "invisible",
)}
>
Previous
</button>
<p className="text-sm text-gray-700 self-center">
Page <span className="font-medium">{currentPage}</span> of{" "}
<span className="font-medium">{lastPage}</span>
</p>
<button
onClick={goToNextPage}
className={clsx(
"ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50",
!hasNextPage && "invisible",
)}
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{skip + 1}</span> to{" "}
<span className="font-medium">{skip + payments.length}</span> of{" "}
<span className="font-medium">{count}</span> results
</p>
</div>
<div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
onClick={goToPreviousPage}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Previous</span>
<IoChevronBack className="h-5 w-5" aria-hidden="true" />
</button>
{pagesNumber.map((pageNumber) => (
<button
key={`billing-history-button-page-${pageNumber}`}
onClick={() => setPage(pageNumber)}
className={clsx(
"relative inline-flex items-center px-4 py-2 border text-sm font-medium",
pageNumber === currentPage
? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600"
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50",
)}
>
{pageNumber}
</button>
))}
<button
onClick={goToNextPage}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Next</span>
<IoChevronForward className="h-5 w-5" aria-hidden="true" />
</button>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -1,18 +0,0 @@
import type { FunctionComponent, MouseEventHandler } from "react";
import { HiExternalLink } from "react-icons/hi";
type Props = {
onClick: MouseEventHandler<HTMLButtonElement>;
text: string;
};
const PaddleLink: FunctionComponent<Props> = ({ onClick, text }) => (
<button className="flex space-x-2 items-center text-left" onClick={onClick}>
<HiExternalLink className="w-6 h-6 flex-shrink-0" />
<span className="font-medium transition-colors duration-150 border-b border-transparent hover:border-primary-500">
{text}
</span>
</button>
);
export default PaddleLink;

View File

@ -1,139 +0,0 @@
import { useState } from "react";
import clsx from "clsx";
import { type Subscription, SubscriptionStatus } from "@prisma/client";
import SwitchPlanModal from "./switch-plan-modal";
export type Plan = typeof pricing["tiers"][number];
function useSubscription() {
return {
hasActiveSubscription: false,
subscription: null as any,
subscribe: () => void 0,
changePlan: () => void 0,
};
}
export default function Plans() {
const { hasActiveSubscription, subscription, subscribe, changePlan } = useSubscription();
const [nextPlan, setNextPlan] = useState<Plan | null>(null);
const [isSwitchPlanModalOpen, setIsSwitchPlanModalOpen] = useState(false);
return (
<>
<div className="mt-6 flex flex-row flex-wrap gap-2">
{pricing.tiers.map((tier) => {
const isCurrentTier = subscription?.paddlePlanId === tier.planId;
const isActiveTier = hasActiveSubscription && isCurrentTier;
const cta = getCTA({ subscription, tier });
return (
<div
key={tier.title}
className={clsx(
"relative p-2 pt-4 bg-white border border-gray-200 rounded-xl shadow-sm flex flex-1 min-w-[250px] flex-col",
)}
>
<div className="flex-1 px-2">
<h3 className="text-xl font-mackinac font-semibold text-gray-900">{tier.title}</h3>
{tier.yearly ? (
<p className="absolute top-0 py-1.5 px-4 bg-primary-500 rounded-full text-xs font-semibold uppercase tracking-wide text-white transform -translate-y-1/2">
Get 2 months free!
</p>
) : null}
<p className="mt-4 flex items-baseline text-gray-900">
<span className="text-2xl font-extrabold tracking-tight">{tier.price}</span>
<span className="ml-1 text-lg font-semibold">{tier.frequency}</span>
</p>
{tier.yearly ? (
<p className="text-gray-500 text-sm">Billed yearly ({tier.price * 12})</p>
) : null}
<p className="mt-6 text-gray-500">{tier.description}</p>
</div>
<button
disabled={isActiveTier}
onClick={() => {
if (hasActiveSubscription) {
setNextPlan(tier);
setIsSwitchPlanModalOpen(true);
} else {
// subscribe({ planId: tier.planId });
// Panelbear.track(`Subscribe to ${tier.title}`);
}
}}
className={clsx(
!isActiveTier
? "bg-primary-500 text-white hover:bg-primary-600"
: "bg-primary-50 text-primary-700 cursor-not-allowed",
"mt-8 block w-full py-3 px-6 border border-transparent rounded-md text-center font-medium",
)}
>
{cta}
</button>
</div>
);
})}
</div>
<SwitchPlanModal
isOpen={isSwitchPlanModalOpen}
nextPlan={nextPlan}
confirm={(nextPlan: Plan) => {
// changePlan({ planId: nextPlan.planId });
// Panelbear.track(`Subscribe to ${nextPlan.title}`);
setIsSwitchPlanModalOpen(false);
}}
closeModal={() => setIsSwitchPlanModalOpen(false)}
/>
</>
);
}
function getCTA({
subscription,
tier,
}: {
subscription?: Subscription;
tier: typeof pricing["tiers"][number];
}): string {
if (!subscription) {
return "Subscribe";
}
const isCancelling = subscription.status === SubscriptionStatus.deleted;
if (isCancelling) {
return "Resubscribe";
}
const isCurrentTier = subscription.paddlePlanId === tier.planId;
const hasActiveSubscription = subscription.status !== SubscriptionStatus.deleted;
const isActiveTier = hasActiveSubscription && isCurrentTier;
if (isActiveTier) {
return "Current plan";
}
return `Switch to ${tier.title}`;
}
const pricing = {
tiers: [
{
title: "Yearly",
planId: 727544,
price: 12.5,
frequency: "/month",
description: "Text and call anyone, anywhere in the world, all year long.",
yearly: true,
},
{
title: "Monthly",
planId: 727540,
price: 15,
frequency: "/month",
description: "Text and call anyone, anywhere in the world.",
yearly: false,
},
],
};

View File

@ -1,52 +0,0 @@
import type { FunctionComponent } from "react";
import { useRef } from "react";
import Modal, { ModalTitle } from "~/features/core/components/modal";
import type { Plan } from "./plans";
type Props = {
isOpen: boolean;
nextPlan: Plan | null;
confirm: (nextPlan: Plan) => void;
closeModal: () => void;
};
const SwitchPlanModal: FunctionComponent<Props> = ({ isOpen, nextPlan, confirm, closeModal }) => {
const confirmButtonRef = useRef<HTMLButtonElement>(null);
return (
<Modal initialFocus={confirmButtonRef} isOpen={isOpen} onClose={closeModal}>
<div className="md:flex md:items-start">
<div className="mt-3 text-center md:mt-0 md:ml-4 md:text-left">
<ModalTitle>Are you sure you want to switch to {nextPlan?.title}?</ModalTitle>
<div className="mt-2 text-gray-500">
<p>
You&#39;re about to switch to the <strong>{nextPlan?.title}</strong> plan. You will be
billed immediately a prorated amount and the next billing date will be recalculated from
today.
</p>
</div>
</div>
</div>
<div className="mt-5 md:mt-4 md:flex md:flex-row-reverse">
<button
ref={confirmButtonRef}
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 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={() => confirm(nextPlan!)}
>
Yes, I&#39;m sure
</button>
<button
type="button"
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}
>
Nope, cancel it
</button>
</div>
</Modal>
);
};
export default SwitchPlanModal;

View File

@ -16,19 +16,6 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
<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 some help?</ModalTitle> <ModalTitle>Need some help?</ModalTitle>
<div className="mt-6 space-y-3 text-gray-500"> <div className="mt-6 space-y-3 text-gray-500">
<p>
Try{" "}
<a className="underline" href="https://www.twilio.com/authorize/CN01675d385a9ee79e6aa58adf54abe3b3">
reconnecting your Twilio account
</a> to refresh the phone numbers.
</p>
<p>
If you are stuck, pick a date & time on{" "}
<a className="underline" href="https://calendly.com/shellphone-onboarding">
our calendly
</a>{" "}
and we will help you get started!
</p>
<p> <p>
Don&#39;t miss out on free $10 Twilio credit by using{" "} Don&#39;t miss out on free $10 Twilio credit by using{" "}
<a className="underline" href="https://www.twilio.com/referral/gNvX8p"> <a className="underline" href="https://www.twilio.com/referral/gNvX8p">

View File

@ -25,7 +25,7 @@ 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 = twilio !== null; const hasFilledTwilioCredentials = twilio != null;
if (!hasFilledTwilioCredentials) { if (!hasFilledTwilioCredentials) {
return null; return null;

View File

@ -13,9 +13,11 @@ import Button from "~/features/settings/components/button";
export default function TwilioConnect() { export default function TwilioConnect() {
const { twilio } = useSession(); const { twilio } = useSession();
console.log("twilio", twilio);
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
const transition = useTransition(); const transition = useTransition();
const actionData = useActionData<SetTwilioCredentialsActionData>()?.setTwilioCredentials; const actionData = useActionData<any>()
?.setTwilioCredentials as SetTwilioCredentialsActionData["setTwilioCredentials"];
const { accountSid, authToken } = useLoaderData<PhoneSettingsLoaderData>(); const { accountSid, authToken } = useLoaderData<PhoneSettingsLoaderData>();
const topErrorMessage = actionData?.errors?.general; const topErrorMessage = actionData?.errors?.general;
@ -50,7 +52,7 @@ export default function TwilioConnect() {
</p> </p>
</article> </article>
{twilio !== null ? ( {twilio != null ? (
<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} ) : null}

View File

@ -2,9 +2,9 @@ import { type LoaderArgs, json } from "@remix-run/node";
import { type PhoneNumber, Prisma } from "@prisma/client"; 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 logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
import { decrypt } from "~/utils/encryption"; import { decrypt } from "~/utils/encryption";
import { getSession } from "~/utils/session.server";
export type PhoneSettingsLoaderData = { export type PhoneSettingsLoaderData = {
accountSid?: string; accountSid?: string;
@ -13,14 +13,15 @@ export type PhoneSettingsLoaderData = {
}; };
const loader = async ({ request }: LoaderArgs) => { const loader = async ({ request }: LoaderArgs) => {
const { organization, twilio } = await requireLoggedIn(request); const session = await getSession(request);
const twilio = session.get("twilio");
if (!twilio) { if (!twilio) {
logger.warn("Twilio account is not connected"); logger.warn("Twilio account is not connected");
return json({ phoneNumbers: [] }); return json({ phoneNumbers: [] });
} }
const phoneNumbers = await db.phoneNumber.findMany({ const phoneNumbers = await db.phoneNumber.findMany({
where: { twilioAccount: { organizationId: organization.id } }, where: { twilioAccount: { accountSid: twilio.accountSid } },
select: { id: true, number: true, isCurrent: true }, select: { id: true, number: true, isCurrent: true },
orderBy: { id: Prisma.SortOrder.desc }, orderBy: { id: Prisma.SortOrder.desc },
}); });

View File

@ -1,21 +0,0 @@
import sendEmail from "~/utils/mailer.server";
import serverConfig from "~/config/config.server";
import { render } from "./renderer/renderer.server";
type Params = {
to: string;
token: string;
userName: string;
};
export async function sendForgotPasswordEmail({ to, token, userName }: Params) {
const origin = serverConfig.app.baseUrl;
const resetUrl = `${origin}/reset-password?token=${token}`;
const html = await render("forgot-password", { action_url: resetUrl, name: userName });
return sendEmail({
recipients: to,
subject: "Reset your password",
html,
});
}

View File

@ -1,16 +0,0 @@
<tr>
<td>
<table align="center" class="email-footer w-570 mx-auto text-center sm:w-full">
<tr>
<td align="center" class="content-cell p-45 text-base">
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
&copy; {{ page.year }} {{ page.company.product }}. All rights reserved.
</p>
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
{{ page.company.name }} {{{ page.company.address }}}
</p>
</td>
</tr>
</table>
</td>
</tr>

View File

@ -1,15 +0,0 @@
<tr>
<td align="center" class="email-masthead">
<a
href="https://remixtape.dev"
class="email-masthead_name text-base font-bold no-underline text-gray-postmark-light"
style="text-shadow: 0 1px 0 #ffffff"
>
<img
width="128px"
src="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📼</text></svg>"
alt="Your logo"
/>
</a>
</td>
</tr>

View File

@ -1,32 +0,0 @@
.button {
@apply inline-block text-white no-underline;
background-color: #3869d4;
border-top: 10px solid #3869d4;
border-right: 18px solid #3869d4;
border-bottom: 10px solid #3869d4;
border-left: 18px solid #3869d4;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
}
.button--green {
background-color: #22bc66;
border-top: 10px solid #22bc66;
border-right: 18px solid #22bc66;
border-bottom: 10px solid #22bc66;
border-left: 18px solid #22bc66;
}
.button--red {
background-color: #ff6136;
border-top: 10px solid #ff6136;
border-right: 18px solid #ff6136;
border-bottom: 10px solid #ff6136;
border-left: 18px solid #ff6136;
}
@screen sm {
.button {
@apply w-full text-center !important;
}
}

View File

@ -1,65 +0,0 @@
@import "buttons";
.purchase_heading {
border-bottom-width: 1px;
border-bottom-color: #eaeaec;
border-bottom-style: solid;
}
.purchase_heading p {
@apply text-xxs leading-24 m-0;
color: #85878e;
}
.purchase_footer {
@apply pt-16 text-base align-middle;
border-top-width: 1px;
border-top-color: #eaeaec;
border-top-style: solid;
}
.body-sub {
@apply mt-25 pt-25 border-t;
border-top-color: #eaeaec;
border-top-style: solid;
}
.discount {
@apply w-full p-24 bg-gray-postmark-lightest;
border: 2px dashed #cbcccf;
}
.email-masthead {
@apply py-24 text-base text-center;
}
@screen dark {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
@apply bg-gray-postmark-darker text-white !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
@apply text-white !important;
}
.attributes_content,
.discount {
@apply bg-gray-postmark-darkest !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}

View File

@ -1,10 +0,0 @@
body {
@apply m-0 p-0 w-full;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
img {
border: 0;
@apply max-w-full leading-full align-middle;
}

View File

@ -1,3 +0,0 @@
.mso-leading-exactly {
mso-line-height-rule: exactly;
}

View File

@ -1,75 +0,0 @@
<!DOCTYPE {{{ page.doctype || 'html' }}}>
<html lang="{{ page.language || 'en' }}" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="{{ page.charset || 'utf-8' }}" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Segoe UI", sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<if condition="page.title">
<title>{{{ page.title }}}</title>
</if>
<if condition="page.googleFonts">
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?{{{ page.googleFonts }}}&display=swap"
rel="stylesheet"
media="screen"
/>
</if>
<if condition="page.css">
<style>
{{{ page.css }}}
</style>
</if>
<block name="head"></block>
</head>
<body class="{{ page.bodyClass }}">
<if condition="page.preheader">
<div class="hidden">
{{{ page.preheader }}}&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&zwnj; &#160;&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847;
</div>
</if>
<div
role="article"
aria-roledescription="email"
aria-label="{{{ page.title || '' }}}"
lang="{{ page.language || 'en' }}"
>
<block name="template"></block>
</div>
</body>
</html>

View File

@ -1,93 +0,0 @@
---
bodyClass: bg-gray-postmark-lighter
---
<extends src="app/mailers/renderer/html/layouts/main.html">
<block name="template">
<table class="email-wrapper w-full bg-gray-postmark-lighter font-sans">
<tr>
<td align="center">
<table class="email-content w-full">
<component src="app/mailers/renderer/html/components/header.html"></component>
<tr>
<td class="email-body w-full">
<table align="center" class="email-body_inner w-570 bg-white mx-auto sm:w-full">
<tr>
<td class="px-45 py-24">
<div class="text-base">
<h1 class="mt-0 text-2xl font-bold text-left text-gray-postmark-darker">
Hi {{ name }},
</h1>
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
You recently requested to reset your password for your
{{ page.company.product }} account. Use the button below to reset it.
<strong
>This password reset is only valid for the next 24
hours.</strong
>
</p>
<table align="center" class="w-full text-center my-30 mx-auto">
<tr>
<td align="center">
<table class="w-full">
<tr>
<td align="center" class="text-base">
<a
href="{{ action_url }}"
class="button button--green"
target="_blank"
>
Reset your password
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
If you did not request a password reset, you can safely ignore this
email.
</p>
<table class="body-sub">
<tr>
<td>
<p
class="
mt-6
mb-20
text-xs
leading-24
text-gray-postmark-dark
"
>
If you're having trouble with the button above, copy and
paste the URL below into your web browser.
</p>
<p
class="
mt-6
mb-20
text-xs
leading-24
text-gray-postmark-dark
"
>
{{ action_url }}
</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<!--<component src="app/mailers/renderer/html/components/footer.html"></component>-->
</table>
</td>
</tr>
</table>
</block>
</extends>

View File

@ -1,18 +0,0 @@
/* Your custom CSS resets for email */
@import "app/mailers/renderer/html/custom/reset";
/* Tailwind components that are generated by plugins */
@import "tailwindcss/components";
/**
* @import here any custom components - classes that you'd want loaded
* before the Tailwind utilities, so that the utilities could still
* override them.
*/
@import "app/mailers/renderer/html/custom/postmark";
/* Tailwind utility classes */
@import "tailwindcss/utilities";
/* Your custom utility classes */
@import "app/mailers/renderer/html/custom/utilities";

View File

@ -1,81 +0,0 @@
---
bodyClass: bg-gray-postmark-lighter
---
<extends src="app/mailers/renderer/html/layouts/main.html">
<block name="template">
<table class="email-wrapper w-full bg-gray-postmark-lighter font-sans">
<tr>
<td align="center">
<table class="email-content w-full">
<component src="app/mailers/renderer/html/components/header.html"></component>
<tr>
<td class="email-body w-full">
<table align="center" class="email-body_inner w-570 bg-white mx-auto sm:w-full">
<tr>
<td class="px-45 py-24">
<div class="text-base">
<h1 class="mt-0 text-2xl font-bold text-left text-gray-postmark-darker">
Hi 👋,
</h1>
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
{{ invitation_sender_name }} from {{ invitation_sender_organization_name }}
has invited you to join their organization.
Use the button below to set up your account and get started.
<strong>
This invitation is only valid for the next 7 days.
</strong>
</p>
<table align="center" class="w-full text-center my-30 mx-auto">
<tr>
<td align="center">
<table class="w-full">
<tr>
<td align="center" class="text-base">
<a
href="{{ action_url }}"
class="button button--green"
target="_blank"
>
Set up account
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
Welcome aboard,
<br />The {{ page.company.product }} Team
</p>
<table class="body-sub">
<tr>
<td>
<p
class="mt-6 mb-20 text-xs leading-24 text-gray-postmark-dark"
>
If you're having trouble with the button above, copy and
paste the URL below into your web browser.
</p>
<p
class="mt-6 mb-20 text-xs leading-24 text-gray-postmark-dark"
>
{{ action_url }}
</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<!--<component src="app/mailers/renderer/html/components/footer.html"></component>-->
</table>
</td>
</tr>
</table>
</block>
</extends>

View File

@ -1,219 +0,0 @@
import fs from "fs";
import path from "path";
// @ts-ignore
import Maizzle from "@maizzle/framework";
export async function render(templateName: string, locals: Record<string, string> = {}) {
const { template, options } = getMaizzleParams(templateName, locals);
const { html } = await Maizzle.render(template, options);
return html;
}
function getMaizzleParams(templateName: string, locals: Record<string, string>) {
const template = fs
.readFileSync(path.resolve(process.cwd(), "./app/mailers/renderer/html/templates", `${templateName}.html`))
.toString();
const tailwindCss = fs
.readFileSync(path.resolve(process.cwd(), "./app/mailers/renderer/html/templates/tailwind.css"))
.toString();
const options = {
tailwind: {
css: tailwindCss,
config: {
mode: "jit",
theme: {
screens: {
sm: { max: "600px" },
dark: { raw: "(prefers-color-scheme: dark)" },
},
extend: {
colors: {
gray: {
"postmark-lightest": "#F4F4F7",
"postmark-lighter": "#F2F4F6",
"postmark-light": "#A8AAAF",
"postmark-dark": "#51545E",
"postmark-darker": "#333333",
"postmark-darkest": "#222222",
"postmark-meta": "#85878E",
},
blue: {
postmark: "#3869D4",
},
},
spacing: {
screen: "100vw",
full: "100%",
px: "1px",
0: "0",
2: "2px",
3: "3px",
4: "4px",
5: "5px",
6: "6px",
7: "7px",
8: "8px",
9: "9px",
10: "10px",
11: "11px",
12: "12px",
14: "14px",
16: "16px",
20: "20px",
21: "21px",
24: "24px",
25: "25px",
28: "28px",
30: "30px",
32: "32px",
35: "35px",
36: "36px",
40: "40px",
44: "44px",
45: "45px",
48: "48px",
52: "52px",
56: "56px",
60: "60px",
64: "64px",
72: "72px",
80: "80px",
96: "96px",
570: "570px",
600: "600px",
"1/2": "50%",
"1/3": "33.333333%",
"2/3": "66.666667%",
"1/4": "25%",
"2/4": "50%",
"3/4": "75%",
"1/5": "20%",
"2/5": "40%",
"3/5": "60%",
"4/5": "80%",
"1/6": "16.666667%",
"2/6": "33.333333%",
"3/6": "50%",
"4/6": "66.666667%",
"5/6": "83.333333%",
"1/12": "8.333333%",
"2/12": "16.666667%",
"3/12": "25%",
"4/12": "33.333333%",
"5/12": "41.666667%",
"6/12": "50%",
"7/12": "58.333333%",
"8/12": "66.666667%",
"9/12": "75%",
"10/12": "83.333333%",
"11/12": "91.666667%",
},
borderRadius: {
none: "0px",
sm: "2px",
DEFAULT: "4px",
md: "6px",
lg: "8px",
xl: "12px",
"2xl": "16px",
"3xl": "24px",
full: "9999px",
},
fontFamily: {
sans: ['"Nunito Sans"', "-apple-system", '"Segoe UI"', "sans-serif"],
serif: ["Constantia", "Georgia", "serif"],
mono: ["Menlo", "Consolas", "monospace"],
},
fontSize: {
0: "0",
xxs: "12px",
xs: "13px",
sm: "14px",
base: "16px",
lg: "18px",
xl: "20px",
"2xl": "24px",
"3xl": "30px",
"4xl": "36px",
"5xl": "48px",
"6xl": "60px",
"7xl": "72px",
"8xl": "96px",
"9xl": "128px",
},
inset: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
letterSpacing: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
lineHeight: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
maxHeight: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
maxWidth: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
xs: "160px",
sm: "192px",
md: "224px",
lg: "256px",
xl: "288px",
"2xl": "336px",
"3xl": "384px",
"4xl": "448px",
"5xl": "512px",
"6xl": "576px",
"7xl": "640px",
}),
minHeight: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
minWidth: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
},
},
corePlugins: {
animation: false,
backgroundOpacity: false,
borderOpacity: false,
divideOpacity: false,
placeholderOpacity: false,
textOpacity: false,
},
},
},
maizzle: {
build: {
posthtml: {
expressions: {
locals,
},
},
},
company: {
name: "Capsule Corp.",
address: `<br>39 Robinson Rd, #11-01<br>Singapore 068911`,
product: "Remixtape",
sender: "Mokhtar",
mailto: "mokhtar@remixtape.dev",
},
googleFonts: "family=Nunito+Sans:wght@400;700",
year: () => new Date().getFullYear(),
inlineCSS: true,
prettify: true,
removeUnusedCSS: true,
},
};
return {
template,
options,
};
}
type TailwindThemeHelper = (str: string) => {};

View File

@ -1,26 +0,0 @@
import sendEmail from "~/utils/mailer.server";
import serverConfig from "~/config/config.server";
import { render } from "./renderer/renderer.server";
type Params = {
to: string;
token: string;
userName: string;
organizationName: string;
};
export async function sendTeamInvitationEmail({ to, token, userName, organizationName }: Params) {
const origin = serverConfig.app.baseUrl;
const invitationUrl = `${origin}/accept-invitation?token=${token}`;
const html = await render("team-invitation", {
action_url: invitationUrl,
invitation_sender_name: userName,
invitation_sender_organization_name: organizationName,
});
return sendEmail({
recipients: to,
subject: `${userName} has invited you to work with them in Remixtape`,
html,
});
}

View File

@ -1,51 +0,0 @@
import { MembershipRole } from "@prisma/client";
import { Queue } from "~/utils/queue.server";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { deleteOrganizationEntities } from "~/utils/organization.server";
type Payload = {
userId: string;
};
export default Queue<Payload>("delete user data", async ({ data }) => {
const { userId } = data;
const user = await db.user.findUnique({
where: { id: userId },
include: {
memberships: {
include: { organization: true },
},
},
});
if (!user) {
return;
}
await Promise.all(
user.memberships.map(async (membership) => {
switch (membership.role) {
case MembershipRole.OWNER: {
await deleteOrganizationEntities(membership.organization);
break;
}
case MembershipRole.USER: {
await db.membership.delete({ where: { id: membership.id } });
break;
}
}
}),
);
try {
await db.user.delete({ where: { id: user.id } });
} catch (error: any) {
if (error.code === "P2025") {
logger.warn("Could not delete user because it has already been deleted");
return;
}
throw error;
}
});

View File

@ -1,4 +1,3 @@
import deleteUserDataQueue from "./delete-user-data.server";
import fetchPhoneCallsQueue from "./fetch-phone-calls.server"; import fetchPhoneCallsQueue from "./fetch-phone-calls.server";
import insertPhoneCallsQueue from "./insert-phone-calls.server"; import insertPhoneCallsQueue from "./insert-phone-calls.server";
import fetchMessagesQueue from "./fetch-messages.server"; import fetchMessagesQueue from "./fetch-messages.server";
@ -7,7 +6,6 @@ import setTwilioWebhooksQueue from "./set-twilio-webhooks.server";
import setTwilioApiKeyQueue from "./set-twilio-api-key.server"; import setTwilioApiKeyQueue from "./set-twilio-api-key.server";
export default [ export default [
deleteUserDataQueue,
fetchPhoneCallsQueue, fetchPhoneCallsQueue,
insertPhoneCallsQueue, insertPhoneCallsQueue,
fetchMessagesQueue, fetchMessagesQueue,

View File

@ -16,15 +16,7 @@ export default Queue<Payload>("notify incoming message", async ({ data }) => {
where: { id: phoneNumberId }, where: { id: phoneNumberId },
select: { select: {
twilioAccount: { twilioAccount: {
include: { include: { notificationSubscriptions: true },
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
},
},
},
},
}, },
}, },
}); });
@ -32,10 +24,7 @@ export default Queue<Payload>("notify incoming message", async ({ data }) => {
logger.warn(`No phone number found with id=${phoneNumberId}`); logger.warn(`No phone number found with id=${phoneNumberId}`);
return; return;
} }
const subscriptions = phoneNumber.twilioAccount.organization.memberships.flatMap( const subscriptions = phoneNumber.twilioAccount.notificationSubscriptions;
(membership) => membership.notificationSubscription,
);
const twilioClient = getTwilioClient(phoneNumber.twilioAccount); const twilioClient = getTwilioClient(phoneNumber.twilioAccount);
const message = await twilioClient.messages.get(messageSid).fetch(); const message = await twilioClient.messages.get(messageSid).fetch();
const payload = buildMessageNotificationPayload(message); const payload = buildMessageNotificationPayload(message);

View File

@ -8,13 +8,12 @@ import { decrypt } from "~/utils/encryption";
type Payload = { type Payload = {
phoneNumberId: string; phoneNumberId: string;
organizationId: string;
}; };
export default Queue<Payload>("set twilio webhooks", async ({ data }) => { export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
const { phoneNumberId, organizationId } = data; const { phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findFirst({ const phoneNumber = await db.phoneNumber.findFirst({
where: { id: phoneNumberId, twilioAccount: { organizationId } }, where: { id: phoneNumberId },
include: { include: {
twilioAccount: { twilioAccount: {
select: { accountSid: true, twimlAppSid: true, authToken: true }, select: { accountSid: true, twimlAppSid: true, authToken: true },
@ -33,7 +32,7 @@ export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
await Promise.all([ await Promise.all([
db.twilioAccount.update({ db.twilioAccount.update({
where: { organizationId }, where: { accountSid: twilioAccount.accountSid },
data: { twimlAppSid }, data: { twimlAppSid },
}), }),
twilioClient.incomingPhoneNumbers.get(phoneNumber.id).update({ twilioClient.incomingPhoneNumbers.get(phoneNumber.id).update({

View File

@ -2,6 +2,7 @@ import { type LinksFunction, type LoaderFunction, json } from "@remix-run/node";
import { Outlet, useCatch, useMatches } from "@remix-run/react"; import { Outlet, useCatch, useMatches } from "@remix-run/react";
import serverConfig from "~/config/config.server"; import serverConfig from "~/config/config.server";
import type { SessionData } from "~/utils/session.server";
import Footer from "~/features/core/components/footer"; import Footer from "~/features/core/components/footer";
import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier"; import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier";
import Notification from "~/features/core/components/notification"; import Notification from "~/features/core/components/notification";
@ -9,6 +10,7 @@ import useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker
import useDevice from "~/features/phone-calls/hooks/use-device"; import useDevice from "~/features/phone-calls/hooks/use-device";
import footerStyles from "~/features/core/components/footer.css"; import footerStyles from "~/features/core/components/footer.css";
import appStyles from "~/styles/app.css"; import appStyles from "~/styles/app.css";
import { getSession } from "~/utils/session.server";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: "stylesheet", href: appStyles }, { rel: "stylesheet", href: appStyles },
@ -16,11 +18,15 @@ export const links: LinksFunction = () => [
]; ];
export type AppLoaderData = { export type AppLoaderData = {
sessionData: SessionData;
config: { webPushPublicKey: string }; config: { webPushPublicKey: string };
}; };
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const session = await getSession(request);
return json<AppLoaderData>({ return json<AppLoaderData>({
sessionData: { twilio: session.data.twilio },
config: { config: {
webPushPublicKey: serverConfig.webPush.publicKey, webPushPublicKey: serverConfig.webPush.publicKey,
}, },

View File

@ -3,7 +3,6 @@ import { useLoaderData } from "superjson-remix";
import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials"; import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials";
import PageTitle from "~/features/core/components/page-title"; import PageTitle from "~/features/core/components/page-title";
import InactiveSubscription from "~/features/core/components/inactive-subscription";
import PhoneCallsList from "~/features/phone-calls/components/phone-calls-list"; import PhoneCallsList from "~/features/phone-calls/components/phone-calls-list";
import callsLoader, { type PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls"; import callsLoader, { type PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
import { getSeoMeta } from "~/utils/seo"; import { getSeoMeta } from "~/utils/seo";
@ -15,7 +14,7 @@ export const meta: MetaFunction = () => ({
export const loader = callsLoader; export const loader = callsLoader;
export default function PhoneCalls() { export default function PhoneCalls() {
const { hasPhoneNumber, hasOngoingSubscription } = useLoaderData<PhoneCallsLoaderData>(); const { hasPhoneNumber } = useLoaderData<PhoneCallsLoaderData>();
if (!hasPhoneNumber) { if (!hasPhoneNumber) {
return ( return (
@ -26,20 +25,6 @@ export default function PhoneCalls() {
); );
} }
if (!hasOngoingSubscription) {
return (
<>
<InactiveSubscription />
<div className="filter blur-sm select-none absolute top-0 w-full h-full z-0">
<PageTitle title="Calls" />
<section className="relative flex flex-grow flex-col">
<PhoneCallsList />
</section>
</div>
</>
);
}
return ( return (
<> <>
<PageTitle className="pl-12" title="Calls" /> <PageTitle className="pl-12" title="Calls" />

View File

@ -11,7 +11,6 @@ import useOnBackspacePress from "~/features/keypad/hooks/use-on-backspace-press"
import Keypad from "~/features/keypad/components/keypad"; import Keypad from "~/features/keypad/components/keypad";
import BlurredKeypad from "~/features/keypad/components/blurred-keypad"; import BlurredKeypad from "~/features/keypad/components/blurred-keypad";
import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials"; import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials";
import InactiveSubscription from "~/features/core/components/inactive-subscription";
import { getSeoMeta } from "~/utils/seo"; import { getSeoMeta } from "~/utils/seo";
import { usePhoneNumber, usePressDigit, useRemoveDigit } from "~/features/keypad/hooks/atoms"; import { usePhoneNumber, usePressDigit, useRemoveDigit } from "~/features/keypad/hooks/atoms";
@ -22,17 +21,13 @@ export const meta: MetaFunction = () => ({
export const loader = keypadLoader; export const loader = keypadLoader;
export default function KeypadPage() { export default function KeypadPage() {
const { hasOngoingSubscription, hasPhoneNumber, lastRecipientCalled } = useLoaderData<KeypadLoaderData>(); const { hasPhoneNumber, lastRecipientCalled } = useLoaderData<KeypadLoaderData>();
const navigate = useNavigate(); const navigate = useNavigate();
const [phoneNumber, setPhoneNumber] = usePhoneNumber(); const [phoneNumber, setPhoneNumber] = usePhoneNumber();
const removeDigit = useRemoveDigit(); const removeDigit = useRemoveDigit();
const pressDigit = usePressDigit(); const pressDigit = usePressDigit();
const onBackspacePress = useOnBackspacePress(); const onBackspacePress = useOnBackspacePress();
useKeyPress((key) => { useKeyPress((key) => {
if (!hasOngoingSubscription) {
return;
}
if (key === "Backspace") { if (key === "Backspace") {
return removeDigit(); return removeDigit();
} }
@ -49,15 +44,6 @@ export default function KeypadPage() {
); );
} }
if (!hasOngoingSubscription) {
return (
<>
<InactiveSubscription />
<BlurredKeypad />
</>
);
}
return ( return (
<> <>
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black"> <div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black">
@ -68,7 +54,7 @@ export default function KeypadPage() {
<Keypad> <Keypad>
<button <button
onClick={async () => { onClick={async () => {
if (!hasPhoneNumber || !hasOngoingSubscription) { if (!hasPhoneNumber) {
return; return;
} }

View File

@ -4,7 +4,6 @@ import clsx from "clsx";
import { import {
IoLogOutOutline, IoLogOutOutline,
IoNotificationsOutline, IoNotificationsOutline,
IoCardOutline,
IoCallOutline, IoCallOutline,
IoPersonCircleOutline, IoPersonCircleOutline,
IoHelpBuoyOutline, IoHelpBuoyOutline,
@ -14,11 +13,8 @@ import Divider from "~/features/settings/components/divider";
import { getSeoMeta } from "~/utils/seo"; import { getSeoMeta } from "~/utils/seo";
const subNavigation = [ const subNavigation = [
{ name: "Account", to: "/settings/account", icon: IoPersonCircleOutline },
{ name: "Phone", to: "/settings/phone", icon: IoCallOutline }, { name: "Phone", to: "/settings/phone", icon: IoCallOutline },
{ name: "Billing", to: "/settings/billing", icon: IoCardOutline },
{ name: "Notifications", to: "/settings/notifications", icon: IoNotificationsOutline }, { name: "Notifications", to: "/settings/notifications", icon: IoNotificationsOutline },
{ name: "Support", to: "/settings/support", icon: IoHelpBuoyOutline },
]; ];
export const meta: MetaFunction = () => ({ export const meta: MetaFunction = () => ({
@ -62,15 +58,6 @@ export default function SettingsLayout() {
)} )}
</NavLink> </NavLink>
))} ))}
<Divider />
<Link
to="/sign-out"
className="group text-gray-900 hover:text-gray-900 hover:bg-gray-50 rounded-md px-3 py-2 flex items-center text-sm font-medium"
>
<IoLogOutOutline className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
Log out
</Link>
</nav> </nav>
</aside> </aside>

View File

@ -1,18 +0,0 @@
import accountAction from "~/features/settings/actions/account";
import ProfileInformations from "~/features/settings/components/account/profile-informations";
import UpdatePassword from "~/features/settings/components/account/update-password";
import DangerZone from "~/features/settings/components/account/danger-zone";
export const action = accountAction;
export default function Account() {
return (
<div className="flex flex-col space-y-6">
<ProfileInformations />
<UpdatePassword />
<DangerZone />
</div>
);
}

View File

@ -1,66 +0,0 @@
import { SubscriptionStatus } from "@prisma/client";
import usePaymentsHistory from "~/features/settings/hooks/use-payments-history";
import SettingsSection from "~/features/settings/components/settings-section";
import BillingHistory from "~/features/settings/components/billing/billing-history";
import Divider from "~/features/settings/components/divider";
import Plans from "~/features/settings/components/billing/plans";
import PaddleLink from "~/features/settings/components/billing/paddle-link";
function useSubscription() {
return {
subscription: null as any,
cancelSubscription: () => void 0,
updatePaymentMethod: () => void 0,
};
}
export default function Billing() {
const { count: paymentsCount } = usePaymentsHistory();
const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription();
return (
<>
{subscription ? (
<SettingsSection>
{subscription.status === SubscriptionStatus.deleted ? (
<p>
Your {plansName[subscription.paddlePlanId]?.toLowerCase()} subscription is cancelled and
will expire on {subscription.cancellationEffectiveDate!.toLocaleDateString()}.
</p>
) : (
<>
<p>Current plan: {subscription.paddlePlanId}</p>
<PaddleLink
onClick={() => updatePaymentMethod(/*{ updateUrl: subscription.updateUrl }*/)}
text="Update payment method"
/>
<PaddleLink
onClick={() => cancelSubscription(/*{ cancelUrl: subscription.cancelUrl }*/)}
text="Cancel subscription"
/>
</>
)}
</SettingsSection>
) : null}
{paymentsCount > 0 ? (
<>
<BillingHistory />
<div className="hidden lg:block lg:py-3">
<Divider />
</div>
</>
) : null}
<Plans />
<p className="text-sm text-gray-500">Prices include all applicable sales taxes.</p>
</>
);
}
const plansName: Record<number, string> = {
727544: "Yearly",
727540: "Monthly",
};

View File

@ -1,4 +1,4 @@
import type { LoaderFunction } from "@remix-run/node"; import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node"; import { redirect } from "@remix-run/node";
export const loader: LoaderFunction = () => redirect("/settings/account"); export const loader: LoaderFunction = () => redirect("/settings/phone");

View File

@ -1,9 +0,0 @@
export default function SupportPage() {
return (
<div>
<a className="underline" href="mailto:support@shellphone.app">
Email us
</a>
</div>
);
}

View File

@ -1,16 +0,0 @@
import { Link, Outlet } from "@remix-run/react";
import Logo from "~/features/core/components/logo";
export default function AuthLayout() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 px-8">
<div className="mx-auto">
<Link to="/" prefetch="intent">
<Logo className="mx-auto w-16" />
</Link>
</div>
<Outlet />
</div>
);
}

View File

@ -1,15 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import ForgotPasswordPage from "~/features/auth/pages/forgot-password";
import forgotPasswordAction from "~/features/auth/actions/forgot-password";
import forgotPasswordLoader from "~/features/auth/loaders/forgot-password";
import { getSeoMeta } from "~/utils/seo";
export default ForgotPasswordPage;
export const action = forgotPasswordAction;
export const loader = forgotPasswordLoader;
export const meta: MetaFunction = () => ({
...getSeoMeta({ title: "Forgot password" }),
robots: "noindex",
googlebot: "noindex",
});

View File

@ -1,15 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import RegisterPage from "~/features/auth/pages/register";
import registerAction from "~/features/auth/actions/register";
import registerLoader from "~/features/auth/loaders/register";
import { getSeoMeta } from "~/utils/seo";
export default RegisterPage;
export const action = registerAction;
export const loader = registerLoader;
export const meta: MetaFunction = () => ({
...getSeoMeta({ title: "Register" }),
robots: "noindex",
googlebot: "noindex",
});

View File

@ -1,15 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import ResetPasswordPage from "~/features/auth/pages/reset-password";
import resetPasswordAction from "~/features/auth/actions/reset-password";
import resetPasswordLoader from "~/features/auth/loaders/reset-password";
import { getSeoMeta } from "~/utils/seo";
export default ResetPasswordPage;
export const action = resetPasswordAction;
export const loader = resetPasswordLoader;
export const meta: MetaFunction = () => ({
...getSeoMeta({ title: "Reset password" }),
robots: "noindex",
googlebot: "noindex",
});

View File

@ -1,15 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
import SignInPage from "~/features/auth/pages/sign-in";
import signInAction from "~/features/auth/actions/sign-in";
import signInLoader from "~/features/auth/loaders/sign-in";
import { getSeoMeta } from "~/utils/seo";
export default SignInPage;
export const action = signInAction;
export const loader = signInLoader;
export const meta: MetaFunction = () => ({
...getSeoMeta({ title: "Sign in" }),
robots: "noindex",
googlebot: "noindex",
});

View File

@ -1,9 +0,0 @@
import type { LoaderFunction } from "@remix-run/node";
import authenticator from "~/utils/authenticator.server";
export const loader: LoaderFunction = async ({ request }) => {
const searchParams = new URL(request.url).searchParams;
const redirectTo = searchParams.get("redirectTo") ?? "/";
await authenticator.logout(request, { redirectTo });
};

View File

@ -26,7 +26,7 @@ export const loader: LoaderFunction = async ({ request }) => {
throw new Error(`Queue "${queueName}"'s scheduler is not running`); throw new Error(`Queue "${queueName}"'s scheduler is not running`);
} }
}), }),
db.user.count(), db.twilioAccount.count(),
fetch(url.toString(), { method: "HEAD" }).then((r) => { fetch(url.toString(), { method: "HEAD" }).then((r) => {
if (!r.ok) return Promise.reject(r); if (!r.ok) return Promise.reject(r);
}), }),

View File

@ -1,17 +1,5 @@
import type { LinksFunction, MetaFunction } from "@remix-run/node"; import { type LoaderArgs, redirect } from "@remix-run/node";
import joinWaitlistAction from "~/features/public-area/actions/index"; export async function loader({ request }: LoaderArgs) {
import IndexPage from "~/features/public-area/pages/index"; return redirect("/messages");
import { getSeoMeta } from "~/utils/seo"; }
import styles from "../styles/index.css";
export const action = joinWaitlistAction;
export const meta: MetaFunction = () => ({
...getSeoMeta({ title: "", description: "Welcome to Remixtape!" }),
});
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
export default IndexPage;

View File

@ -1,7 +1,7 @@
import { type ActionFunction } from "@remix-run/node"; import { type ActionFunction } from "@remix-run/node";
import { badRequest, serverError } from "remix-utils"; import { badRequest, serverError } from "remix-utils";
import { z } from "zod"; import { z } from "zod";
import { Direction, Prisma, SubscriptionStatus } from "@prisma/client"; import { Direction, Prisma } from "@prisma/client";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
@ -42,29 +42,7 @@ async function handleIncomingCall(formData: unknown, twilioSignature: string) {
twilioAccountSid: body.AccountSid, twilioAccountSid: body.AccountSid,
}, },
include: { include: {
twilioAccount: { twilioAccount: true,
include: {
organization: {
select: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
memberships: {
select: { user: true },
},
},
},
},
},
}, },
}); });
if (!phoneNumber) { if (!phoneNumber) {
@ -72,13 +50,6 @@ async function handleIncomingCall(formData: unknown, twilioSignature: string) {
return new Response(null, { status: 402 }); return new Response(null, { status: 402 });
} }
if (phoneNumber.twilioAccount.organization.subscriptions.length === 0) {
// decline the outgoing call because
// the organization is on the free plan
console.log("no active subscription"); // TODO: uncomment the line below
// return new Response(null, { status: 402 });
}
const encryptedAuthToken = phoneNumber.twilioAccount.authToken; const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
if (!phoneNumber || !encryptedAuthToken || !twilio.validateRequest(authToken, twilioSignature, voiceUrl, body)) { if (!phoneNumber || !encryptedAuthToken || !twilio.validateRequest(authToken, twilioSignature, voiceUrl, body)) {
@ -99,8 +70,7 @@ async function handleIncomingCall(formData: unknown, twilioSignature: string) {
}); });
// await notify(); TODO // await notify(); TODO
const user = phoneNumber.twilioAccount.organization.memberships[0].user!; const identity = `shellphone__${phoneNumber.twilioAccount.accountSid}`;
const identity = `${phoneNumber.twilioAccount.accountSid}__${user.id}`;
const voiceResponse = new twilio.twiml.VoiceResponse(); const voiceResponse = new twilio.twiml.VoiceResponse();
const dial = voiceResponse.dial({ answerOnBridge: true }); const dial = voiceResponse.dial({ answerOnBridge: true });
dial.client(identity); // TODO: si le device n'est pas registered => call failed *shrug* dial.client(identity); // TODO: si le device n'est pas registered => call failed *shrug*
@ -118,32 +88,15 @@ async function handleOutgoingCall(formData: unknown, twilioSignature: string) {
const body = validation.data; const body = validation.data;
const recipient = body.To; const recipient = body.To;
const accountSid = body.From.slice("client:".length).split("__")[0]; const accountSid = body.From.slice("client:".length).split("__")[1];
try { try {
const twilioAccount = await db.twilioAccount.findUnique({ const twilioAccount = await db.twilioAccount.findUnique({
where: { accountSid }, where: { accountSid },
include: {
organization: {
select: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
},
},
},
}); });
if (!twilioAccount) { if (!twilioAccount) {
// this shouldn't be happening // this shouldn't be happening
logger.warn("this shouldn't be happening, no twilio account found");
return new Response(null, { status: 402 }); return new Response(null, { status: 402 });
} }
@ -152,16 +105,12 @@ async function handleOutgoingCall(formData: unknown, twilioSignature: string) {
}); });
if (!phoneNumber) { if (!phoneNumber) {
// this shouldn't be happening // this shouldn't be happening
logger.warn(
`this shouldn't be happening, no phone number found for twilio account ${twilioAccount.accountSid}`,
);
return new Response(null, { status: 402 }); return new Response(null, { status: 402 });
} }
if (twilioAccount.organization.subscriptions.length === 0) {
// decline the outgoing call because
// the organization is on the free plan
console.log("no active subscription"); // TODO: uncomment the line below
// return new Response(null, { status: 402 });
}
const encryptedAuthToken = twilioAccount.authToken; const encryptedAuthToken = twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
if ( if (

View File

@ -2,7 +2,6 @@ import { type ActionFunction } from "@remix-run/node";
import { badRequest, html, notFound, serverError } from "remix-utils"; import { badRequest, html, notFound, serverError } from "remix-utils";
import twilio from "twilio"; import twilio from "twilio";
import { z } from "zod"; import { z } from "zod";
import { Prisma, SubscriptionStatus } from "@prisma/client";
import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server"; import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server";
import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server"; import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server";
@ -30,26 +29,7 @@ export const action: ActionFunction = async ({ request }) => {
const phoneNumbers = await db.phoneNumber.findMany({ const phoneNumbers = await db.phoneNumber.findMany({
where: { number: body.To }, where: { number: body.To },
include: { include: {
twilioAccount: { twilioAccount: true,
include: {
organization: {
select: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
},
},
},
},
}, },
}); });
if (phoneNumbers.length === 0) { if (phoneNumbers.length === 0) {

View File

@ -1,204 +0,0 @@
import { redirect, type Session } from "@remix-run/node";
import type { FormStrategyVerifyParams } from "remix-auth-form";
import SecurePassword from "secure-password";
import type { MembershipRole, Organization, TwilioAccount, User } from "@prisma/client";
import db from "./db.server";
import logger from "./logger.server";
import authenticator from "./authenticator.server";
import { AuthenticationError, NotFoundError } from "./errors";
import { commitSession, destroySession, getSession } from "./session.server";
type SessionTwilioAccount = Pick<TwilioAccount, "accountSid" | "authToken">;
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole; membershipId: string };
export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">;
export type SessionData = {
user: SessionUser;
organization: SessionOrganization;
twilio: SessionTwilioAccount | null;
};
const SP = new SecurePassword();
export async function login({ form }: FormStrategyVerifyParams): Promise<SessionData> {
const email = form.get("email");
const password = form.get("password");
const isEmailValid = typeof email === "string" && email.length > 0;
const isPasswordValid = typeof password === "string" && password.length > 0;
if (!isEmailValid && !isPasswordValid) {
throw new AuthenticationError("Email and password are required");
}
if (!isEmailValid) {
throw new AuthenticationError("Email is required");
}
if (!isPasswordValid) {
throw new AuthenticationError("Password is required");
}
const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user || !user.hashedPassword) {
throw new AuthenticationError("Incorrect password");
}
switch (await verifyPassword(user.hashedPassword, password)) {
case SecurePassword.VALID:
break;
case SecurePassword.VALID_NEEDS_REHASH:
// Upgrade hashed password with a more secure hash
const improvedHash = await hashPassword(password);
await db.user.update({ where: { id: user.id }, data: { hashedPassword: improvedHash } });
break;
default:
logger.warn(`Tried to log into account with email=${email.toLowerCase()} with an incorrect password`);
throw new AuthenticationError("Incorrect password");
}
try {
return await buildSessionData(user.id);
} catch (error: any) {
logger.error(error);
if (error instanceof AuthenticationError) {
throw error;
}
throw new AuthenticationError("Incorrect password");
}
}
export async function verifyPassword(hashedPassword: string, password: string) {
try {
return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"));
} catch (error) {
logger.error(error);
return false;
}
}
export async function hashPassword(password: string) {
const hashedBuffer = await SP.hash(Buffer.from(password));
return hashedBuffer.toString("base64");
}
type AuthenticateParams = {
email: string;
password: string;
request: Request;
successRedirect?: string | null;
failureRedirect?: string;
};
export async function authenticate({
email,
password,
request,
successRedirect,
failureRedirect = "/sign-in",
}: AuthenticateParams) {
const body = new URLSearchParams({ email, password });
const signInRequest = new Request(request.url, {
body,
method: "post",
headers: request.headers,
});
const sessionData = await authenticator.authenticate("email-password", signInRequest, { failureRedirect });
const session = await getSession(request);
session.set(authenticator.sessionKey, sessionData);
const redirectTo = successRedirect ?? "/messages";
return redirect(redirectTo, {
headers: { "Set-Cookie": await commitSession(session) },
});
}
export function getErrorMessage(session: Session) {
const authError = session.get(authenticator.sessionErrorKey || "auth:error");
return authError?.message;
}
export async function requireLoggedOut(request: Request) {
const user = await authenticator.isAuthenticated(request);
if (user) {
throw redirect("/messages");
}
}
export async function requireLoggedIn(request: Request) {
const user = await authenticator.isAuthenticated(request);
if (!user) {
const signInUrl = "/sign-in";
const redirectTo = buildRedirectTo(new URL(request.url));
const searchParams = new URLSearchParams({ redirectTo });
throw redirect(`${signInUrl}?${searchParams.toString()}`, {
headers: { "Set-Cookie": await destroySession(await getSession(request)) },
});
}
return user;
}
function buildRedirectTo(url: URL) {
let redirectTo = url.pathname;
const searchParams = url.searchParams.toString();
if (searchParams.length > 0) {
redirectTo += `?${searchParams}`;
}
return encodeURIComponent(redirectTo);
}
export async function refreshSessionData(request: Request) {
const {
user: { id },
} = await requireLoggedIn(request);
const user = await db.user.findUnique({ where: { id } });
if (!user || !user.hashedPassword) {
logger.warn(`User with id=${id} not found`);
throw new AuthenticationError("Could not refresh session, user does not exist");
}
const sessionData = await buildSessionData(id);
const session = await getSession(request);
session.set(authenticator.sessionKey, sessionData);
return { session, sessionData: sessionData };
}
async function buildSessionData(id: string): Promise<SessionData> {
const user = await db.user.findUnique({
where: { id },
include: {
memberships: {
select: {
organization: {
select: {
id: true,
twilioAccount: {
select: { accountSid: true, authToken: true },
},
},
},
role: true,
id: true,
},
},
},
});
if (!user) {
logger.warn(`User with id=${id} not found`);
throw new NotFoundError(`User with id=${id} not found`);
}
const { hashedPassword, memberships, ...rest } = user;
const organizations = memberships.map((membership) => ({
...membership.organization,
role: membership.role,
membershipId: membership.id,
}));
const { twilioAccount, ...organization } = organizations[0];
return {
user: rest,
organization,
twilio: twilioAccount,
};
}

Some files were not shown because too many files have changed in this diff Show More