remixed v0

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

12
.dockerignore Normal file
View File

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

View File

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

1
.env.e2e Normal file
View File

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

25
.env.example Normal file
View File

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

View File

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

View File

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

61
.gitignore vendored
View File

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

View File

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

View File

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

1
.npmrc
View File

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

View File

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

View File

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

View File

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

57
Dockerfile Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,46 +0,0 @@
import { resolver, generateToken, hash256 } from "blitz";
import db, { User } from "../../../db";
import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer";
import { ForgotPassword } from "../validations";
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
const user = await db.user.findFirst({ where: { email: email.toLowerCase() } });
// always wait the same amount of time so attackers can't tell the difference whether a user is found
await Promise.all([updatePassword(user), new Promise((resolve) => setTimeout(resolve, 750))]);
// return the same result whether a password reset email was sent or not
return;
});
async function updatePassword(user: User | null) {
if (!user) {
return;
}
const token = generateToken();
const hashedToken = hash256(token);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS);
await db.token.deleteMany({ where: { type: "RESET_PASSWORD", userId: user.id } });
await db.token.create({
data: {
user: { connect: { id: user.id } },
type: "RESET_PASSWORD",
expiresAt,
hashedToken,
sentTo: user.email,
},
});
await (
await forgotPasswordMailer({
to: user.email,
token,
userName: user.fullName,
})
).send();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
import Link from "next/link";
import Avatar from "./avatar";
import Date from "./date";
import CoverImage from "./cover-image";