make app usable without account, remove extra stuff
This commit is contained in:
parent
cb35455722
commit
03ae466c66
@ -1,12 +1,9 @@
|
||||
node_modules
|
||||
.env
|
||||
/.idea
|
||||
/cypress/videos
|
||||
/cypress/screenshots
|
||||
/coverage
|
||||
|
||||
# build artifacts
|
||||
/.cache
|
||||
/public/build
|
||||
/build
|
||||
/server/index.js
|
||||
/server/index.js
|
||||
|
37
.github/workflows/main.yml
vendored
37
.github/workflows/main.yml
vendored
@ -25,40 +25,3 @@ jobs:
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npx tsc
|
||||
|
||||
deploy_development:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
needs: [lint, typecheck]
|
||||
name: Deploy development environment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: superfly/flyctl-actions@master
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
with:
|
||||
args: "deploy --strategy rolling -c ./fly.dev.toml"
|
||||
- uses: appleboy/discord-action@master
|
||||
with:
|
||||
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
args: "https://dev.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
|
||||
|
||||
|
||||
deploy_production:
|
||||
if: github.ref == 'refs/heads/production'
|
||||
needs: [lint, typecheck]
|
||||
name: Deploy production environment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: superfly/flyctl-actions@master
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
with:
|
||||
args: "deploy --strategy rolling"
|
||||
- uses: appleboy/discord-action@master
|
||||
with:
|
||||
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
args: "https://www.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,7 +11,3 @@ node_modules
|
||||
/.idea
|
||||
|
||||
.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",
|
||||
`Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.MAILCHIMP_API_KEY === "string",
|
||||
`Please define the "MAILCHIMP_API_KEY" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.MAILCHIMP_AUDIENCE_ID === "string",
|
||||
`Please define the "MAILCHIMP_AUDIENCE_ID" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.DISCORD_WEBHOOK_ID === "string",
|
||||
`Please define the "DISCORD_WEBHOOK_ID" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.DISCORD_WEBHOOK_TOKEN === "string",
|
||||
`Please define the "DISCORD_WEBHOOK_TOKEN" environment variable`,
|
||||
);
|
||||
|
||||
export default {
|
||||
app: {
|
||||
@ -66,24 +50,11 @@ export default {
|
||||
},
|
||||
aws: {
|
||||
region: process.env.AWS_REGION,
|
||||
ses: {
|
||||
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
|
||||
fromEmail: process.env.AWS_SES_FROM_EMAIL,
|
||||
},
|
||||
s3: {
|
||||
accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_S3_ACCESS_KEY_SECRET,
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
webhookId: process.env.DISCORD_WEBHOOK_ID,
|
||||
webhookToken: process.env.DISCORD_WEBHOOK_TOKEN,
|
||||
},
|
||||
mailchimp: {
|
||||
apiKey: process.env.MAILCHIMP_API_KEY,
|
||||
audienceId: process.env.MAILCHIMP_AUDIENCE_ID,
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
|
@ -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 logger from "~/utils/logger.server";
|
||||
import { validate } from "~/utils/validation.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.clone().formData();
|
||||
@ -31,7 +31,6 @@ const action: ActionFunction = async ({ request }) => {
|
||||
export default action;
|
||||
|
||||
async function subscribe(request: Request) {
|
||||
const { organization } = await requireLoggedIn(request);
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
|
||||
@ -42,17 +41,16 @@ async function subscribe(request: Request) {
|
||||
}
|
||||
|
||||
const { subscription } = validation.data;
|
||||
const membership = await db.membership.findFirst({
|
||||
where: { id: organization.membershipId },
|
||||
});
|
||||
if (!membership) {
|
||||
return notFound("Phone number not found");
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.notificationSubscription.create({
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
twilioAccountSid: twilio.accountSid,
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime,
|
||||
keys_p256dh: subscription.keys.p256dh,
|
||||
|
@ -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 { Prisma } from "@prisma/client";
|
||||
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import db from "~/utils/db.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type KeypadLoaderData = {
|
||||
hasOngoingSubscription: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
lastRecipientCalled?: string;
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
const hasOngoingSubscription = true; // TODO
|
||||
const hasPhoneNumber = Boolean(phoneNumber);
|
||||
const lastCall =
|
||||
phoneNumber &&
|
||||
@ -26,7 +25,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}));
|
||||
return json<KeypadLoaderData>(
|
||||
{
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
lastRecipientCalled: lastCall?.recipient,
|
||||
},
|
||||
|
@ -2,19 +2,21 @@ import { type ActionFunction } from "@remix-run/node";
|
||||
import { json } from "superjson-remix";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import getTwilioClient, { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type NewMessageActionData = {};
|
||||
type NewMessageActionData = {};
|
||||
|
||||
const action: ActionFunction = async ({ params, request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const [phoneNumber, twilioAccount] = await Promise.all([
|
||||
db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid ?? "", isCurrent: true } },
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid, isCurrent: true } },
|
||||
}),
|
||||
db.twilioAccount.findUnique({ where: { accountSid: twilio.accountSid } }),
|
||||
]);
|
||||
|
@ -4,8 +4,8 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
type ConversationType = {
|
||||
recipient: string;
|
||||
@ -19,7 +19,8 @@ export type ConversationLoaderData = {
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request, params }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
return redirect("/messages");
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type Message, type PhoneNumber, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
export type MessagesLoaderData = {
|
||||
hasPhoneNumber: boolean;
|
||||
@ -19,7 +19,8 @@ type Conversation = {
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
|
@ -10,20 +10,14 @@ import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
|
||||
import type { PhoneCallsLoaderData } from "~/features/phone-calls/loaders/calls";
|
||||
|
||||
export default function PhoneCallsList() {
|
||||
const { hasOngoingSubscription, isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
|
||||
const { isFetchingCalls, phoneCalls } = useLoaderData<PhoneCallsLoaderData>();
|
||||
|
||||
if (!hasOngoingSubscription) {
|
||||
if (!phoneCalls || phoneCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (isFetchingCalls || !phoneCalls) {
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
if (isFetchingCalls || !phoneCalls) {
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
|
||||
if (phoneCalls.length === 0) {
|
||||
return hasOngoingSubscription ? <EmptyCalls /> : null;
|
||||
}
|
||||
if (phoneCalls.length === 0) {
|
||||
return <EmptyCalls />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -4,7 +4,7 @@ import { parsePhoneNumber } from "awesome-phonenumber";
|
||||
import { type PhoneCall, Prisma } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
|
||||
type PhoneCallMeta = {
|
||||
formattedPhoneNumber: string;
|
||||
@ -12,7 +12,6 @@ type PhoneCallMeta = {
|
||||
};
|
||||
|
||||
export type PhoneCallsLoaderData = {
|
||||
hasOngoingSubscription: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
} & (
|
||||
| {
|
||||
@ -26,15 +25,14 @@ export type PhoneCallsLoaderData = {
|
||||
);
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio?.accountSid ?? "", isCurrent: true } },
|
||||
});
|
||||
const hasPhoneNumber = Boolean(phoneNumber);
|
||||
const hasOngoingSubscription = true; // TODO
|
||||
if (!phoneNumber || phoneNumber.isFetchingCalls) {
|
||||
return json<PhoneCallsLoaderData>({
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
isFetchingCalls: phoneNumber?.isFetchingCalls ?? false,
|
||||
});
|
||||
@ -46,7 +44,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
});
|
||||
return json<PhoneCallsLoaderData>(
|
||||
{
|
||||
hasOngoingSubscription,
|
||||
hasPhoneNumber,
|
||||
phoneCalls: phoneCalls.map((phoneCall) => ({
|
||||
...phoneCall,
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { type LoaderFunction } from "@remix-run/node";
|
||||
import Twilio from "twilio";
|
||||
|
||||
import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import { decrypt, encrypt } from "~/utils/encryption";
|
||||
import db from "~/utils/db.server";
|
||||
import { commitSession } from "~/utils/session.server";
|
||||
import { getSession } from "~/utils/session.server";
|
||||
import getTwilioClient from "~/utils/twilio.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
|
||||
export type TwilioTokenLoaderData = string;
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { user, twilio } = await requireLoggedIn(request);
|
||||
const session = await getSession(request);
|
||||
const twilio = session.get("twilio");
|
||||
if (!twilio) {
|
||||
logger.warn("Twilio account is not connected");
|
||||
return null;
|
||||
@ -26,7 +26,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
const twilioClient = getTwilioClient(twilioAccount);
|
||||
let shouldRefreshSession = false;
|
||||
let { apiKeySid, apiKeySecret } = twilioAccount;
|
||||
if (apiKeySid && apiKeySecret) {
|
||||
try {
|
||||
@ -41,7 +40,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
if (!apiKeySid || !apiKeySecret) {
|
||||
shouldRefreshSession = true;
|
||||
const apiKey = await twilioClient.newKeys.create({ friendlyName: "Shellphone" });
|
||||
apiKeySid = apiKey.sid;
|
||||
apiKeySecret = encrypt(apiKey.secret);
|
||||
@ -52,7 +50,7 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
const accessToken = new Twilio.jwt.AccessToken(twilioAccount.accountSid, apiKeySid, decrypt(apiKeySecret), {
|
||||
identity: `${twilio.accountSid}__${user.id}`,
|
||||
identity: `shellphone__${twilio.accountSid}`,
|
||||
ttl: 3600,
|
||||
});
|
||||
const grant = new Twilio.jwt.AccessToken.VoiceGrant({
|
||||
@ -62,11 +60,6 @@ const loader: LoaderFunction = async ({ request }) => {
|
||||
accessToken.addGrant(grant);
|
||||
|
||||
const headers = new Headers({ "Content-Type": "text/plain" });
|
||||
if (shouldRefreshSession) {
|
||||
const { session } = await refreshSessionData(request);
|
||||
headers.set("Set-Cookie", await commitSession(session));
|
||||
}
|
||||
|
||||
return new Response(accessToken.toJwt(), { headers });
|
||||
};
|
||||
|
||||
|
@ -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" |