make app usable without account, remove extra stuff
This commit is contained in:
parent
cb35455722
commit
03ae466c66
@ -1,9 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
/.idea
|
/.idea
|
||||||
/cypress/videos
|
|
||||||
/cypress/screenshots
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# build artifacts
|
# build artifacts
|
||||||
/.cache
|
/.cache
|
||||||
|
37
.github/workflows/main.yml
vendored
37
.github/workflows/main.yml
vendored
@ -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
4
.gitignore
vendored
@ -11,7 +11,3 @@ node_modules
|
|||||||
/.idea
|
/.idea
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
/cypress/videos
|
|
||||||
/cypress/screenshots
|
|
||||||
/coverage
|
|
@ -1 +0,0 @@
|
|||||||
export default {};
|
|
@ -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,
|
||||||
|
@ -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 * * *");
|
|
@ -1,3 +0,0 @@
|
|||||||
import registerPurgeExpiredSession from "./purge-expired-sessions";
|
|
||||||
|
|
||||||
export default [registerPurgeExpiredSession];
|
|
@ -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 * *");
|
|
@ -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 * * *",
|
|
||||||
);
|
|
@ -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");
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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?
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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(),
|
|
||||||
});
|
|
@ -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,
|
||||||
|
@ -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;
|
|
@ -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">​</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'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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 } }),
|
||||||
]);
|
]);
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
|
@ -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 } },
|
||||||
});
|
});
|
||||||
|
@ -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 (!phoneCalls || phoneCalls.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isFetchingCalls || !phoneCalls) {
|
if (isFetchingCalls || !phoneCalls) {
|
||||||
return <PhoneInitLoader />;
|
return <PhoneInitLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phoneCalls.length === 0) {
|
if (phoneCalls.length === 0) {
|
||||||
return hasOngoingSubscription ? <EmptyCalls /> : null;
|
return <EmptyCalls />;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -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,
|
||||||
|
@ -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 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
|
@ -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} />;
|
|
||||||
}
|
|
@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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} />;
|
|
||||||
}
|
|
@ -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'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'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'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'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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
export default function Logo() {
|
|
||||||
return <img className="w-10 h-10" src="/shellphone.webp" alt="Shellphone logo" />;
|
|
||||||
}
|
|
@ -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 |
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
@ -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'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'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;
|
|
@ -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't miss out on free $10 Twilio credit by using{" "}
|
Don'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">
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
|
|
||||||
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
@ -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">
|
|
||||||
© {{ 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>
|
|
@ -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>
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
.mso-leading-exactly {
|
|
||||||
mso-line-height-rule: exactly;
|
|
||||||
}
|
|
@ -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 }}}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
‌  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
|
||||||
 ͏ ͏ ͏ ͏ ͏
|
|
||||||
</div>
|
|
||||||
</if>
|
|
||||||
<div
|
|
||||||
role="article"
|
|
||||||
aria-roledescription="email"
|
|
||||||
aria-label="{{{ page.title || '' }}}"
|
|
||||||
lang="{{ page.language || 'en' }}"
|
|
||||||
>
|
|
||||||
<block name="template"></block>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -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>
|
|
@ -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";
|
|
@ -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>
|
|
@ -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) => {};
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
@ -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({
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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" />
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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",
|
|
||||||
};
|
|
@ -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");
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
export default function SupportPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<a className="underline" href="mailto:support@shellphone.app">
|
|
||||||
Email us
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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",
|
|
||||||
});
|
|
@ -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",
|
|
||||||
});
|
|
@ -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",
|
|
||||||
});
|
|
@ -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",
|
|
||||||
});
|
|
@ -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 });
|
|
||||||
};
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
@ -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;
|
|
||||||
|
@ -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 (
|
||||||
|
@ -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) {
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user