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]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
timeout-minutes: 4
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
HUSKY_SKIP_INSTALL: 1
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
|
cache: "npm"
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
|
|
||||||
test:
|
e2e:
|
||||||
if: false == true
|
name: E2E tests
|
||||||
name: Test
|
|
||||||
timeout-minutes: 4
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:13-alpine
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
env:
|
env:
|
||||||
HUSKY_SKIP_INSTALL: 1
|
POSTGRES_USER: pgremixtape
|
||||||
steps:
|
POSTGRES_PASSWORD: pgpassword
|
||||||
- uses: actions/checkout@v2
|
POSTGRES_DB: remixtape
|
||||||
- uses: actions/setup-node@v2
|
ports:
|
||||||
with:
|
- "5432:5432"
|
||||||
node-version: 16
|
redis:
|
||||||
- run: npm ci
|
image: redis:6-alpine
|
||||||
- run: npm test
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
build:
|
--health-interval 10s
|
||||||
name: Compile
|
--health-timeout 5s
|
||||||
timeout-minutes: 6
|
--health-retries 5
|
||||||
runs-on: ubuntu-latest
|
ports:
|
||||||
env:
|
- "6379:6379"
|
||||||
HUSKY_SKIP_INSTALL: 1
|
env:
|
||||||
|
DATABASE_URL: postgresql://pgremixtape:pgpassword@localhost:5432/remixtape
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
CI: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
- run: cp .env.example .env
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
|
cache: "npm"
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- run: npm run db:setup
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
env:
|
- run: npx dotenv npm run e2e:ci
|
||||||
DATOCMS_API_TOKEN: ${{ secrets.DATOCMS_API_TOKEN }}
|
|
||||||
QUIRREL_BASE_URL: doesntmatter.shellphone.app
|
|
||||||
|
|
||||||
deploy_dev:
|
typecheck:
|
||||||
|
name: Typecheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
HUSKY_SKIP_INSTALL: 1
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx tsc
|
||||||
|
|
||||||
|
deploy:
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
needs: [lint, test, build]
|
needs: [lint, e2e, typecheck]
|
||||||
name: Deploy dev.shellphone.app
|
name: Deploy to Fly.io
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
HUSKY_SKIP_INSTALL: 1
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: superfly/flyctl-actions@master
|
- uses: superfly/flyctl-actions@master
|
||||||
env:
|
env:
|
||||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
with:
|
with:
|
||||||
args: "deploy -c ./fly.dev.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_DEV }} --build-arg DATOCMS_API_TOKEN=${{ secrets.DATOCMS_API_TOKEN }}"
|
args: "deploy --strategy rolling"
|
||||||
- uses: appleboy/discord-action@master
|
|
||||||
with:
|
|
||||||
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
|
||||||
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
|
||||||
args: "https://dev.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
|
|
||||||
|
|
||||||
deploy_prod:
|
|
||||||
if: github.ref == 'refs/heads/production'
|
|
||||||
needs: [lint, test, build]
|
|
||||||
name: Deploy www.shellphone.app
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
HUSKY_SKIP_INSTALL: 1
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: superfly/flyctl-actions@master
|
|
||||||
env:
|
|
||||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
|
||||||
with:
|
|
||||||
args: "deploy -c ./fly.prod.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_PROD }} --build-arg DATOCMS_API_TOKEN=${{ secrets.DATOCMS_API_TOKEN }}"
|
|
||||||
- uses: appleboy/discord-action@master
|
|
||||||
with:
|
|
||||||
webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
|
||||||
webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
|
||||||
args: "https://www.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`"
|
|
||||||
# TODO: on pull_request, deploy 24hour-long deployment at {commit_short_hash}.shellphone.app, provision db and seed data
|
|
||||||
|
61
.gitignore
vendored
61
.gitignore
vendored
@ -1,55 +1,16 @@
|
|||||||
# dependencies
|
|
||||||
node_modules
|
node_modules
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.pnp.*
|
|
||||||
.npm
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# blitz
|
/.cache
|
||||||
/.blitz/
|
/server/build
|
||||||
/.next/
|
/public/build
|
||||||
*.sqlite
|
/build
|
||||||
*.sqlite-journal
|
server.js
|
||||||
.now
|
/app/styles/tailwind.css
|
||||||
.blitz**
|
|
||||||
blitz-log.log
|
|
||||||
|
|
||||||
# misc
|
/.idea
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# local env files
|
.env
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
.envrc
|
|
||||||
|
|
||||||
# Logs
|
/cypress/videos
|
||||||
logs
|
/cypress/screenshots
|
||||||
*.log
|
/coverage
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
.coverage
|
|
||||||
*.lcov
|
|
||||||
.nyc_output
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Caches
|
|
||||||
*.tsbuildinfo
|
|
||||||
.eslintcache
|
|
||||||
.node_repl_history
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
.idea/
|
|
@ -2,4 +2,3 @@
|
|||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
npx lint-staged
|
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
|
.gitkeep
|
||||||
.env*
|
.env
|
||||||
*.ico
|
.cache
|
||||||
*.lock
|
build
|
||||||
db/migrations
|
package-lock.json
|
||||||
.next
|
app/tailwind.css
|
||||||
.blitz
|
|
||||||
mailers/**/*.html
|
|
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 password = z.string().min(10).max(100);
|
||||||
|
|
||||||
export const Signup = z.object({
|
export const Register = z.object({
|
||||||
fullName: z.string(),
|
orgName: z.string().nonempty(),
|
||||||
|
fullName: z.string().nonempty(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Login = z.object({
|
export const SignIn = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string(),
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ForgotPassword = z.object({
|
export const ForgotPassword = z.object({
|
||||||
@ -27,3 +28,14 @@ export const ResetPassword = z
|
|||||||
message: "Passwords don't match",
|
message: "Passwords don't match",
|
||||||
path: ["passwordConfirmation"], // set the path of the error
|
path: ["passwordConfirmation"], // set the path of the error
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AcceptInvitation = z.object({
|
||||||
|
fullName: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password,
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AcceptAuthedInvitation = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
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";
|
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||||
|
|
||||||
export default function InactiveSubscription() {
|
export default function InactiveSubscription() {
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
|
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
|
||||||
@ -22,7 +22,7 @@ export default function InactiveSubscription() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
onClick={() => router.push(Routes.Billing())}
|
onClick={() => navigate("/settings/billing")}
|
||||||
>
|
>
|
||||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||||
Choose a plan
|
Choose a plan
|
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 type { FunctionComponent } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Logo: FunctionComponent<Props> = ({ className }) => (
|
const Logo: FunctionComponent<Props> = ({ className }) => (
|
||||||
<div className={clsx("relative", className)}>
|
<div className={className}>
|
||||||
<Image src="/shellphone.png" layout="fill" alt="app logo" />
|
<img src="/shellphone.png" alt="app logo" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1,9 +1,7 @@
|
|||||||
import { Routes, useRouter } from "blitz";
|
import { Link } from "@remix-run/react";
|
||||||
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
|
||||||
|
|
||||||
export default function MissingTwilioCredentials() {
|
export default function MissingTwilioCredentials() {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center my-auto">
|
<div className="text-center my-auto">
|
||||||
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
|
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
|
||||||
@ -14,14 +12,13 @@ export default function MissingTwilioCredentials() {
|
|||||||
to set up your phone number.
|
to set up your phone number.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/settings/account"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
onClick={() => router.push(Routes.PhoneSettings())}
|
|
||||||
>
|
>
|
||||||
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||||
Set up my phone number
|
Set up my phone number
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
@ -1,4 +1,4 @@
|
|||||||
import type { FunctionComponent, MutableRefObject } from "react";
|
import type { FunctionComponent, MutableRefObject, PropsWithChildren } from "react";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { Transition, Dialog } from "@headlessui/react";
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
|
|
||||||
@ -8,7 +8,7 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Modal: FunctionComponent<Props> = ({ children, initialFocus, isOpen, onClose }) => {
|
const Modal: FunctionComponent<PropsWithChildren<Props>> = ({ children, initialFocus, isOpen, onClose }) => {
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -52,7 +52,7 @@ const Modal: FunctionComponent<Props> = ({ children, initialFocus, isOpen, onClo
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModalTitle: FunctionComponent = ({ children }) => (
|
export const ModalTitle: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
|
||||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
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 type { FunctionComponent } from "react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Link, Routes, useRouter } from "blitz";
|
import { useNavigate } from "@remix-run/react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import Modal, { ModalTitle } from "app/core/components/modal";
|
import Modal, { ModalTitle } from "~/features/core/components/modal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -11,7 +12,7 @@ type Props = {
|
|||||||
|
|
||||||
const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
||||||
const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} onClose={closeModal}>
|
<Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} onClose={closeModal}>
|
||||||
@ -21,8 +22,8 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
|||||||
<div className="mt-2 text-gray-500">
|
<div className="mt-2 text-gray-500">
|
||||||
<p>
|
<p>
|
||||||
First things first. Head over to your{" "}
|
First things first. Head over to your{" "}
|
||||||
<Link href={Routes.PhoneSettings()}>
|
<Link to="/settings/phone" className="underline">
|
||||||
<a className="underline">phone settings</a>
|
phone settings
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to set up your phone number.
|
to set up your phone number.
|
||||||
</p>
|
</p>
|
||||||
@ -34,7 +35,7 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
|
|||||||
ref={openSettingsButtonRef}
|
ref={openSettingsButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto"
|
||||||
onClick={() => router.push(Routes.PhoneSettings())}
|
onClick={() => navigate("/settings/phone")}
|
||||||
>
|
>
|
||||||
Take me there
|
Take me there
|
||||||
</button>
|
</button>
|
@ -1,4 +1,4 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent, PropsWithChildren } from "react";
|
||||||
import type { PressHookProps } from "@react-aria/interactions";
|
import type { PressHookProps } from "@react-aria/interactions";
|
||||||
import { usePress } from "@react-aria/interactions";
|
import { usePress } from "@react-aria/interactions";
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ type Props = {
|
|||||||
onZeroPressProps: PressHookProps;
|
onZeroPressProps: PressHookProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroPressProps }) => {
|
const Keypad: FunctionComponent<PropsWithChildren<Props>> = ({ children, onDigitPressProps, onZeroPressProps }) => {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<Row>
|
<Row>
|
||||||
@ -53,18 +53,18 @@ const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroP
|
|||||||
|
|
||||||
export default Keypad;
|
export default Keypad;
|
||||||
|
|
||||||
const Row: FunctionComponent = ({ children }) => (
|
const Row: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
|
||||||
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">{children}</div>
|
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DigitLetters: FunctionComponent = ({ children }) => <div className="text-xs text-gray-600">{children}</div>;
|
const DigitLetters: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => <div className="text-xs text-gray-600">{children}</div>;
|
||||||
|
|
||||||
type DigitProps = {
|
type DigitProps = {
|
||||||
digit: string;
|
digit: string;
|
||||||
onPressProps: Props["onDigitPressProps"];
|
onPressProps: Props["onDigitPressProps"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const Digit: FunctionComponent<DigitProps> = ({ children, digit, onPressProps }) => {
|
const Digit: FunctionComponent<PropsWithChildren<DigitProps>> = ({ children, digit, onPressProps }) => {
|
||||||
const { pressProps } = usePress(onPressProps(digit));
|
const { pressProps } = usePress(onPressProps(digit));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -79,7 +79,7 @@ type ZeroDigitProps = {
|
|||||||
onPressProps: Props["onZeroPressProps"];
|
onPressProps: Props["onZeroPressProps"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZeroDigit: FunctionComponent<ZeroDigitProps> = ({ onPressProps }) => {
|
const ZeroDigit: FunctionComponent<PropsWithChildren<ZeroDigitProps>> = ({ onPressProps }) => {
|
||||||
const { pressProps } = usePress(onPressProps);
|
const { pressProps } = usePress(onPressProps);
|
||||||
|
|
||||||
return (
|
return (
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user