remixed v0
This commit is contained in:
parent
9275d4499b
commit
98b89ae0f7
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.env
|
||||
/.idea
|
||||
/cypress/videos
|
||||
/cypress/screenshots
|
||||
/coverage
|
||||
|
||||
# build artifacts
|
||||
/.cache
|
||||
/public/build
|
||||
/build
|
||||
server.js
|
@ -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
1
.env.e2e
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape_e2e
|
25
.env.example
Normal file
25
.env.example
Normal 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
|
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["blitz"],
|
||||
};
|
109
.github/workflows/main.yml
vendored
109
.github/workflows/main.yml
vendored
@ -1,91 +1,84 @@
|
||||
name: Deployment pipeline
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
timeout-minutes: 4
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
test:
|
||||
if: false == true
|
||||
name: Test
|
||||
timeout-minutes: 4
|
||||
e2e:
|
||||
name: E2E tests
|
||||
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:
|
||||
POSTGRES_USER: pgremixtape
|
||||
POSTGRES_PASSWORD: pgpassword
|
||||
POSTGRES_DB: remixtape
|
||||
ports:
|
||||
- "5432:5432"
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
name: Compile
|
||||
timeout-minutes: 6
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
DATABASE_URL: postgresql://pgremixtape:pgpassword@localhost:5432/remixtape
|
||||
REDIS_URL: redis://localhost:6379
|
||||
CI: true
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: cp .env.example .env
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run db:setup
|
||||
- run: npm run build
|
||||
env:
|
||||
DATOCMS_API_TOKEN: ${{ secrets.DATOCMS_API_TOKEN }}
|
||||
QUIRREL_BASE_URL: doesntmatter.shellphone.app
|
||||
- run: npx dotenv npm run e2e:ci
|
||||
|
||||
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'
|
||||
needs: [lint, test, build]
|
||||
name: Deploy dev.shellphone.app
|
||||
needs: [lint, e2e, typecheck]
|
||||
name: Deploy to Fly.io
|
||||
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.dev.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_DEV }} --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://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
|
||||
args: "deploy --strategy rolling"
|
||||
|
61
.gitignore
vendored
61
.gitignore
vendored
@ -1,55 +1,16 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.pnp.*
|
||||
.npm
|
||||
web_modules/
|
||||
|
||||
# blitz
|
||||
/.blitz/
|
||||
/.next/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.now
|
||||
.blitz**
|
||||
blitz-log.log
|
||||
/.cache
|
||||
/server/build
|
||||
/public/build
|
||||
/build
|
||||
server.js
|
||||
/app/styles/tailwind.css
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
/.idea
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
.envrc
|
||||
.env
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# 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/
|
||||
/cypress/videos
|
||||
/cypress/screenshots
|
||||
/coverage
|
@ -2,4 +2,3 @@
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
npx pretty-quick --staged
|
||||
|
@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx tsc
|
||||
npm run lint
|
||||
#npm run test
|
@ -1,8 +1,6 @@
|
||||
.gitkeep
|
||||
.env*
|
||||
*.ico
|
||||
*.lock
|
||||
db/migrations
|
||||
.next
|
||||
.blitz
|
||||
mailers/**/*.html
|
||||
.env
|
||||
.cache
|
||||
build
|
||||
package-lock.json
|
||||
app/tailwind.css
|
12
.vscode/extensions.json
vendored
12
.vscode/extensions.json
vendored
@ -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": []
|
||||
}
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
57
Dockerfile
Normal file
57
Dockerfile
Normal 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"]
|
@ -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);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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();
|
||||
}
|
@ -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;
|
||||
});
|
@ -1,5 +0,0 @@
|
||||
import type { Ctx } from "blitz";
|
||||
|
||||
export default async function logout(_ = null, ctx: Ctx) {
|
||||
return await ctx.session.$revoke();
|
||||
}
|
@ -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;
|
||||
});
|
@ -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;
|
||||
});
|
@ -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;
|
@ -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;
|
@ -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?
|
||||
<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;
|
@ -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;
|
@ -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;
|
@ -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();
|
||||
}
|
@ -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();
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export default function SectionSeparator() {
|
||||
return <hr className="border-accent-2 mt-28 mb-24" />;
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
@ -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;
|
@ -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;
|
||||
}
|
1
app/config/config.client.ts
Normal file
1
app/config/config.client.ts
Normal file
@ -0,0 +1 @@
|
||||
export default {};
|
40
app/config/config.server.ts
Normal file
40
app/config/config.server.ts
Normal 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,
|
||||
},
|
||||
};
|
@ -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"));
|
@ -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"));
|
@ -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"));
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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]);
|
||||
};
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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`;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
@ -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 */
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
@ -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
3
app/cron-jobs/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import registerPurgeExpiredSession from "./purge-expired-sessions";
|
||||
|
||||
export default [registerPurgeExpiredSession];
|
14
app/cron-jobs/purge-expired-sessions.ts
Normal file
14
app/cron-jobs/purge-expired-sessions.ts
Normal 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
4
app/entry.client.tsx
Normal 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
19
app/entry.server.tsx
Normal 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,
|
||||
});
|
||||
}
|
63
app/features/auth/actions/forgot-password.ts
Normal file
63
app/features/auth/actions/forgot-password.ts
Normal 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,
|
||||
});
|
||||
}
|
56
app/features/auth/actions/register.ts
Normal file
56
app/features/auth/actions/register.ts
Normal 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;
|
56
app/features/auth/actions/reset-password.ts
Normal file
56
app/features/auth/actions/reset-password.ts
Normal 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;
|
22
app/features/auth/actions/sign-in.ts
Normal file
22
app/features/auth/actions/sign-in.ts
Normal 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;
|
11
app/features/auth/loaders/forgot-password.ts
Normal file
11
app/features/auth/loaders/forgot-password.ts
Normal 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;
|
25
app/features/auth/loaders/register.ts
Normal file
25
app/features/auth/loaders/register.ts
Normal 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;
|
23
app/features/auth/loaders/reset-password.ts
Normal file
23
app/features/auth/loaders/reset-password.ts
Normal 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;
|
25
app/features/auth/loaders/sign-in.ts
Normal file
25
app/features/auth/loaders/sign-in.ts
Normal 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;
|
49
app/features/auth/pages/forgot-password.tsx
Normal file
49
app/features/auth/pages/forgot-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
83
app/features/auth/pages/register.tsx
Normal file
83
app/features/auth/pages/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
55
app/features/auth/pages/reset-password.tsx
Normal file
55
app/features/auth/pages/reset-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
75
app/features/auth/pages/sign-in.tsx
Normal file
75
app/features/auth/pages/sign-in.tsx
Normal 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?
|
||||
<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>
|
||||
);
|
||||
}
|
@ -2,15 +2,16 @@ import { z } from "zod";
|
||||
|
||||
export const password = z.string().min(10).max(100);
|
||||
|
||||
export const Signup = z.object({
|
||||
fullName: z.string(),
|
||||
export const Register = z.object({
|
||||
orgName: z.string().nonempty(),
|
||||
fullName: z.string().nonempty(),
|
||||
email: z.string().email(),
|
||||
password,
|
||||
});
|
||||
|
||||
export const Login = z.object({
|
||||
export const SignIn = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
password,
|
||||
});
|
||||
|
||||
export const ForgotPassword = z.object({
|
||||
@ -27,3 +28,14 @@ export const ResetPassword = z
|
||||
message: "Passwords don't match",
|
||||
path: ["passwordConfirmation"], // set the path of the error
|
||||
});
|
||||
|
||||
export const AcceptInvitation = z.object({
|
||||
fullName: z.string(),
|
||||
email: z.string().email(),
|
||||
password,
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const AcceptAuthedInvitation = z.object({
|
||||
token: z.string(),
|
||||
});
|
51
app/features/core/components/alert.tsx
Normal file
51
app/features/core/components/alert.tsx
Normal 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;
|
26
app/features/core/components/button.tsx
Normal file
26
app/features/core/components/button.tsx
Normal 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;
|
44
app/features/core/components/footer.tsx
Normal file
44
app/features/core/components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import { Routes, useRouter } from "blitz";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||
|
||||
export default function InactiveSubscription() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
|
||||
@ -22,7 +22,7 @@ export default function InactiveSubscription() {
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={() => router.push(Routes.Billing())}
|
||||
onClick={() => navigate("/settings/billing")}
|
||||
>
|
||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Choose a plan
|
46
app/features/core/components/labeled-text-field.tsx
Normal file
46
app/features/core/components/labeled-text-field.tsx
Normal 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;
|
@ -1,14 +1,12 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Logo: FunctionComponent<Props> = ({ className }) => (
|
||||
<div className={clsx("relative", className)}>
|
||||
<Image src="/shellphone.png" layout="fill" alt="app logo" />
|
||||
<div className={className}>
|
||||
<img src="/shellphone.png" alt="app logo" />
|
||||
</div>
|
||||
);
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { Routes, useRouter } from "blitz";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||
|
||||
export default function MissingTwilioCredentials() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="text-center my-auto">
|
||||
<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.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
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"
|
||||
onClick={() => router.push(Routes.PhoneSettings())}
|
||||
>
|
||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Set up my phone number
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -1,4 +1,4 @@
|
||||
import type { FunctionComponent, MutableRefObject } from "react";
|
||||
import type { FunctionComponent, MutableRefObject, PropsWithChildren } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
@ -8,7 +8,7 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const Modal: FunctionComponent<Props> = ({ children, initialFocus, isOpen, onClose }) => {
|
||||
const Modal: FunctionComponent<PropsWithChildren<Props>> = ({ children, initialFocus, isOpen, onClose }) => {
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<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">
|
||||
{children}
|
||||
</Dialog.Title>
|
68
app/features/core/components/select.tsx
Normal file
68
app/features/core/components/select.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
app/features/core/components/spinner.tsx
Normal file
15
app/features/core/components/spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
13
app/features/core/hooks/use-session.ts
Normal file
13
app/features/core/hooks/use-session.ts
Normal 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 };
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import type { FunctionComponent } 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 = {
|
||||
isOpen: boolean;
|
||||
@ -11,7 +12,7 @@ type Props = {
|
||||
|
||||
const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
||||
const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} onClose={closeModal}>
|
||||
@ -21,8 +22,8 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
||||
<div className="mt-2 text-gray-500">
|
||||
<p>
|
||||
First things first. Head over to your{" "}
|
||||
<Link href={Routes.PhoneSettings()}>
|
||||
<a className="underline">phone settings</a>
|
||||
<Link to="/settings/phone" className="underline">
|
||||
phone settings
|
||||
</Link>{" "}
|
||||
to set up your phone number.
|
||||
</p>
|
||||
@ -34,7 +35,7 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
||||
ref={openSettingsButtonRef}
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||
onClick={() => router.push(Routes.PhoneSettings())}
|
||||
onClick={() => navigate("/settings/phone")}
|
||||
>
|
||||
Take me there
|
||||
</button>
|
@ -1,4 +1,4 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import type { FunctionComponent, PropsWithChildren } from "react";
|
||||
import type { PressHookProps } from "@react-aria/interactions";
|
||||
import { usePress } from "@react-aria/interactions";
|
||||
|
||||
@ -7,7 +7,7 @@ type Props = {
|
||||
onZeroPressProps: PressHookProps;
|
||||
};
|
||||
|
||||
const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroPressProps }) => {
|
||||
const Keypad: FunctionComponent<PropsWithChildren<Props>> = ({ children, onDigitPressProps, onZeroPressProps }) => {
|
||||
return (
|
||||
<section>
|
||||
<Row>
|
||||
@ -53,18 +53,18 @@ const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroP
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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 = {
|
||||
digit: string;
|
||||
onPressProps: Props["onDigitPressProps"];
|
||||
};
|
||||
|
||||
const Digit: FunctionComponent<DigitProps> = ({ children, digit, onPressProps }) => {
|
||||
const Digit: FunctionComponent<PropsWithChildren<DigitProps>> = ({ children, digit, onPressProps }) => {
|
||||
const { pressProps } = usePress(onPressProps(digit));
|
||||
|
||||
return (
|
||||
@ -79,7 +79,7 @@ type ZeroDigitProps = {
|
||||
onPressProps: Props["onZeroPressProps"];
|
||||
};
|
||||
|
||||
const ZeroDigit: FunctionComponent<ZeroDigitProps> = ({ onPressProps }) => {
|
||||
const ZeroDigit: FunctionComponent<PropsWithChildren<ZeroDigitProps>> = ({ onPressProps }) => {
|
||||
const { pressProps } = usePress(onPressProps);
|
||||
|
||||
return (
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user