remixed v0

This commit is contained in:
m5r 2022-05-14 12:22:06 +02:00
parent 9275d4499b
commit 98b89ae0f7
338 changed files with 22549 additions and 44628 deletions

12
.dockerignore Normal file
View File

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

View File

@ -1,11 +0,0 @@
# https://EditorConfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

1
.env.e2e Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape_e2e

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
APP_BASE_URL=http://localhost:3000
INVITATION_TOKEN_SECRET=0ded075524fd19fe467eb00480b8d5d4
SESSION_SECRET=754a554f4cbf9254e50fda87b48ee52b
POSTGRES_USER=pgremixtape
POSTGRES_PASSWORD=pgpassword
POSTGRES_DB=remixtape
DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=
# Grab those from https://console.aws.amazon.com/ses/home
AWS_SES_REGION=eu-central-1
AWS_SES_ACCESS_KEY_ID=TODO
AWS_SES_ACCESS_KEY_SECRET=TODO
AWS_SES_FROM_EMAIL=remixtape@fake.app
# Grab those from https://dashboard.stripe.com/
STRIPE_SECRET_API_KEY=sk_TODO
STRIPE_MONTHLY_PRICE_ID=price_TODO
STRIPE_YEARLY_PRICE_ID=price_TODO
# Grab this one from the Stripe CLI logs, within the `stripe` container if you're using Docker
STRIPE_WEBHOOK_SECRET=whsec_TODO

View File

@ -1,3 +0,0 @@
module.exports = {
extends: ["blitz"],
};

View File

@ -1,91 +1,84 @@
name: Deployment pipeline name: CI
on: [push, pull_request] on: [push, pull_request]
jobs: jobs:
lint: lint:
name: Lint name: Lint
timeout-minutes: 4
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
HUSKY_SKIP_INSTALL: 1
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 16 node-version: 16
cache: "npm"
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
test: e2e:
if: false == true name: E2E tests
name: Test
timeout-minutes: 4
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:13-alpine
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env: env:
HUSKY_SKIP_INSTALL: 1 POSTGRES_USER: pgremixtape
steps: POSTGRES_PASSWORD: pgpassword
- uses: actions/checkout@v2 POSTGRES_DB: remixtape
- uses: actions/setup-node@v2 ports:
with: - "5432:5432"
node-version: 16 redis:
- run: npm ci image: redis:6-alpine
- run: npm test options: >-
--health-cmd "redis-cli ping"
build: --health-interval 10s
name: Compile --health-timeout 5s
timeout-minutes: 6 --health-retries 5
runs-on: ubuntu-latest ports:
env: - "6379:6379"
HUSKY_SKIP_INSTALL: 1 env:
DATABASE_URL: postgresql://pgremixtape:pgpassword@localhost:5432/remixtape
REDIS_URL: redis://localhost:6379
CI: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- run: cp .env.example .env
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: 16 node-version: 16
cache: "npm"
- run: npm ci - run: npm ci
- run: npm run db:setup
- run: npm run build - run: npm run build
env: - run: npx dotenv npm run e2e:ci
DATOCMS_API_TOKEN: ${{ secrets.DATOCMS_API_TOKEN }}
QUIRREL_BASE_URL: doesntmatter.shellphone.app
deploy_dev: typecheck:
name: Typecheck
runs-on: ubuntu-latest
env:
HUSKY_SKIP_INSTALL: 1
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
- run: npm ci
- run: npx tsc
deploy:
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
needs: [lint, test, build] needs: [lint, e2e, typecheck]
name: Deploy dev.shellphone.app name: Deploy to Fly.io
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
HUSKY_SKIP_INSTALL: 1
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: superfly/flyctl-actions@master - uses: superfly/flyctl-actions@master
env: env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
with: with:
args: "deploy -c ./fly.dev.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_DEV }} --build-arg DATOCMS_API_TOKEN=${{ secrets.DATOCMS_API_TOKEN }}" args: "deploy --strategy rolling"
- 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_prod:
if: github.ref == 'refs/heads/production'
needs: [lint, test, build]
name: Deploy www.shellphone.app
runs-on: ubuntu-latest
env:
HUSKY_SKIP_INSTALL: 1
steps:
- uses: actions/checkout@v2
- uses: superfly/flyctl-actions@master
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
with:
args: "deploy -c ./fly.prod.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_PROD }} --build-arg DATOCMS_API_TOKEN=${{ secrets.DATOCMS_API_TOKEN }}"
- 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 }}`"
# TODO: on pull_request, deploy 24hour-long deployment at {commit_short_hash}.shellphone.app, provision db and seed data

61
.gitignore vendored
View File

@ -1,55 +1,16 @@
# dependencies
node_modules node_modules
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.pnp.*
.npm
web_modules/
# blitz /.cache
/.blitz/ /server/build
/.next/ /public/build
*.sqlite /build
*.sqlite-journal server.js
.now /app/styles/tailwind.css
.blitz**
blitz-log.log
# misc /.idea
.DS_Store
# local env files .env
.env.local
.env.*.local
.envrc
# Logs /cypress/videos
logs /cypress/screenshots
*.log /coverage
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Testing
.coverage
*.lcov
.nyc_output
lib-cov
# Caches
*.tsbuildinfo
.eslintcache
.node_repl_history
.yarn-integrity
# Serverless directories
.serverless/
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.idea/

View File

@ -2,4 +2,3 @@
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
npx lint-staged npx lint-staged
npx pretty-quick --staged

View File

@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx tsc
npm run lint
#npm run test

1
.npmrc
View File

@ -1,2 +1 @@
save-exact=true save-exact=true
legacy-peer-deps=true

View File

@ -1,8 +1,6 @@
.gitkeep .gitkeep
.env* .env
*.ico .cache
*.lock build
db/migrations package-lock.json
.next app/tailwind.css
.blitz
mailers/**/*.html

View File

@ -1,12 +0,0 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"mikestead.dotenv",
"mgmcdermott.vscode-language-babel",
"orta.vscode-jest",
"prisma.prisma"
],
"unwantedRecommendations": []
}

View File

@ -1,7 +0,0 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

57
Dockerfile Normal file
View File

@ -0,0 +1,57 @@
# base node image
FROM node:16-bullseye-slim as base
# set for base and all layer that inherit from it
ENV NODE_ENV=production
# Install openssl for Prisma
RUN apt-get update && apt-get install -y openssl
# Install all node_modules, including dev dependencies
FROM base as deps
RUN mkdir /app
WORKDIR /app
ADD package.json package-lock.json ./
RUN npm install --production=false
# Setup production node_modules
FROM base as production-deps
RUN mkdir /app
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
ADD package.json package-lock.json ./
RUN npm prune --production
# Build the app
FROM base as build
RUN mkdir /app
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
# Cache the prisma schema
ADD prisma .
RUN npx prisma generate
ADD . .
RUN npm run build
# Finally, build the production image with minimal footprint
FROM base
RUN mkdir /app
WORKDIR /app
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma
COPY --from=build /app/build /app/build
COPY --from=build /app/public /app/public
COPY --from=build /app/server.js /app/server.js
ADD . .
CMD ["npm", "run", "start"]

View File

@ -1,71 +0,0 @@
import { BlitzApiRequest, BlitzApiResponse } from "blitz";
import db from "db";
import twilio from "twilio";
import setTwilioWebhooks from "../settings/api/queue/set-twilio-webhooks";
import backup from "../../db/backup";
import { sendEmail } from "../../integrations/aws-ses";
export default async function ddd(req: BlitzApiRequest, res: BlitzApiResponse) {
/*await Promise.all([
db.message.deleteMany(),
db.phoneCall.deleteMany(),
db.phoneNumber.deleteMany(),
]);
await db.customer.deleteMany();
await db.user.deleteMany();*/
const accountSid = "ACa886d066be0832990d1cf43fb1d53362";
const authToken = "8696a59a64b94bb4eba3548ed815953b";
/*const ddd = await twilio(accountSid, authToken)
.lookups
.v1
// .phoneNumbers("+33613370787")
.phoneNumbers("+33476982071")
.fetch();*/
/*try {
await twilio(accountSid, authToken).messages.create({
body: "content",
to: "+213744123789",
from: "+33757592025",
});
} catch (error) {
console.log(error.code);
console.log(error.moreInfo);
console.log(error.details);
// console.log(JSON.stringify(Object.keys(error)));
}*/
/*const ddd = await twilio(accountSid, authToken).messages.create({
body: "cccccasdasd",
to: "+33757592025",
from: "+33757592722",
});*/
/*const [messagesSent, messagesReceived] = await Promise.all([
twilio(accountSid, authToken).messages.list({
from: "+33757592025",
}),
twilio(accountSid, authToken).messages.list({
to: "+33757592025",
}),
]);
console.log("messagesReceived", messagesReceived.sort((a, b) => a.dateCreated.getTime() - b.dateCreated.getTime()));
// console.log("messagesReceived", messagesReceived);*/
/*setTwilioWebhooks.enqueue({
phoneNumberId: "PNb77c9690c394368bdbaf20ea6fe5e9fc",
organizationId: "95267d60-3d35-4c36-9905-8543ecb4f174",
});*/
/*try {
const before = Date.now();
await backup("daily");
console.log(`took ${Date.now() - before}ms`);
} catch (error) {
console.error("dddd error", error);
res.status(500).end();
}*/
// setTimeout(() => {
res.status(200).end();
// }, 1000 * 60 * 5);
}

View File

@ -1,18 +0,0 @@
import type { BlitzApiHandler } from "blitz";
import { cancelPaddleSubscription } from "integrations/paddle";
import appLogger from "integrations/logger";
const logger = appLogger.child({ route: "/api/debug/cancel-subscription" });
const cancelSubscriptionHandler: BlitzApiHandler = async (req, res) => {
const { subscriptionId } = req.body;
logger.debug(`cancelling subscription for subscriptionId="${subscriptionId}"`);
await cancelPaddleSubscription({ subscriptionId });
logger.debug(`cancelled subscription for subscriptionId="${subscriptionId}"`);
res.status(200).end();
};
export default cancelSubscriptionHandler;

View File

@ -1,29 +0,0 @@
import type { BlitzApiHandler } from "blitz";
import { getPayments } from "integrations/paddle";
import appLogger from "integrations/logger";
import db from "db";
const logger = appLogger.child({ route: "/api/debug/cancel-subscription" });
const cancelSubscriptionHandler: BlitzApiHandler = async (req, res) => {
const { organizationId } = req.body;
logger.debug(`fetching payments for organizationId="${organizationId}"`);
const subscriptions = await db.subscription.findMany({ where: { organizationId } });
if (subscriptions.length === 0) {
res.status(200).send([]);
}
console.log("subscriptions", subscriptions);
const paymentsBySubscription = await Promise.all(
subscriptions.map((subscription) => getPayments({ subscriptionId: subscription.paddleSubscriptionId })),
);
const payments = paymentsBySubscription.flat();
const result = Array.from(payments).sort((a, b) => b.payout_date.localeCompare(a.payout_date));
logger.debug(result);
res.status(200).send(result);
};
export default cancelSubscriptionHandler;

View File

@ -1,18 +0,0 @@
import type { BlitzApiHandler } from "blitz";
import db from "db";
import appLogger from "integrations/logger";
const logger = appLogger.child({ route: "/api/debug/get-subscription" });
const cancelSubscriptionHandler: BlitzApiHandler = async (req, res) => {
const { organizationId } = req.body;
logger.debug(`fetching subscription for organizationId="${organizationId}"`);
const subscription = await db.subscription.findFirst({ where: { organizationId } });
console.debug(subscription);
res.status(200).send(subscription);
};
export default cancelSubscriptionHandler;

View File

@ -1,104 +0,0 @@
import { useState, ReactNode, PropsWithoutRef } from "react";
import { FormProvider, useForm, UseFormProps } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import clsx from "clsx";
import Alert from "app/core/components/alert";
import Logo from "app/core/components/logo";
export interface FormProps<S extends z.ZodType<any, any>>
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> {
/** All your form fields */
children?: ReactNode;
texts: {
title: string;
subtitle: ReactNode;
submit: string;
};
schema?: S;
onSubmit: (values: z.infer<S>) => Promise<void | OnSubmitResult>;
initialValues?: UseFormProps<z.infer<S>>["defaultValues"];
}
interface OnSubmitResult {
FORM_ERROR?: string;
[prop: string]: any;
}
export const FORM_ERROR = "FORM_ERROR";
export function AuthForm<S extends z.ZodType<any, any>>({
children,
texts,
schema,
initialValues,
onSubmit,
...props
}: FormProps<S>) {
const ctx = useForm<z.infer<S>>({
mode: "onBlur",
resolver: schema ? zodResolver(schema) : undefined,
defaultValues: initialValues,
});
const [formError, setFormError] = useState<string | null>(null);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-12 w-12" />
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">{texts.title}</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">{texts.subtitle}</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<FormProvider {...ctx}>
<form
onSubmit={ctx.handleSubmit(async (values) => {
const result = (await onSubmit(values)) || {};
for (const [key, value] of Object.entries(result)) {
if (key === FORM_ERROR) {
setFormError(value);
} else {
ctx.setError(key as any, {
type: "submit",
message: value,
});
}
}
})}
className="form"
{...props}
>
{formError ? (
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm">
<Alert title="Oops, there was an issue" message={formError} variant="error" />
</div>
) : null}
{children}
{texts.submit ? (
<button
type="submit"
disabled={ctx.formState.isSubmitting}
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": ctx.formState.isSubmitting,
"bg-primary-600 hover:bg-primary-700": !ctx.formState.isSubmitting,
},
)}
>
{texts.submit}
</button>
) : null}
</form>
</FormProvider>
</div>
</div>
);
}
export default AuthForm;

View File

@ -1,66 +0,0 @@
import { forwardRef, PropsWithoutRef } from "react";
import { Link, Routes } from "blitz";
import { useFormContext } from "react-hook-form";
import clsx from "clsx";
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
/** Field name. */
name: string;
/** Field label. */
label: string;
/** Field type. Doesn't include radio buttons and checkboxes */
type?: "text" | "password" | "email" | "number";
showForgotPasswordLabel?: boolean;
}
export const LabeledTextField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
({ label, name, showForgotPasswordLabel, ...props }, ref) => {
const {
register,
formState: { isSubmitting, errors },
} = useFormContext();
const error = Array.isArray(errors[name]) ? errors[name].join(", ") : errors[name]?.message || errors[name];
return (
<div className="mb-6">
<label
htmlFor="name"
className={clsx("text-sm font-medium leading-5 text-gray-700", {
block: !showForgotPasswordLabel,
"flex justify-between": showForgotPasswordLabel,
})}
>
{label}
{showForgotPasswordLabel ? (
<div>
<Link href={Routes.ForgotPasswordPage()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Forgot your password?
</a>
</Link>
</div>
) : null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="name"
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"
disabled={isSubmitting}
{...register(name)}
{...props}
/>
</div>
{error ? (
<div role="alert" className="text-red-600">
{error}
</div>
) : null}
</div>
);
},
);
export default LabeledTextField;

View File

@ -1,46 +0,0 @@
import { resolver, generateToken, hash256 } from "blitz";
import db, { User } from "../../../db";
import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer";
import { ForgotPassword } from "../validations";
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
const user = await db.user.findFirst({ 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;
});
async function updatePassword(user: User | null) {
if (!user) {
return;
}
const token = generateToken();
const hashedToken = hash256(token);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS);
await db.token.deleteMany({ where: { type: "RESET_PASSWORD", userId: user.id } });
await db.token.create({
data: {
user: { connect: { id: user.id } },
type: "RESET_PASSWORD",
expiresAt,
hashedToken,
sentTo: user.email,
},
});
await (
await forgotPasswordMailer({
to: user.email,
token,
userName: user.fullName,
})
).send();
}

View File

@ -1,46 +0,0 @@
import { resolver, SecurePassword, AuthenticationError } from "blitz";
import db from "../../../db";
import { Login } from "../validations";
export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
const email = rawEmail.toLowerCase().trim();
const password = rawPassword.trim();
const user = await db.user.findFirst({
where: { email },
include: {
memberships: {
include: { organization: true },
},
},
});
if (!user) throw new AuthenticationError();
const result = await SecurePassword.verify(user.hashedPassword, password);
if (result === SecurePassword.VALID_NEEDS_REHASH) {
// Upgrade hashed password with a more secure hash
const improvedHash = await SecurePassword.hash(password);
await db.user.update({
where: { id: user.id },
data: { hashedPassword: improvedHash },
});
}
const { hashedPassword, ...rest } = user;
return rest;
};
export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ctx) => {
// This throws an error if credentials are invalid
const user = await authenticateUser(email, password);
const organization = user.memberships[0]!.organization;
await ctx.session.$create({
userId: user.id,
roles: [user.role, user.memberships[0]!.role],
orgId: organization.id,
});
return user;
});

View File

@ -1,5 +0,0 @@
import type { Ctx } from "blitz";
export default async function logout(_ = null, ctx: Ctx) {
return await ctx.session.$revoke();
}

View File

@ -1,48 +0,0 @@
import { resolver, SecurePassword, hash256 } from "blitz";
import db from "../../../db";
import { ResetPassword } from "../validations";
import login from "./login";
export class ResetPasswordError extends Error {
name = "ResetPasswordError";
message = "Reset password link is invalid or it has expired.";
}
export default resolver.pipe(resolver.zod(ResetPassword), async ({ password, token }, ctx) => {
// 1. Try to find this token in the database
const hashedToken = hash256(token);
const possibleToken = await db.token.findFirst({
where: { hashedToken, type: "RESET_PASSWORD" },
include: { user: true },
});
// 2. If token not found, error
if (!possibleToken) {
throw new ResetPasswordError();
}
const savedToken = possibleToken;
// 3. Delete token so it can't be used again
await db.token.delete({ where: { id: savedToken.id } });
// 4. If token has expired, error
if (savedToken.expiresAt < new Date()) {
throw new ResetPasswordError();
}
// 5. Since token is valid, now we can update the user's password
const hashedPassword = await SecurePassword.hash(password.trim());
const user = await db.user.update({
where: { id: savedToken.userId },
data: { hashedPassword },
});
// 6. Revoke all existing login sessions for this user
await db.session.deleteMany({ where: { userId: user.id } });
// 7. Now log the user in with the new credentials
await login({ email: user.email, password }, ctx);
return true;
});

View File

@ -1,46 +0,0 @@
import { resolver, SecurePassword } from "blitz";
import db, { GlobalRole, MembershipRole } from "db";
import { Signup } from "../validations";
import { computeEncryptionKey } from "db/_encryption";
import { welcomeMailer } from "mailers/welcome-mailer";
export default resolver.pipe(resolver.zod(Signup), async ({ email, password, fullName }, ctx) => {
const hashedPassword = await SecurePassword.hash(password.trim());
const encryptionKey = computeEncryptionKey(email.toLowerCase().trim()).toString("hex");
const user = await db.user.create({
data: {
fullName: fullName.trim(),
email: email.toLowerCase().trim(),
hashedPassword,
role: GlobalRole.CUSTOMER,
memberships: {
create: {
role: MembershipRole.OWNER,
organization: {
create: {
encryptionKey,
},
},
},
},
},
include: { memberships: true },
});
await ctx.session.$create({
userId: user.id,
roles: [user.role, user.memberships[0]!.role],
orgId: user.memberships[0]!.organizationId,
shouldShowWelcomeMessage: true,
});
await (
await welcomeMailer({
to: user.email,
userName: user.fullName,
})
).send();
return user;
});

View File

@ -1,48 +0,0 @@
import type { BlitzPage } from "blitz";
import { Routes, useMutation } from "blitz";
import BaseLayout from "app/core/layouts/base-layout";
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
import { LabeledTextField } from "../components/labeled-text-field";
import { ForgotPassword } from "../validations";
import forgotPassword from "app/auth/mutations/forgot-password";
const ForgotPasswordPage: BlitzPage = () => {
const [forgotPasswordMutation, { isSuccess, reset }] = useMutation(forgotPassword);
return (
<Form
texts={{
title: isSuccess ? "Request submitted" : "Forgot your password?",
subtitle: "",
submit: isSuccess ? "" : "Send reset password link",
}}
schema={ForgotPassword}
initialValues={{ email: "" }}
onSubmit={async (values) => {
try {
reset();
await forgotPasswordMutation(values);
} catch (error: any) {
return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
};
}
}}
>
{isSuccess ? (
<p className="text-center">
If your email is in our system, you will receive instructions to reset your password shortly.
</p>
) : (
<LabeledTextField name="email" label="Email" />
)}
</Form>
);
};
ForgotPasswordPage.redirectAuthenticatedTo = Routes.Messages();
ForgotPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
export default ForgotPasswordPage;

View File

@ -1,74 +0,0 @@
import type { BlitzPage, GetServerSideProps } from "blitz";
import { useRouterQuery, Link, useMutation, Routes } from "blitz";
import BaseLayout from "../../core/layouts/base-layout";
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
import { LabeledTextField } from "../components/labeled-text-field";
import { ResetPassword } from "../validations";
import resetPassword from "../../auth/mutations/reset-password";
const ResetPasswordPage: BlitzPage = () => {
const query = useRouterQuery();
const [resetPasswordMutation, { isSuccess }] = useMutation(resetPassword);
return (
<Form
texts={{
title: isSuccess ? "Password reset successfully" : "Set a new password",
subtitle: "",
submit: "Reset password",
}}
schema={ResetPassword}
initialValues={{
password: "",
passwordConfirmation: "",
token: query.token as string,
}}
onSubmit={async (values) => {
try {
await resetPasswordMutation(values);
} catch (error: any) {
if (error.name === "ResetPasswordError") {
return {
[FORM_ERROR]: error.message,
};
} else {
return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
};
}
}
}}
>
{isSuccess ? (
<p>
Go to the <Link href={Routes.LandingPage()}>homepage</Link>
</p>
) : (
<>
<LabeledTextField name="password" label="New Password" type="password" />
<LabeledTextField name="passwordConfirmation" label="Confirm New Password" type="password" />
</>
)}
</Form>
);
};
ResetPasswordPage.redirectAuthenticatedTo = Routes.Messages();
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
export const getServerSideProps: GetServerSideProps = async (context) => {
if (!context.query.token) {
return {
redirect: {
destination: Routes.ForgotPasswordPage().pathname,
permanent: false,
},
};
}
return { props: {} };
};
export default ResetPasswordPage;

View File

@ -1,60 +0,0 @@
import type { BlitzPage } from "blitz";
import { useRouter, Routes, AuthenticationError, Link, useMutation } from "blitz";
import BaseLayout from "../../core/layouts/base-layout";
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
import { Login } from "../validations";
import { LabeledTextField } from "../components/labeled-text-field";
import login from "../mutations/login";
const SignIn: BlitzPage = () => {
const router = useRouter();
const [loginMutation] = useMutation(login);
return (
<Form
texts={{
title: "Welcome back!",
subtitle: (
<>
Need an account?&nbsp;
<Link href={Routes.SignUp()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Create yours for free
</a>
</Link>
</>
),
submit: "Sign in",
}}
schema={Login}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await loginMutation(values);
const next = router.query.next
? decodeURIComponent(router.query.next as string)
: Routes.Messages();
router.push(next);
} catch (error: any) {
if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
} else {
return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again. - " + error.toString(),
};
}
}
}}
>
<LabeledTextField name="email" label="Email" type="email" />
<LabeledTextField name="password" label="Password" type="password" showForgotPasswordLabel />
</Form>
);
};
SignIn.redirectAuthenticatedTo = Routes.Messages();
SignIn.getLayout = (page) => <BaseLayout title="Sign in">{page}</BaseLayout>;
export default SignIn;

View File

@ -1,60 +0,0 @@
import type { BlitzPage } from "blitz";
import { useRouter, Routes, useMutation, Link } from "blitz";
import BaseLayout from "../../core/layouts/base-layout";
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
import { LabeledTextField } from "../components/labeled-text-field";
import signup from "../mutations/signup";
import { Signup } from "../validations";
const SignUp: BlitzPage = () => {
const router = useRouter();
const [signupMutation] = useMutation(signup);
return (
<Form
texts={{
title: "Create your account",
subtitle: (
<Link href={Routes.SignIn()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Already have an account?
</a>
</Link>
),
submit: "Sign up",
}}
schema={Signup}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await signupMutation(values);
await router.push(Routes.Welcome());
} catch (error: any) {
if (error.code === "P2002" && error.meta?.target?.includes("email")) {
// This error comes from Prisma
return { email: "This email is already being used" };
} else {
return { [FORM_ERROR]: error.toString() };
}
}
}}
>
<LabeledTextField name="fullName" label="Full name" type="text" />
<LabeledTextField name="email" label="Email" type="email" />
<LabeledTextField name="password" label="Password" type="password" />
</Form>
);
};
SignUp.redirectAuthenticatedTo = ({ session }) => {
if (session.shouldShowWelcomeMessage) {
return Routes.Welcome();
}
return Routes.Messages();
};
SignUp.getLayout = (page) => <BaseLayout title="Sign up">{page}</BaseLayout>;
export default SignUp;

View File

@ -1,28 +0,0 @@
import type { BlitzPage, GetServerSideProps } from "blitz";
import { getSession, Routes, useRouter } from "blitz";
// TODO: make this page feel more welcoming lol
const Welcome: BlitzPage = () => {
const router = useRouter();
return (
<div>
<p>Thanks for joining Shellphone</p>
<p>Let us know if you need our help</p>
<p>Make sure to set up your phone number</p>
<button onClick={() => router.push(Routes.Messages())}>Open my phone</button>
</div>
);
};
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const session = await getSession(req, res);
await session.$setPublicData({ shouldShowWelcomeMessage: undefined });
return {
props: {},
};
};
export default Welcome;

View File

@ -1,10 +0,0 @@
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
export default async function preview(req: BlitzApiRequest, res: BlitzApiResponse) {
// Exit the current user from "Preview Mode". This function accepts no args.
res.clearPreviewData();
// Redirect the user back to the index page.
res.writeHead(307, { Location: "/" });
res.end();
}

View File

@ -1,34 +0,0 @@
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
import { getConfig } from "blitz";
import { getPreviewPostBySlug } from "../../../../integrations/datocms";
const { serverRuntimeConfig } = getConfig();
export default async function preview(req: BlitzApiRequest, res: BlitzApiResponse) {
// Check the secret and next parameters
// This secret should only be known to this API route and the CMS
if (
req.query.secret !== serverRuntimeConfig.datoCms.previewSecret ||
!req.query.slug ||
Array.isArray(req.query.slug)
) {
return res.status(401).json({ message: "Invalid token" });
}
// Fetch the headless CMS to check if the provided `slug` exists
const post = await getPreviewPostBySlug(req.query.slug);
// If the slug doesn't exist prevent preview mode from being enabled
if (!post) {
return res.status(401).json({ message: "Invalid slug" });
}
// Enable Preview Mode by setting the cookies
res.setPreviewData({});
// Redirect to the path from the fetched post
// We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
res.writeHead(307, { Location: `/posts/${post.slug}` });
res.end();
}

View File

@ -1,12 +0,0 @@
import Image from "next/image";
export default function Avatar({ name, picture }: any) {
return (
<div className="flex items-center">
<div className="w-12 h-12 relative mr-4">
<Image src={picture.url} layout="fill" className="rounded-full" alt={name} />
</div>
<div className="text-xl font-bold">{name}</div>
</div>
);
}

View File

@ -1,29 +0,0 @@
import { Image } from "react-datocms";
import clsx from "clsx";
import Link from "next/link";
export default function CoverImage({ title, responsiveImage, slug }: any) {
const image = (
// eslint-disable-next-line jsx-a11y/alt-text
<Image
data={{
...responsiveImage,
alt: `Cover Image for ${title}`,
}}
className={clsx("shadow-small", {
"hover:shadow-medium transition-shadow duration-200": slug,
})}
/>
);
return (
<div className="sm:mx-0">
{slug ? (
<Link href={`/posts/${slug}`}>
<a aria-label={title}>{image}</a>
</Link>
) : (
image
)}
</div>
);
}

View File

@ -1,6 +0,0 @@
import { formatDate } from "../../core/helpers/date-formatter";
export default function DateComponent({ dateString }: any) {
const date = new Date(dateString);
return <time dateTime={dateString}>{formatDate(date)}</time>;
}

View File

@ -1,77 +0,0 @@
import { Link, Routes } from "blitz";
import type { Post } from "../../../integrations/datocms";
import { formatDate } from "../../core/helpers/date-formatter";
import PostPreview from "./post-preview";
type Props = {
posts: Post[];
};
export default function MoreStories({ posts }: Props) {
return (
<aside>
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
<div className="pb-12 md:pb-20">
<div className="max-w-3xl mx-auto">
<h4 className="h4 font-mackinac mb-8">Related articles</h4>
{/* Articles container */}
<div className="grid gap-4 sm:gap-6 sm:grid-cols-2">
{posts.map((post) => (
<article key={post.slug} className="relative group p-6 text-white">
<figure>
<img
className="absolute inset-0 w-full h-full object-cover opacity-50 group-hover:opacity-75 transition duration-700 ease-out"
src={post.coverImage.responsiveImage.src}
width="372"
height="182"
alt="Related post"
/>
<div
className="absolute inset-0 bg-primary-500 opacity-75 group-hover:opacity-50 transition duration-700 ease-out"
aria-hidden="true"
/>
</figure>
<div className="relative flex flex-col h-full">
<header className="flex-grow">
<Link href={Routes.PostPage({ slug: post.slug })}>
<a className="hover:underline">
<h3 className="text-lg font-mackinac font-bold tracking-tight mb-2">
{post.title}
</h3>
</a>
</Link>
<div className="text-sm opacity-80">{formatDate(new Date(post.date))}</div>
</header>
<footer>
{/* Author meta */}
<div className="flex items-center text-sm mt-5">
<a href="#0">
<img
className="rounded-full flex-shrink-0 mr-3"
src={post.author.picture.url}
width="32"
height="32"
alt={post.author.name}
/>
</a>
<div>
<span className="opacity-75">By </span>
<span className="font-medium hover:underline">
{post.author.name}
</span>
</div>
</div>
</footer>
</div>
</article>
))}
</div>
</div>
</div>
</div>
</aside>
);
}

View File

@ -1,16 +0,0 @@
import styles from "../styles/post-body.module.css";
type Props = {
content: string;
};
export default function PostBody({ content }: Props) {
return (
<div className="max-w-2xl mx-auto">
<div
className={`prose prose-lg prose-blue ${styles.markdown}`}
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
);
}

View File

@ -1,25 +0,0 @@
import Link from "next/link";
import Avatar from "./avatar";
import Date from "./date";
import CoverImage from "./cover-image";
export default function PostPreview({ title, coverImage, date, excerpt, author, slug }: any) {
return (
<div>
<div className="mb-5">
<CoverImage slug={slug} title={title} responsiveImage={coverImage.responsiveImage} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<Link href={`/posts/${slug}`}>
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="text-lg mb-4">
<Date dateString={date} />
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
</div>
);
}

View File

@ -1,3 +0,0 @@
export default function SectionSeparator() {
return <hr className="border-accent-2 mt-28 mb-24" />;
}

View File

@ -1,166 +0,0 @@
import type { BlitzPage, GetStaticPaths, GetStaticProps } from "blitz";
import { useRouter } from "blitz";
import ErrorPage from "next/error";
import type { Post } from "integrations/datocms";
import { getAllPostsWithSlug, getPostAndMorePosts, markdownToHtml } from "integrations/datocms";
import { formatDate } from "../../../core/helpers/date-formatter";
import Header from "../../../public-area/components/header";
import PostBody from "../../components/post-body";
import SectionSeparator from "../../components/section-separator";
import MoreStories from "../../components/more-stories";
type Props = {
post: Post;
morePosts: Post[];
preview: boolean;
};
const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
const router = useRouter();
if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />;
}
console.log("post", post);
return (
<div className="flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-grow">
<section className="relative">
{/* Background image */}
<div className="absolute inset-0 h-128 pt-16 box-content">
<img
className="absolute inset-0 w-full h-full object-cover opacity-25"
src={post.coverImage.responsiveImage.src}
width={post.coverImage.responsiveImage.width}
height={post.coverImage.responsiveImage.height}
alt={post.coverImage.responsiveImage.alt ?? `${post.title} cover image`}
/>
<div
className="absolute inset-0 bg-gradient-to-t from-white dark:from-gray-900"
aria-hidden="true"
/>
</div>
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
<div className="pt-32 pb-12 md:pt-40 md:pb-20">
<div className="max-w-3xl mx-auto">
<article>
{/* Article header */}
<header className="mb-8">
{/* Title and excerpt */}
<div className="text-center md:text-left">
<h1 className="h1 font-mackinac mb-4">{post.title}</h1>
<p className="text-xl text-gray-600 dark:text-gray-400">{post.excerpt}</p>
</div>
{/* Article meta */}
<div className="md:flex md:items-center md:justify-between mt-5">
{/* Author meta */}
<div className="flex items-center justify-center">
<img
className="rounded-full flex-shrink-0 mr-3"
src={post.author.picture.url}
width="32"
height="32"
alt="Author 04"
/>
<div>
<span className="text-gray-600 dark:text-gray-400">By </span>
<a
className="font-medium text-gray-800 dark:text-gray-300 hover:underline"
href="#0"
>
{post.author.name}
</a>
<span className="text-gray-600 dark:text-gray-400">
{" "}
· {formatDate(new Date(post.date))}
</span>
</div>
</div>
</div>
</header>
<hr className="w-5 h-px pt-px bg-gray-400 dark:bg-gray-500 border-0 mb-8" />
{/* Article content */}
<div className="text-lg text-gray-600 dark:text-gray-400">
<PostBody content={post.content} />
</div>
</article>
<SectionSeparator />
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</div>
</div>
</div>
</section>
</main>
</div>
);
/*return (
<Layout preview={preview}>
<Container>
<Header />
{router.isFallback ? (
<PostTitle>Loading</PostTitle>
) : (
<>
<article>
<Head>
<title>
{post.title} | Next.js Blog Example with {CMS_NAME}
</title>
<meta property="og:image" content={post.ogImage.url} />
</Head>
<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
/>
<PostBody content={post.content} />
</article>
<SectionSeparator />
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</>
)}
</Container>
</Layout>
);*/
};
export default PostPage;
export const getStaticProps: GetStaticProps = async ({ params, preview = false }) => {
if (!params || !params.slug || Array.isArray(params.slug)) {
return {
notFound: true,
};
}
const data = await getPostAndMorePosts(params.slug, preview);
const content = await markdownToHtml(data.post.content || "");
return {
props: {
preview,
post: {
...data.post,
content,
},
morePosts: data.morePosts,
},
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const allPosts = await getAllPostsWithSlug();
return {
paths: allPosts.map((post) => `/articles/${post.slug}`),
fallback: true,
};
};

View File

@ -1,12 +0,0 @@
import type { BlitzPage } from "blitz";
import Layout from "../../public-area/components/layout";
const Blog: BlitzPage = () => {
return <article className="m-auto">Coming soon.</article>;
};
Blog.getLayout = (page) => <Layout title="Blog">{page}</Layout>;
Blog.suppressFirstRenderFlicker = true;
export default Blog;

View File

@ -1,18 +0,0 @@
.markdown {
@apply text-lg leading-relaxed;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown blockquote {
@apply my-6;
}
.markdown h2 {
@apply text-3xl mt-12 mb-4 leading-snug;
}
.markdown h3 {
@apply text-2xl mt-8 mb-4 leading-snug;
}

View File

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

View File

@ -0,0 +1,40 @@
import invariant from "tiny-invariant";
invariant(typeof process.env.APP_BASE_URL === "string", `Please define the "APP_BASE_URL" environment variable`);
invariant(
typeof process.env.INVITATION_TOKEN_SECRET === "string",
`Please define the "INVITATION_TOKEN_SECRET" environment variable`,
);
invariant(typeof process.env.SESSION_SECRET === "string", `Please define the "SESSION_SECRET" environment variable`);
invariant(typeof process.env.AWS_SES_REGION === "string", `Please define the "AWS_SES_REGION" environment variable`);
invariant(
typeof process.env.AWS_SES_ACCESS_KEY_ID === "string",
`Please define the "AWS_SES_ACCESS_KEY_ID" environment variable`,
);
invariant(
typeof process.env.AWS_SES_ACCESS_KEY_SECRET === "string",
`Please define the "AWS_SES_ACCESS_KEY_SECRET" environment variable`,
);
invariant(
typeof process.env.AWS_SES_FROM_EMAIL === "string",
`Please define the "AWS_SES_FROM_EMAIL" environment variable`,
);
invariant(typeof process.env.REDIS_URL === "string", `Please define the "REDIS_URL" environment variable`);
export default {
app: {
baseUrl: process.env.APP_BASE_URL,
invitationTokenSecret: process.env.INVITATION_TOKEN_SECRET,
sessionSecret: process.env.SESSION_SECRET,
},
awsSes: {
awsRegion: process.env.AWS_SES_REGION,
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
fromEmail: process.env.AWS_SES_FROM_EMAIL,
},
redis: {
url: process.env.REDIS_URL,
password: process.env.REDIS_PASSWORD,
},
};

View File

@ -1,5 +0,0 @@
import { CronJob } from "quirrel/blitz";
import backup from "../../../../db/backup";
export default CronJob("api/cron/daily-backup", "0 0 * * *", async () => backup("daily"));

View File

@ -1,5 +0,0 @@
import { CronJob } from "quirrel/blitz";
import backup from "../../../../db/backup";
export default CronJob("api/cron/monthly-backup", "0 0 1 * *", async () => backup("monthly"));

View File

@ -1,5 +0,0 @@
import { CronJob } from "quirrel/blitz";
import backup from "../../../../db/backup";
export default CronJob("api/cron/weekly-backup", "0 0 * * 0", async () => backup("weekly"));

View File

@ -1,91 +0,0 @@
import type { ReactElement, ReactChild } from "react";
type AlertVariant = "error" | "success" | "info" | "warning";
type AlertVariantProps = {
backgroundColor: string;
icon: ReactElement;
titleTextColor: string;
messageTextColor: string;
};
type Props = {
title: ReactChild;
message: ReactChild;
variant: AlertVariant;
};
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
error: {
backgroundColor: "bg-red-50",
icon: (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-red-800",
messageTextColor: "text-red-700",
},
success: {
backgroundColor: "bg-green-50",
icon: (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-green-800",
messageTextColor: "text-green-700",
},
info: {
backgroundColor: "bg-primary-50",
icon: (
<svg className="h-5 w-5 text-primary-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-primary-800",
messageTextColor: "text-primary-700",
},
warning: {
backgroundColor: "bg-yellow-50",
icon: (
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-yellow-800",
messageTextColor: "text-yellow-700",
},
};
export default function Alert({ title, message, variant }: Props) {
const variantProperties = ALERT_VARIANTS[variant];
return (
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
<div className="flex">
<div className="flex-shrink-0">{variantProperties.icon}</div>
<div className="ml-3">
<h3 className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}>{title}</h3>
<div className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}>{message}</div>
</div>
</div>
</div>
);
}

View File

@ -1,63 +0,0 @@
import type { ErrorProps } from "next/error";
import { ErrorComponent as DefaultErrorComponent } from "blitz";
import Sentry from "../../../integrations/sentry";
type ExtraProps = {
hasGetInitialPropsRun?: boolean;
err?: any;
};
class ErrorComponent extends DefaultErrorComponent<ExtraProps> {
render() {
const { statusCode, hasGetInitialPropsRun, err } = this.props;
if (!hasGetInitialPropsRun && err) {
// getInitialProps is not called in case of
// https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
// err via _app.js so it can be captured
Sentry.captureException(err);
}
return <DefaultErrorComponent statusCode={statusCode} />;
}
}
ErrorComponent.getInitialProps = async (ctx) => {
const errorInitialProps: ErrorProps & ExtraProps = await DefaultErrorComponent.getInitialProps(ctx);
// Workaround for https://github.com/vercel/next.js/issues/8592, mark when
// getInitialProps has run
errorInitialProps.hasGetInitialPropsRun = true;
// Running on the server, the response object (`res`) is available.
// Next.js will pass an err on the server if a page's data fetching methods
// threw or returned a Promise that rejected
//
// Running on the client (browser), Next.js will provide an err if:
// - a page's `getInitialProps` threw or returned a Promise that rejected
// - an exception was thrown somewhere in the React lifecycle (render,
// componentDidMount, etc) that was caught by Next.js's React Error
// Boundary. Read more about what types of exceptions are caught by Error
// Boundaries: https://reactjs.org/docs/error-boundaries.html
if (ctx.res?.statusCode === 404) {
// Opinionated: do not record an exception in Sentry for 404
return { statusCode: 404 };
}
if (ctx.err) {
Sentry.captureException(ctx.err);
await Sentry.flush(2000);
return errorInitialProps;
}
// If this point is reached, getInitialProps was called without any
// information about what the error might be. This is unexpected and may
// indicate a bug introduced in Next.js, so record it in Sentry
Sentry.captureException(new Error(`_error.js getInitialProps missing data at path: ${ctx.asPath}`));
await Sentry.flush(2000);
return errorInitialProps;
};
export default ErrorComponent;

View File

@ -1,56 +0,0 @@
import { forwardRef, PropsWithoutRef } from "react";
import { useFormContext } from "react-hook-form";
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
/** Field name. */
name: string;
/** Field label. */
label: string;
/** Field type. Doesn't include radio buttons and checkboxes */
type?: "text" | "password" | "email" | "number";
outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>;
}
export const LabeledTextField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
({ label, outerProps, name, ...props }, ref) => {
const {
register,
formState: { isSubmitting, errors },
} = useFormContext();
const error = Array.isArray(errors[name]) ? errors[name].join(", ") : errors[name]?.message || errors[name];
return (
<div {...outerProps}>
<label>
{label}
<input disabled={isSubmitting} {...register(name)} {...props} />
</label>
{error && (
<div role="alert" style={{ color: "red" }}>
{error}
</div>
)}
<style jsx>{`
label {
display: flex;
flex-direction: column;
align-items: start;
font-size: 1rem;
}
input {
font-size: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
border: 1px solid purple;
appearance: none;
margin-top: 0.5rem;
}
`}</style>
</div>
);
},
);
export default LabeledTextField;

View File

@ -1,11 +0,0 @@
import clsx from "clsx";
import styles from "./spinner.module.css";
export default function Spinner() {
return (
<div className="h-full flex">
<div className={clsx(styles.ring, "m-auto text-primary-400")} />
</div>
);
}

View File

@ -1,11 +0,0 @@
import { useQuery } from "blitz";
import getCurrentPhoneNumber from "app/phone-numbers/queries/get-current-phone-number";
import useCurrentUser from "./use-current-user";
export default function useUserPhoneNumber() {
const { hasFilledTwilioCredentials } = useCurrentUser();
const [phoneNumber] = useQuery(getCurrentPhoneNumber, {}, { enabled: hasFilledTwilioCredentials });
return phoneNumber;
}

View File

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

View File

@ -1,71 +0,0 @@
import { getConfig, useMutation } from "blitz";
import { useEffect, useMemo, useState } from "react";
import setNotificationSubscription from "../mutations/set-notification-subscription";
import useCurrentPhoneNumber from "./use-current-phone-number";
const { publicRuntimeConfig } = getConfig();
export default function useNotifications() {
const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []);
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const [setNotificationSubscriptionMutation] = useMutation(setNotificationSubscription);
const phoneNumber = useCurrentPhoneNumber();
useEffect(() => {
(async () => {
if (!isServiceWorkerSupported) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setSubscription(subscription);
})();
}, [isServiceWorkerSupported]);
async function subscribe() {
if (!isServiceWorkerSupported || !phoneNumber) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicRuntimeConfig.webPush.publicKey),
});
setSubscription(subscription);
await setNotificationSubscriptionMutation({
phoneNumberId: phoneNumber.id,
subscription: subscription.toJSON() as any,
}); // TODO remove as any
}
async function unsubscribe() {
if (!isServiceWorkerSupported) {
return;
}
return subscription?.unsubscribe();
}
return {
isServiceWorkerSupported,
subscription,
subscribe,
unsubscribe,
};
}
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@ -1,22 +0,0 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import * as Panelbear from "@panelbear/panelbear-js";
import type { PanelbearConfig } from "@panelbear/panelbear-js";
export const usePanelbear = (siteId?: string, config: PanelbearConfig = {}) => {
const router = useRouter();
useEffect(() => {
if (!siteId) {
return;
}
Panelbear.load(siteId, { scriptSrc: "/bear.js", ...config });
Panelbear.trackPageview();
const handleRouteChange = () => Panelbear.trackPageview();
router.events.on("routeChangeComplete", handleRouteChange);
return () => router.events.off("routeChangeComplete", handleRouteChange);
// eslint-disable-next-line
}, [siteId]);
};

View File

@ -1,114 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useSession } from "blitz";
import type { Subscription } from "db";
import { SubscriptionStatus } from "db";
import getSubscription from "app/settings/queries/get-subscription";
import updateSubscription from "app/settings/mutations/update-subscription";
import usePaddle from "app/settings/hooks/use-paddle";
import useCurrentUser from "app/core/hooks/use-current-user";
type Params = {
initialData?: Subscription;
};
export default function useSubscription({ initialData }: Params = {}) {
const session = useSession();
const { user } = useCurrentUser();
const [isWaitingForSubChange, setIsWaitingForSubChange] = useState(false);
const [subscription] = useQuery(getSubscription, null, {
initialData,
refetchInterval: isWaitingForSubChange ? 1500 : false,
});
const [updateSubscriptionMutation] = useMutation(updateSubscription);
const resolve = useRef<() => void>();
const promise = useRef<Promise<void>>();
const { Paddle } = usePaddle({
eventCallback(data) {
if (["Checkout.Close", "Checkout.Complete"].includes(data.event)) {
resolve.current!();
promise.current = new Promise((r) => (resolve.current = r));
}
},
});
// cancel subscription polling when we get a new subscription
useEffect(() => setIsWaitingForSubChange(false), [subscription?.paddleSubscriptionId, subscription?.status]);
useEffect(() => {
promise.current = new Promise((r) => (resolve.current = r));
}, []);
type BuyParams = {
planId: number;
coupon?: string;
};
async function subscribe(params: BuyParams) {
if (!user || !session.orgId) {
return;
}
const { planId, coupon } = params;
const checkoutOpenParams = {
email: user.email,
product: planId,
allowQuantity: false,
passthrough: JSON.stringify({ organizationId: session.orgId }),
coupon: "",
};
if (coupon) {
checkoutOpenParams.coupon = coupon;
}
Paddle.Checkout.open(checkoutOpenParams);
setIsWaitingForSubChange(true);
return promise.current;
}
async function updatePaymentMethod({ updateUrl }: { updateUrl: string }) {
const checkoutOpenParams = { override: updateUrl };
Paddle.Checkout.open(checkoutOpenParams);
setIsWaitingForSubChange(true);
return promise.current;
}
async function cancelSubscription({ cancelUrl }: { cancelUrl: string }) {
const checkoutOpenParams = { override: cancelUrl };
Paddle.Checkout.open(checkoutOpenParams);
setIsWaitingForSubChange(true);
return promise.current;
}
type ChangePlanParams = {
planId: number;
};
async function changePlan({ planId }: ChangePlanParams) {
try {
await updateSubscriptionMutation({ planId });
setIsWaitingForSubChange(true);
} catch (error) {
console.log("error", error);
}
}
const hasActiveSubscription = Boolean(subscription && subscription?.status !== SubscriptionStatus.deleted);
return {
subscription,
hasActiveSubscription,
subscribe,
updatePaymentMethod,
cancelSubscription,
changePlan,
};
}

View File

@ -1,22 +0,0 @@
import { ReactNode } from "react";
import { Head } from "blitz";
type LayoutProps = {
title?: string;
children: ReactNode;
};
const BaseLayout = ({ title, children }: LayoutProps) => {
return (
<>
<Head>
<title>{title ? `${title} | Shellphone` : "Shellphone"}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
{children}
</>
);
};
export default BaseLayout;

View File

@ -1,45 +0,0 @@
import type { ReactNode } from "react";
import { Link, useRouter } from "blitz";
import { IoCall, IoKeypad, IoChatbubbles, IoSettings } from "react-icons/io5";
import clsx from "clsx";
export default function Footer() {
return (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10"
style={{ flex: "0 0 50px" }}
>
<NavLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
<NavLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />
<NavLink label="Messages" path="/messages" icon={<IoChatbubbles className="w-6 h-6" />} />
<NavLink label="Settings" path="/settings" icon={<IoSettings className="w-6 h-6" />} />
</footer>
);
}
type NavLinkProps = {
path: string;
label: string;
icon: ReactNode;
};
function NavLink({ path, label, icon }: NavLinkProps) {
const router = useRouter();
const isActiveRoute = router.pathname.startsWith(path);
return (
<div className="flex flex-col items-center justify-around h-full">
<Link href={path} prefetch={false}>
<a
className={clsx("flex flex-col items-center", {
"text-primary-500": isActiveRoute,
"text-[#959595]": !isActiveRoute,
})}
>
{icon}
<span className="text-xs">{label}</span>
</a>
</Link>
</div>
);
}

View File

@ -1,119 +0,0 @@
import type { ErrorInfo } from "react";
import { Component, Suspense } from "react";
import type { BlitzLayout } from "blitz";
import {
Head,
withRouter,
AuthenticationError,
AuthorizationError,
CSRFTokenMismatchError,
NotFoundError,
RedirectError,
Routes,
} from "blitz";
import type { WithRouterProps } from "next/dist/client/with-router";
import appLogger from "integrations/logger";
import Footer from "./footer";
import Loader from "./loader";
type Props = {
title: string;
pageTitle?: string;
hideFooter?: true;
};
const logger = appLogger.child({ module: "Layout" });
const AppLayout: BlitzLayout<Props> = ({ children, title, pageTitle = title, hideFooter = false }) => {
return (
<>
{pageTitle ? (
<Head>
<title>{pageTitle} | Shellphone</title>
</Head>
) : null}
<Suspense fallback={<Loader />}>
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
<div className="flex flex-col w-full h-full">
<div className="flex flex-col flex-1 w-full overflow-y-auto">
<main className="flex flex-col flex-1 my-0 h-full">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
{!hideFooter ? <Footer /> : null}
</div>
</div>
</Suspense>
</>
);
};
AppLayout.authenticate = { redirectTo: Routes.SignIn() };
type ErrorBoundaryState =
| {
isError: false;
}
| {
isError: true;
errorMessage: string;
};
const blitzErrors = [RedirectError, AuthenticationError, AuthorizationError, CSRFTokenMismatchError, NotFoundError];
const ErrorBoundary = withRouter(
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
public readonly state = {
isError: false,
} as const;
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
isError: true,
errorMessage: error.message,
};
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.trace("ddd");
logger.error(error, errorInfo.componentStack);
if (blitzErrors.some((blitzError) => error instanceof blitzError)) {
// let Blitz ErrorBoundary handle this one
throw error;
}
// if network error and connection lost, display the auto-reload page with countdown
}
public render() {
if (this.state.isError) {
return (
<>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Oops, something went wrong.
</h2>
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
Would you like to{" "}
<button
className="inline-flex space-x-2 items-center text-left"
onClick={this.props.router.reload}
>
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
reload the page
</span>
</button>{" "}
?
</p>
</>
);
}
return this.props.children;
}
},
);
export default AppLayout;

File diff suppressed because one or more lines are too long

View File

@ -1,8 +0,0 @@
#gradientCanvas {
width: 100%;
height: 100%;
--gradient-color-1: #c3e4ff;
--gradient-color-2: #6ec3f4;
--gradient-color-3: #eae2ff;
--gradient-color-4: #b9beff;
}

View File

@ -1,26 +0,0 @@
import { useEffect } from "react";
import Logo from "../../components/logo";
import { Gradient } from "./loader-gradient.js";
import styles from "./loader.module.css";
export default function Loader() {
useEffect(() => {
const gradient = new Gradient();
// @ts-ignore
gradient.initGradient(`#${styles.gradientCanvas}`);
}, []);
return (
<div className="min-h-screen min-w-screen relative">
<div className="relative z-10 min-h-screen flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-12 w-12" />
<span className="mt-2 text-center text-lg leading-9 text-gray-900">Loading up Shellphone...</span>
</div>
</div>
<canvas id={styles.gradientCanvas} className="absolute top-0 z-0" />
</div>
);
}

View File

@ -1,55 +0,0 @@
import { resolver } from "blitz";
import { z } from "zod";
import db from "../../../db";
import appLogger from "../../../integrations/logger";
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../utils";
const logger = appLogger.child({ mutation: "set-notification-subscription" });
const Body = z.object({
organizationId: z.string().optional(),
phoneNumberId: z.string(),
subscription: z.object({
endpoint: z.string(),
expirationTime: z.number().nullable(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
}),
});
export default resolver.pipe(
resolver.zod(Body),
resolver.authorize(),
setDefaultOrganizationId,
enforceSuperAdminIfNotCurrentOrganization,
async ({ organizationId, phoneNumberId, subscription }) => {
const phoneNumber = await db.phoneNumber.findFirst({
where: { id: phoneNumberId, organizationId },
include: { organization: true },
});
if (!phoneNumber) {
return;
}
try {
await db.notificationSubscription.create({
data: {
organizationId,
phoneNumberId,
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys_p256dh: subscription.keys.p256dh,
keys_auth: subscription.keys.auth,
},
});
} catch (error: any) {
if (error.code !== "P2002") {
logger.error(error);
// we might want to `throw error`;
}
}
},
);

View File

@ -1,153 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: optional;
font-style: normal;
font-named-instance: "Regular";
src: url("/fonts/inter-roman.var.woff2") format("woff2");
}
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: optional;
font-style: italic;
font-named-instance: "Italic";
src: url("/fonts/inter-italic.var.woff2") format("woff2");
}
@font-face {
font-family: "P22 Mackinac Pro";
src: url("/fonts/P22MackinacPro-Book.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: optional;
}
@font-face {
font-family: "P22 Mackinac Pro";
src: url("/fonts/P22MackinacPro-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: optional;
}
@font-face {
font-family: "P22 Mackinac Pro";
src: url("/fonts/P22MackinacPro-ExtraBold.woff2") format("woff2");
font-weight: 800;
font-style: normal;
font-display: optional;
}
@font-face {
font-family: "P22 Mackinac Pro";
src: url("/fonts/P22MackinacPro-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: optional;
}
.font-heading {
@apply font-mackinac tracking-tight font-bold;
word-spacing: 0.025em;
}
.divide-y > :first-child {
@apply border-t;
}
.divide-y > :last-child:not([hidden]) {
@apply border-b;
}
.h1 {
@apply text-4xl font-extrabold tracking-tighter;
}
.h2 {
@apply text-3xl font-extrabold tracking-tighter;
}
.h3 {
@apply text-3xl font-extrabold;
}
.h4 {
@apply text-2xl font-extrabold tracking-tight;
}
@screen md {
.h1 {
@apply text-5xl;
}
.h2 {
@apply text-4xl;
}
}
.btn,
.btn-sm {
@apply font-medium inline-flex items-center justify-center border border-transparent rounded leading-snug transition duration-150 ease-in-out;
}
.btn {
@apply px-8 py-3;
}
.btn-sm {
@apply px-4 py-2;
}
.form-input,
.form-textarea,
.form-multiselect,
.form-select,
.form-checkbox,
.form-radio {
@apply bg-white border border-gray-300 focus:border-gray-400;
}
.form-input,
.form-textarea,
.form-multiselect,
.form-select,
.form-checkbox {
@apply rounded;
}
.form-input,
.form-textarea,
.form-multiselect,
.form-select {
@apply leading-snug py-3 px-4;
}
.form-input,
.form-textarea {
@apply placeholder-gray-500;
}
.form-select {
@apply pr-10;
}
.form-checkbox,
.form-radio {
@apply text-primary-600;
}
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View File

@ -1,4 +0,0 @@
export type ApiError = {
statusCode: number;
errorMessage: string;
};

View File

@ -1,35 +0,0 @@
import type { Ctx } from "blitz";
import type { Prisma } from "../../db";
import { GlobalRole } from "../../db";
function assert(condition: any, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function setDefaultOrganizationId<T extends Record<any, any>>(
input: T,
{ session }: Ctx,
): T & { organizationId: Prisma.StringNullableFilter | string } {
assert(session.orgId, "Missing session.orgId in setDefaultOrganizationId");
if (input.organizationId) {
// Pass through the input
return input as T & { organizationId: string };
} else if (session.roles?.includes(GlobalRole.SUPERADMIN)) {
// Allow viewing any organization
return { ...input, organizationId: { not: "" } };
} else {
// Set organizationId to session.orgId
return { ...input, organizationId: session.orgId };
}
}
export function enforceSuperAdminIfNotCurrentOrganization<T extends Record<any, any>>(input: T, ctx: Ctx): T {
assert(ctx.session.orgId, "missing session.orgId");
assert(input.organizationId, "missing input.organizationId");
if (input.organizationId !== ctx.session.orgId) {
ctx.session.$authorize(GlobalRole.SUPERADMIN);
}
return input;
}

3
app/cron-jobs/index.ts Normal file
View File

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

View File

@ -0,0 +1,14 @@
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 * * *",
);

4
app/entry.client.tsx Normal file
View File

@ -0,0 +1,4 @@
import { hydrate } from "react-dom";
import { RemixBrowser } from "@remix-run/react";
hydrate(<RemixBrowser />, document);

19
app/entry.server.tsx Normal file
View File

@ -0,0 +1,19 @@
import { renderToString } from "react-dom/server";
import type { EntryContext } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
const markup = renderToString(<RemixServer context={remixContext} url={request.url} />);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders,
});
}

View File

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

View File

@ -0,0 +1,56 @@
import { type ActionFunction, json } from "@remix-run/node";
import { GlobalRole, MembershipRole } from "@prisma/client";
import db from "~/utils/db.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 { orgName, 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: { name: orgName },
},
},
},
},
});
} catch (error: any) {
if (error.code === "P2002") {
if (error.meta.target[0] === "email") {
return json<RegisterActionData>({
errors: { general: "An account with this email address already exists" },
});
}
}
return json<RegisterActionData>({
errors: { general: `An unexpected error happened${error.code ? `\nCode: ${error.code}` : ""}` },
});
}
return authenticate({ email, password, request, failureRedirect: "/register" });
};
export default action;

View File

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

View File

@ -0,0 +1,22 @@
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 { email, password } = validation.data;
return authenticate({ email, password, request, successRedirect: redirectTo });
};
export default action;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,83 @@
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="orgName"
type="text"
label="Organization name"
disabled={isSubmitting}
error={actionData?.errors?.orgName}
tabIndex={1}
/>
<LabeledTextField
name="fullName"
type="text"
label="Full name"
disabled={isSubmitting}
error={actionData?.errors?.fullName}
tabIndex={2}
/>
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={3}
/>
<LabeledTextField
name="password"
type="password"
label="Password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={4}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={5}
>
Register
</Button>
</Form>
</section>
);
}

View File

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

View File

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

View File

@ -2,15 +2,16 @@ import { z } from "zod";
export const password = z.string().min(10).max(100); export const password = z.string().min(10).max(100);
export const Signup = z.object({ export const Register = z.object({
fullName: z.string(), orgName: z.string().nonempty(),
fullName: z.string().nonempty(),
email: z.string().email(), email: z.string().email(),
password, password,
}); });
export const Login = z.object({ export const SignIn = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string(), password,
}); });
export const ForgotPassword = z.object({ export const ForgotPassword = z.object({
@ -27,3 +28,14 @@ export const ResetPassword = z
message: "Passwords don't match", message: "Passwords don't match",
path: ["passwordConfirmation"], // set the path of the error path: ["passwordConfirmation"], // set the path of the error
}); });
export const AcceptInvitation = z.object({
fullName: z.string(),
email: z.string().email(),
password,
token: z.string(),
});
export const AcceptAuthedInvitation = z.object({
token: z.string(),
});

View File

@ -0,0 +1,51 @@
import type { FunctionComponent, ReactChild } from "react";
type AlertVariant = "error" | "success" | "info" | "warning";
type AlertVariantProps = {
backgroundColor: string;
titleTextColor: string;
messageTextColor: string;
};
type Props = {
title: ReactChild;
message: ReactChild;
variant: AlertVariant;
};
const ALERT_VARIANTS: Record<AlertVariant, AlertVariantProps> = {
error: {
backgroundColor: "bg-red-50",
titleTextColor: "text-red-800",
messageTextColor: "text-red-700",
},
success: {
backgroundColor: "bg-green-50",
titleTextColor: "text-green-800",
messageTextColor: "text-green-700",
},
info: {
backgroundColor: "bg-primary-50",
titleTextColor: "text-primary-800",
messageTextColor: "text-primary-700",
},
warning: {
backgroundColor: "bg-yellow-50",
titleTextColor: "text-yellow-800",
messageTextColor: "text-yellow-700",
},
};
const Alert: FunctionComponent<Props> = ({ title, message, variant }) => {
const variantProperties = ALERT_VARIANTS[variant];
return (
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
<h3 className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}>{title}</h3>
<div className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}>{message}</div>
</div>
);
};
export default Alert;

View File

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

View File

@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import { NavLink } from "@remix-run/react";
import { IoCall, IoKeypad, IoChatbubbles, IoSettings } from "react-icons/io5";
import clsx from "clsx";
export default function Footer() {
return (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10"
style={{ flex: "0 0 50px" }}
>
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
<FooterLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />
<FooterLink label="Messages" path="/messages" icon={<IoChatbubbles className="w-6 h-6" />} />
<FooterLink label="Settings" path="/settings" icon={<IoSettings className="w-6 h-6" />} />
</footer>
);
}
type FooterLinkProps = {
path: string;
label: string;
icon: ReactNode;
};
function FooterLink({ path, label, icon }: FooterLinkProps) {
return (
<div className="flex flex-col items-center justify-around h-full">
<NavLink
to={path}
prefetch="none"
className={({ isActive }) =>
clsx("flex flex-col items-center", {
"text-primary-500": isActive,
"text-[#959595]": !isActive,
})
}
>
{icon}
<span className="text-xs">{label}</span>
</NavLink>
</div>
);
}

View File

@ -1,8 +1,8 @@
import { Routes, useRouter } from "blitz"; import { useNavigate } from "@remix-run/react";
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5"; import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
export default function InactiveSubscription() { export default function InactiveSubscription() {
const router = useRouter(); const navigate = useNavigate();
return ( 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"> <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">
@ -22,7 +22,7 @@ export default function InactiveSubscription() {
<button <button
type="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" 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={() => router.push(Routes.Billing())} onClick={() => navigate("/settings/billing")}
> >
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" /> <IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Choose a plan Choose a plan

View File

@ -0,0 +1,46 @@
import type { FunctionComponent, InputHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
type Props = {
name: string;
label: ReactNode;
sideLabel?: ReactNode;
type?: "text" | "password" | "email";
error?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const LabeledTextField: FunctionComponent<Props> = ({ name, label, sideLabel, type = "text", error, ...props }) => {
const hasSideLabel = !!sideLabel;
return (
<div className="mb-6">
<label
htmlFor={name}
className={clsx("text-sm font-medium leading-5 text-gray-700", {
block: !hasSideLabel,
"flex justify-between": hasSideLabel,
})}
>
{label}
{sideLabel ?? null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id={name}
name={name}
type={type}
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"
required
{...props}
/>
</div>
{error ? (
<div role="alert" className="text-red-600">
{error}
</div>
) : null}
</div>
);
};
export default LabeledTextField;

View File

@ -1,14 +1,12 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import Image from "next/image";
import clsx from "clsx";
type Props = { type Props = {
className?: string; className?: string;
}; };
const Logo: FunctionComponent<Props> = ({ className }) => ( const Logo: FunctionComponent<Props> = ({ className }) => (
<div className={clsx("relative", className)}> <div className={className}>
<Image src="/shellphone.png" layout="fill" alt="app logo" /> <img src="/shellphone.png" alt="app logo" />
</div> </div>
); );

View File

@ -1,9 +1,7 @@
import { Routes, useRouter } from "blitz"; import { Link } from "@remix-run/react";
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5"; import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
export default function MissingTwilioCredentials() { export default function MissingTwilioCredentials() {
const router = useRouter();
return ( return (
<div className="text-center my-auto"> <div className="text-center my-auto">
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" /> <IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
@ -14,14 +12,13 @@ export default function MissingTwilioCredentials() {
to set up your phone number. to set up your phone number.
</p> </p>
<div className="mt-6"> <div className="mt-6">
<button <Link
type="button" to="/settings/account"
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" 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={() => router.push(Routes.PhoneSettings())}
> >
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" /> <IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Set up my phone number Set up my phone number
</button> </Link>
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import type { FunctionComponent, MutableRefObject } from "react"; import type { FunctionComponent, MutableRefObject, PropsWithChildren } from "react";
import { Fragment } from "react"; import { Fragment } from "react";
import { Transition, Dialog } from "@headlessui/react"; import { Transition, Dialog } from "@headlessui/react";
@ -8,7 +8,7 @@ type Props = {
onClose: () => void; onClose: () => void;
}; };
const Modal: FunctionComponent<Props> = ({ children, initialFocus, isOpen, onClose }) => { const Modal: FunctionComponent<PropsWithChildren<Props>> = ({ children, initialFocus, isOpen, onClose }) => {
return ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={isOpen} as={Fragment}>
<Dialog <Dialog
@ -52,7 +52,7 @@ const Modal: FunctionComponent<Props> = ({ children, initialFocus, isOpen, onClo
); );
}; };
export const ModalTitle: FunctionComponent = ({ children }) => ( export const ModalTitle: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900"> <Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{children} {children}
</Dialog.Title> </Dialog.Title>

View File

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

View File

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

View File

@ -0,0 +1,13 @@
import { useMatches } from "@remix-run/react";
import type { SessionOrganization, SessionUser } from "~/utils/auth.server";
export default function useSession() {
const matches = useMatches();
const __appRoute = matches.find((match) => match.id === "routes/__app");
if (!__appRoute) {
throw new Error("useSession hook called outside _app route");
}
return __appRoute.data as SessionUser & { currentOrganization: SessionOrganization };
}

View File

@ -1,8 +1,9 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import { useRef } from "react"; import { useRef } from "react";
import { Link, Routes, useRouter } from "blitz"; import { useNavigate } from "@remix-run/react";
import { Link } from "react-router-dom";
import Modal, { ModalTitle } from "app/core/components/modal"; import Modal, { ModalTitle } from "~/features/core/components/modal";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -11,7 +12,7 @@ type Props = {
const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => { const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
const openSettingsButtonRef = useRef<HTMLButtonElement>(null); const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
const router = useRouter(); const navigate = useNavigate();
return ( return (
<Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} onClose={closeModal}> <Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} onClose={closeModal}>
@ -21,8 +22,8 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
<div className="mt-2 text-gray-500"> <div className="mt-2 text-gray-500">
<p> <p>
First things first. Head over to your{" "} First things first. Head over to your{" "}
<Link href={Routes.PhoneSettings()}> <Link to="/settings/phone" className="underline">
<a className="underline">phone settings</a> phone settings
</Link>{" "} </Link>{" "}
to set up your phone number. to set up your phone number.
</p> </p>
@ -34,7 +35,7 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
ref={openSettingsButtonRef} ref={openSettingsButtonRef}
type="button" type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
onClick={() => router.push(Routes.PhoneSettings())} onClick={() => navigate("/settings/phone")}
> >
Take me there Take me there
</button> </button>

View File

@ -1,4 +1,4 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent, PropsWithChildren } from "react";
import type { PressHookProps } from "@react-aria/interactions"; import type { PressHookProps } from "@react-aria/interactions";
import { usePress } from "@react-aria/interactions"; import { usePress } from "@react-aria/interactions";
@ -7,7 +7,7 @@ type Props = {
onZeroPressProps: PressHookProps; onZeroPressProps: PressHookProps;
}; };
const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroPressProps }) => { const Keypad: FunctionComponent<PropsWithChildren<Props>> = ({ children, onDigitPressProps, onZeroPressProps }) => {
return ( return (
<section> <section>
<Row> <Row>
@ -53,18 +53,18 @@ const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroP
export default Keypad; export default Keypad;
const Row: FunctionComponent = ({ children }) => ( const Row: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">{children}</div> <div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">{children}</div>
); );
const DigitLetters: FunctionComponent = ({ children }) => <div className="text-xs text-gray-600">{children}</div>; const DigitLetters: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => <div className="text-xs text-gray-600">{children}</div>;
type DigitProps = { type DigitProps = {
digit: string; digit: string;
onPressProps: Props["onDigitPressProps"]; onPressProps: Props["onDigitPressProps"];
}; };
const Digit: FunctionComponent<DigitProps> = ({ children, digit, onPressProps }) => { const Digit: FunctionComponent<PropsWithChildren<DigitProps>> = ({ children, digit, onPressProps }) => {
const { pressProps } = usePress(onPressProps(digit)); const { pressProps } = usePress(onPressProps(digit));
return ( return (
@ -79,7 +79,7 @@ type ZeroDigitProps = {
onPressProps: Props["onZeroPressProps"]; onPressProps: Props["onZeroPressProps"];
}; };
const ZeroDigit: FunctionComponent<ZeroDigitProps> = ({ onPressProps }) => { const ZeroDigit: FunctionComponent<PropsWithChildren<ZeroDigitProps>> = ({ onPressProps }) => {
const { pressProps } = usePress(onPressProps); const { pressProps } = usePress(onPressProps);
return ( return (

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