diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 7fa337a..0000000 --- a/.babelrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "presets": [ - "next/babel" - ], - "plugins": [ - "superjson-next" - ] -} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ad8f0ba --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# 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 diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 15b1ed9..0000000 --- a/.eslintrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next" -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..55d8cd1 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["blitz"], +} diff --git a/.gitignore b/.gitignore index e6e4e5e..f6fda81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,53 @@ -.next/* -node_modules/* -.idea/* -build/* -.env -coverage/ +# 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 + +# misc +.DS_Store + +# local env files +.env.local +.env.*.local +.envrc + +# 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 diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..dd4268e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged +npx pretty-quick --staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..4918980 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,6 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx tsc +npm run lint +npm run test diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ea94aae --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/virtual-phone.blitz.iml b/.idea/virtual-phone.blitz.iml new file mode 100644 index 0000000..ea68f0d --- /dev/null +++ b/.idea/virtual-phone.blitz.iml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..1b78f1c --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +legacy-peer-deps=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ad8c486 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +.gitkeep +.env* +*.ico +*.lock +db/migrations +.next +.blitz diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 58e00e9..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: node_js - -node_js: - - node - - 'lts/*' - -cache: npm - -script: - - npm run build - - npm run test diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..900a577 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "mikestead.dotenv", + "mgmcdermott.vscode-language-babel", + "orta.vscode-jest", + "prisma.prisma" + ], + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8d19091 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} diff --git a/app/api/_types.ts b/app/api/_types.ts new file mode 100644 index 0000000..45beb19 --- /dev/null +++ b/app/api/_types.ts @@ -0,0 +1,4 @@ +export type ApiError = { + statusCode: number + errorMessage: string +} diff --git a/app/api/ddd.ts b/app/api/ddd.ts new file mode 100644 index 0000000..62e0842 --- /dev/null +++ b/app/api/ddd.ts @@ -0,0 +1,16 @@ +import { BlitzApiRequest, BlitzApiResponse } from "blitz" + +import db from "db" + +export default async function ddd(req: BlitzApiRequest, res: BlitzApiResponse) { + await Promise.all([ + db.message.deleteMany(), + db.phoneCall.deleteMany(), + db.phoneNumber.deleteMany(), + db.customer.deleteMany(), + ]) + + await db.user.deleteMany() + + res.status(200).end() +} diff --git a/src/pages/api/newsletter/_mailchimp.ts b/app/api/newsletter/_mailchimp.ts similarity index 54% rename from src/pages/api/newsletter/_mailchimp.ts rename to app/api/newsletter/_mailchimp.ts index c9ca6bb..1cdfd06 100644 --- a/src/pages/api/newsletter/_mailchimp.ts +++ b/app/api/newsletter/_mailchimp.ts @@ -1,21 +1,21 @@ -import getConfig from "next/config"; -import axios from "axios"; +import getConfig from "next/config" +import axios from "axios" -const { serverRuntimeConfig } = getConfig(); +const { serverRuntimeConfig } = getConfig() export async function addSubscriber(email: string) { - const { apiKey, audienceId } = serverRuntimeConfig.mailChimp; - const region = apiKey.split("-")[1]; - const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`; + const { apiKey, audienceId } = serverRuntimeConfig.mailChimp + const region = apiKey.split("-")[1] + const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members` const data = { email_address: email, status: "subscribed", - }; - const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64"); + } + const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64") const headers = { "Content-Type": "application/json", Authorization: `Basic ${base64ApiKey}`, - }; + } - return axios.post(url, data, { headers }); + return axios.post(url, data, { headers }) } diff --git a/app/api/newsletter/subscribe.ts b/app/api/newsletter/subscribe.ts new file mode 100644 index 0000000..d68ea12 --- /dev/null +++ b/app/api/newsletter/subscribe.ts @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from "next" +import zod from "zod" + +import type { ApiError } from "../_types" +import appLogger from "../../../integrations/logger" +import { addSubscriber } from "./_mailchimp" + +type Response = {} | ApiError + +const logger = appLogger.child({ route: "/api/newsletter/subscribe" }) + +const bodySchema = zod.object({ + email: zod.string().email(), +}) + +export default async function subscribeToNewsletter( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + const statusCode = 405 + const apiError: ApiError = { + statusCode, + errorMessage: `Method ${req.method} Not Allowed`, + } + logger.error(apiError) + + res.setHeader("Allow", ["POST"]) + res.status(statusCode).send(apiError) + return + } + + let body + try { + body = bodySchema.parse(req.body) + } catch (error) { + const statusCode = 400 + const apiError: ApiError = { + statusCode, + errorMessage: "Body is malformed", + } + logger.error(error) + + res.status(statusCode).send(apiError) + return + } + + try { + await addSubscriber(body.email) + } catch (error) { + console.log("error", error.response?.data) + + if (error.response?.data.title !== "Member Exists") { + return res.status(error.response?.status ?? 400).end() + } + } + + res.status(200).end() +} diff --git a/app/api/queue/fetch-calls.ts b/app/api/queue/fetch-calls.ts new file mode 100644 index 0000000..8bd0286 --- /dev/null +++ b/app/api/queue/fetch-calls.ts @@ -0,0 +1,38 @@ +import { Queue } from "quirrel/blitz" +import twilio from "twilio" + +import db from "../../../db" +import insertCallsQueue from "./insert-calls" + +type Payload = { + customerId: string +} + +const fetchCallsQueue = Queue("api/queue/fetch-calls", async ({ customerId }) => { + const customer = await db.customer.findFirst({ where: { id: customerId } }) + const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } }) + + const [callsSent, callsReceived] = await Promise.all([ + twilio(customer!.accountSid!, customer!.authToken!).calls.list({ + from: phoneNumber!.phoneNumber, + }), + twilio(customer!.accountSid!, customer!.authToken!).calls.list({ + to: phoneNumber!.phoneNumber, + }), + ]) + const calls = [...callsSent, ...callsReceived].sort( + (a, b) => a.dateCreated.getTime() - b.dateCreated.getTime() + ) + + await insertCallsQueue.enqueue( + { + customerId, + calls, + }, + { + id: `insert-calls-${customerId}`, + } + ) +}) + +export default fetchCallsQueue diff --git a/app/api/queue/fetch-messages.ts b/app/api/queue/fetch-messages.ts new file mode 100644 index 0000000..5af91ba --- /dev/null +++ b/app/api/queue/fetch-messages.ts @@ -0,0 +1,38 @@ +import { Queue } from "quirrel/blitz" +import twilio from "twilio" + +import db from "../../../db" +import insertMessagesQueue from "./insert-messages" + +type Payload = { + customerId: string +} + +const fetchMessagesQueue = Queue("api/queue/fetch-messages", async ({ customerId }) => { + const customer = await db.customer.findFirst({ where: { id: customerId } }) + const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } }) + + const [messagesSent, messagesReceived] = await Promise.all([ + twilio(customer!.accountSid!, customer!.authToken!).messages.list({ + from: phoneNumber!.phoneNumber, + }), + twilio(customer!.accountSid!, customer!.authToken!).messages.list({ + to: phoneNumber!.phoneNumber, + }), + ]) + const messages = [...messagesSent, ...messagesReceived].sort( + (a, b) => a.dateSent.getTime() - b.dateSent.getTime() + ) + + await insertMessagesQueue.enqueue( + { + customerId, + messages, + }, + { + id: `insert-messages-${customerId}`, + } + ) +}) + +export default fetchMessagesQueue diff --git a/app/api/queue/insert-calls.ts b/app/api/queue/insert-calls.ts new file mode 100644 index 0000000..f707b54 --- /dev/null +++ b/app/api/queue/insert-calls.ts @@ -0,0 +1,59 @@ +import { Queue } from "quirrel/blitz" +import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call" + +import db, { Direction, CallStatus } from "../../../db" + +type Payload = { + customerId: string + calls: CallInstance[] +} + +const insertCallsQueue = Queue("api/queue/insert-calls", async ({ calls, customerId }) => { + const phoneCalls = calls + .map((call) => ({ + customerId, + twilioSid: call.sid, + from: call.from, + to: call.to, + direction: translateDirection(call.direction), + status: translateStatus(call.status), + duration: call.duration, + createdAt: new Date(call.dateCreated), + })) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + + await db.phoneCall.createMany({ data: phoneCalls }) +}) + +export default insertCallsQueue + +function translateDirection(direction: CallInstance["direction"]): Direction { + switch (direction) { + case "inbound": + return Direction.Inbound + case "outbound": + default: + return Direction.Outbound + } +} + +function translateStatus(status: CallInstance["status"]): CallStatus { + switch (status) { + case "busy": + return CallStatus.Busy + case "canceled": + return CallStatus.Canceled + case "completed": + return CallStatus.Completed + case "failed": + return CallStatus.Failed + case "in-progress": + return CallStatus.InProgress + case "no-answer": + return CallStatus.NoAnswer + case "queued": + return CallStatus.Queued + case "ringing": + return CallStatus.Ringing + } +} diff --git a/app/api/queue/insert-messages.ts b/app/api/queue/insert-messages.ts new file mode 100644 index 0000000..bda5def --- /dev/null +++ b/app/api/queue/insert-messages.ts @@ -0,0 +1,78 @@ +import { Queue } from "quirrel/blitz" +import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message" + +import db, { MessageStatus, Direction, Message } from "../../../db" +import { encrypt } from "../../../db/_encryption" + +type Payload = { + customerId: string + messages: MessageInstance[] +} + +const insertMessagesQueue = Queue( + "api/queue/insert-messages", + async ({ messages, customerId }) => { + const customer = await db.customer.findFirst({ where: { id: customerId } }) + const encryptionKey = customer!.encryptionKey + + const sms = messages + .map>((message) => ({ + customerId, + content: encrypt(message.body, encryptionKey), + from: message.from, + to: message.to, + status: translateStatus(message.status), + direction: translateDirection(message.direction), + twilioSid: message.sid, + sentAt: new Date(message.dateSent), + })) + .sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime()) + + await db.message.createMany({ data: sms }) + } +) + +export default insertMessagesQueue + +function translateDirection(direction: MessageInstance["direction"]): Direction { + switch (direction) { + case "inbound": + return Direction.Inbound + case "outbound-api": + case "outbound-call": + case "outbound-reply": + default: + return Direction.Outbound + } +} + +function translateStatus(status: MessageInstance["status"]): MessageStatus { + switch (status) { + case "accepted": + return MessageStatus.Accepted + case "canceled": + return MessageStatus.Canceled + case "delivered": + return MessageStatus.Delivered + case "failed": + return MessageStatus.Failed + case "partially_delivered": + return MessageStatus.PartiallyDelivered + case "queued": + return MessageStatus.Queued + case "read": + return MessageStatus.Read + case "received": + return MessageStatus.Received + case "receiving": + return MessageStatus.Receiving + case "scheduled": + return MessageStatus.Scheduled + case "sending": + return MessageStatus.Sending + case "sent": + return MessageStatus.Sent + case "undelivered": + return MessageStatus.Undelivered + } +} diff --git a/app/api/queue/send-message.ts b/app/api/queue/send-message.ts new file mode 100644 index 0000000..78ef16f --- /dev/null +++ b/app/api/queue/send-message.ts @@ -0,0 +1,34 @@ +import { Queue } from "quirrel/blitz" +import twilio from "twilio" + +import db from "../../../db" + +type Payload = { + id: string + customerId: string + to: string + content: string +} + +const sendMessageQueue = Queue( + "api/queue/send-message", + async ({ id, customerId, to, content }) => { + const customer = await db.customer.findFirst({ where: { id: customerId } }) + const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } }) + + const message = await twilio(customer!.accountSid!, customer!.authToken!).messages.create({ + body: content, + to, + from: phoneNumber!.phoneNumber, + }) + await db.message.update({ + where: { id }, + data: { twilioSid: message.sid }, + }) + }, + { + retry: ["1min"], + } +) + +export default sendMessageQueue diff --git a/app/api/queue/set-twilio-webhooks.ts b/app/api/queue/set-twilio-webhooks.ts new file mode 100644 index 0000000..d1968c2 --- /dev/null +++ b/app/api/queue/set-twilio-webhooks.ts @@ -0,0 +1,43 @@ +import { Queue } from "quirrel/blitz" +import twilio from "twilio" + +import db from "../../../db" + +type Payload = { + customerId: string +} + +const setTwilioWebhooks = Queue( + "api/queue/set-twilio-webhooks", + async ({ customerId }) => { + const customer = await db.customer.findFirst({ where: { id: customerId } }) + const twimlApp = customer!.twimlAppSid + ? await twilio(customer!.accountSid!, customer!.authToken!) + .applications.get(customer!.twimlAppSid) + .fetch() + : await twilio(customer!.accountSid!, customer!.authToken!).applications.create({ + friendlyName: "Virtual Phone", + smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-message", + smsMethod: "POST", + voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call", + voiceMethod: "POST", + }) + const twimlAppSid = twimlApp.sid + const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } }) + + await Promise.all([ + db.customer.update({ + where: { id: customerId }, + data: { twimlAppSid }, + }), + twilio(customer!.accountSid!, customer!.authToken!) + .incomingPhoneNumbers.get(phoneNumber!.phoneNumberSid) + .update({ + smsApplicationSid: twimlAppSid, + voiceApplicationSid: twimlAppSid, + }), + ]) + } +) + +export default setTwilioWebhooks diff --git a/app/auth/components/login-form.tsx b/app/auth/components/login-form.tsx new file mode 100644 index 0000000..52339fe --- /dev/null +++ b/app/auth/components/login-form.tsx @@ -0,0 +1,61 @@ +import { AuthenticationError, Link, useMutation, Routes } from "blitz" + +import { LabeledTextField } from "../../core/components/labeled-text-field" +import { Form, FORM_ERROR } from "../../core/components/form" +import login from "../../../app/auth/mutations/login" +import { Login } from "../validations" + +type LoginFormProps = { + onSuccess?: () => void +} + +export const LoginForm = (props: LoginFormProps) => { + const [loginMutation] = useMutation(login) + + return ( +
+

Login

+ +
{ + try { + await loginMutation(values) + props.onSuccess?.() + } catch (error) { + 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(), + } + } + } + }} + > + + + + + +
+ Or Sign Up +
+
+ ) +} + +export default LoginForm diff --git a/app/auth/components/signup-form.tsx b/app/auth/components/signup-form.tsx new file mode 100644 index 0000000..16b1ece --- /dev/null +++ b/app/auth/components/signup-form.tsx @@ -0,0 +1,49 @@ +import { useMutation } from "blitz" + +import { LabeledTextField } from "../../core/components/labeled-text-field" +import { Form, FORM_ERROR } from "../../core/components/form" +import signup from "../../auth/mutations/signup" +import { Signup } from "../validations" + +type SignupFormProps = { + onSuccess?: () => void +} + +export const SignupForm = (props: SignupFormProps) => { + const [signupMutation] = useMutation(signup) + + return ( +
+

Create an Account

+ +
{ + try { + await signupMutation(values) + props.onSuccess?.() + } catch (error) { + 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() } + } + } + }} + > + + + +
+ ) +} + +export default SignupForm diff --git a/app/auth/mutations/change-password.ts b/app/auth/mutations/change-password.ts new file mode 100644 index 0000000..4b24476 --- /dev/null +++ b/app/auth/mutations/change-password.ts @@ -0,0 +1,24 @@ +import { NotFoundError, SecurePassword, resolver } from "blitz" + +import db from "../../../db" +import { authenticateUser } from "./login" +import { ChangePassword } from "../validations" + +export default resolver.pipe( + resolver.zod(ChangePassword), + resolver.authorize(), + async ({ currentPassword, newPassword }, ctx) => { + const user = await db.user.findFirst({ where: { id: ctx.session.userId! } }) + if (!user) throw new NotFoundError() + + await authenticateUser(user.email, currentPassword) + + const hashedPassword = await SecurePassword.hash(newPassword.trim()) + await db.user.update({ + where: { id: user.id }, + data: { hashedPassword }, + }) + + return true + } +) diff --git a/app/auth/mutations/forgot-password.test.ts b/app/auth/mutations/forgot-password.test.ts new file mode 100644 index 0000000..b07d9af --- /dev/null +++ b/app/auth/mutations/forgot-password.test.ts @@ -0,0 +1,61 @@ +import { hash256, Ctx } from "blitz" +import previewEmail from "preview-email" + +import forgotPassword from "./forgot-password" +import db from "../../../db" + +beforeEach(async () => { + await db.$reset() +}) + +const generatedToken = "plain-token" +jest.mock("blitz", () => ({ + ...jest.requireActual("blitz")!, + generateToken: () => generatedToken, +})) +jest.mock("preview-email", () => jest.fn()) + +describe("forgotPassword mutation", () => { + it("does not throw error if user doesn't exist", async () => { + await expect( + forgotPassword({ email: "no-user@email.com" }, {} as Ctx) + ).resolves.not.toThrow() + }) + + it("works correctly", async () => { + // Create test user + const user = await db.user.create({ + data: { + email: "user@example.com", + tokens: { + // Create old token to ensure it's deleted + create: { + type: "RESET_PASSWORD", + hashedToken: "token", + expiresAt: new Date(), + sentTo: "user@example.com", + }, + }, + }, + include: { tokens: true }, + }) + + // Invoke the mutation + await forgotPassword({ email: user.email }, {} as Ctx) + + const tokens = await db.token.findMany({ where: { userId: user.id } }) + const token = tokens[0] + if (!user.tokens[0]) throw new Error("Missing user token") + if (!token) throw new Error("Missing token") + + // delete's existing tokens + expect(tokens.length).toBe(1) + + expect(token.id).not.toBe(user.tokens[0].id) + expect(token.type).toBe("RESET_PASSWORD") + expect(token.sentTo).toBe(user.email) + expect(token.hashedToken).toBe(hash256(generatedToken)) + expect(token.expiresAt > new Date()).toBe(true) + expect(previewEmail).toBeCalled() + }) +}) diff --git a/app/auth/mutations/forgot-password.ts b/app/auth/mutations/forgot-password.ts new file mode 100644 index 0000000..42ac117 --- /dev/null +++ b/app/auth/mutations/forgot-password.ts @@ -0,0 +1,42 @@ +import { resolver, generateToken, hash256 } from "blitz" + +import db from "../../../db" +import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer" +import { ForgotPassword } from "../validations" + +const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 4 + +export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => { + // 1. Get the user + const user = await db.user.findFirst({ where: { email: email.toLowerCase() } }) + + // 2. Generate the token and expiration date. + const token = generateToken() + const hashedToken = hash256(token) + const expiresAt = new Date() + expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS) + + // 3. If user with this email was found + if (user) { + // 4. Delete any existing password reset tokens + await db.token.deleteMany({ where: { type: "RESET_PASSWORD", userId: user.id } }) + // 5. Save this new token in the database. + await db.token.create({ + data: { + user: { connect: { id: user.id } }, + type: "RESET_PASSWORD", + expiresAt, + hashedToken, + sentTo: user.email, + }, + }) + // 6. Send the email + await forgotPasswordMailer({ to: user.email, token }).send() + } else { + // 7. If no user found wait the same time so attackers can't tell the difference + await new Promise((resolve) => setTimeout(resolve, 750)) + } + + // 8. Return the same result whether a password reset email was sent or not + return +}) diff --git a/app/auth/mutations/login.ts b/app/auth/mutations/login.ts new file mode 100644 index 0000000..c6d4321 --- /dev/null +++ b/app/auth/mutations/login.ts @@ -0,0 +1,31 @@ +import { resolver, SecurePassword, AuthenticationError } from "blitz" + +import db, { Role } 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 } }) + 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) + + await ctx.session.$create({ userId: user.id, role: user.role as Role }) + + return user +}) diff --git a/app/auth/mutations/logout.ts b/app/auth/mutations/logout.ts new file mode 100644 index 0000000..114e0fe --- /dev/null +++ b/app/auth/mutations/logout.ts @@ -0,0 +1,5 @@ +import { Ctx } from "blitz" + +export default async function logout(_: any, ctx: Ctx) { + return await ctx.session.$revoke() +} diff --git a/app/auth/mutations/reset-password.test.ts b/app/auth/mutations/reset-password.test.ts new file mode 100644 index 0000000..2de6c96 --- /dev/null +++ b/app/auth/mutations/reset-password.test.ts @@ -0,0 +1,83 @@ +import { hash256, SecurePassword } from "blitz" + +import db from "../../../db" +import resetPassword from "./reset-password" + +beforeEach(async () => { + await db.$reset() +}) + +const mockCtx: any = { + session: { + $create: jest.fn, + }, +} + +describe("resetPassword mutation", () => { + it("works correctly", async () => { + expect(true).toBe(true) + + // Create test user + const goodToken = "randomPasswordResetToken" + const expiredToken = "expiredRandomPasswordResetToken" + const future = new Date() + future.setHours(future.getHours() + 4) + const past = new Date() + past.setHours(past.getHours() - 4) + + const user = await db.user.create({ + data: { + email: "user@example.com", + tokens: { + // Create old token to ensure it's deleted + create: [ + { + type: "RESET_PASSWORD", + hashedToken: hash256(expiredToken), + expiresAt: past, + sentTo: "user@example.com", + }, + { + type: "RESET_PASSWORD", + hashedToken: hash256(goodToken), + expiresAt: future, + sentTo: "user@example.com", + }, + ], + }, + }, + include: { tokens: true }, + }) + + const newPassword = "newPassword" + + // Non-existent token + await expect( + resetPassword({ token: "no-token", password: "", passwordConfirmation: "" }, mockCtx) + ).rejects.toThrowError() + + // Expired token + await expect( + resetPassword( + { token: expiredToken, password: newPassword, passwordConfirmation: newPassword }, + mockCtx + ) + ).rejects.toThrowError() + + // Good token + await resetPassword( + { token: goodToken, password: newPassword, passwordConfirmation: newPassword }, + mockCtx + ) + + // Delete's the token + const numberOfTokens = await db.token.count({ where: { userId: user.id } }) + expect(numberOfTokens).toBe(0) + + // Updates user's password + const updatedUser = await db.user.findFirst({ where: { id: user.id } }) + expect(await SecurePassword.verify(updatedUser!.hashedPassword, newPassword)).toBe( + SecurePassword.VALID + ) + }) +}) diff --git a/app/auth/mutations/reset-password.ts b/app/auth/mutations/reset-password.ts new file mode 100644 index 0000000..48ff1ae --- /dev/null +++ b/app/auth/mutations/reset-password.ts @@ -0,0 +1,48 @@ +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 +}) diff --git a/app/auth/mutations/signup.ts b/app/auth/mutations/signup.ts new file mode 100644 index 0000000..34c853b --- /dev/null +++ b/app/auth/mutations/signup.ts @@ -0,0 +1,18 @@ +import { resolver, SecurePassword } from "blitz" + +import db, { Role } from "../../../db" +import { Signup } from "../validations" +import { computeEncryptionKey } from "../../../db/_encryption" + +export default resolver.pipe(resolver.zod(Signup), async ({ email, password }, ctx) => { + const hashedPassword = await SecurePassword.hash(password.trim()) + const user = await db.user.create({ + data: { email: email.toLowerCase().trim(), hashedPassword, role: Role.USER }, + select: { id: true, name: true, email: true, role: true }, + }) + const encryptionKey = computeEncryptionKey(user.id).toString("hex") + await db.customer.create({ data: { id: user.id, encryptionKey } }) + + await ctx.session.$create({ userId: user.id, role: user.role }) + return user +}) diff --git a/app/auth/pages/forgot-password.tsx b/app/auth/pages/forgot-password.tsx new file mode 100644 index 0000000..2f61e41 --- /dev/null +++ b/app/auth/pages/forgot-password.tsx @@ -0,0 +1,52 @@ +import { BlitzPage, useMutation } from "blitz" + +import BaseLayout from "../../core/layouts/base-layout" +import { LabeledTextField } from "../../core/components/labeled-text-field" +import { Form, FORM_ERROR } from "../../core/components/form" +import { ForgotPassword } from "../validations" +import forgotPassword from "../../auth/mutations/forgot-password" + +const ForgotPasswordPage: BlitzPage = () => { + const [forgotPasswordMutation, { isSuccess }] = useMutation(forgotPassword) + + return ( +
+

Forgot your password?

+ + {isSuccess ? ( +
+

Request Submitted

+

+ If your email is in our system, you will receive instructions to reset your + password shortly. +

+
+ ) : ( +
{ + try { + await forgotPasswordMutation(values) + } catch (error) { + return { + [FORM_ERROR]: + "Sorry, we had an unexpected error. Please try again.", + } + } + }} + > + + + )} +
+ ) +} + +ForgotPasswordPage.redirectAuthenticatedTo = "/" +ForgotPasswordPage.getLayout = (page) => ( + {page} +) + +export default ForgotPasswordPage diff --git a/app/auth/pages/login.tsx b/app/auth/pages/login.tsx new file mode 100644 index 0000000..1f35791 --- /dev/null +++ b/app/auth/pages/login.tsx @@ -0,0 +1,26 @@ +import { useRouter, BlitzPage } from "blitz" + +import BaseLayout from "../../core/layouts/base-layout" +import { LoginForm } from "../components/login-form" + +const LoginPage: BlitzPage = () => { + const router = useRouter() + + return ( +
+ { + const next = router.query.next + ? decodeURIComponent(router.query.next as string) + : "/" + router.push(next) + }} + /> +
+ ) +} + +LoginPage.redirectAuthenticatedTo = "/" +LoginPage.getLayout = (page) => {page} + +export default LoginPage diff --git a/app/auth/pages/reset-password.tsx b/app/auth/pages/reset-password.tsx new file mode 100644 index 0000000..4206d6d --- /dev/null +++ b/app/auth/pages/reset-password.tsx @@ -0,0 +1,65 @@ +import { BlitzPage, useRouterQuery, Link, useMutation, Routes } from "blitz" + +import BaseLayout from "../../core/layouts/base-layout" +import { LabeledTextField } from "../../core/components/labeled-text-field" +import { Form, FORM_ERROR } from "../../core/components/form" +import { ResetPassword } from "../validations" +import resetPassword from "../../auth/mutations/reset-password" + +const ResetPasswordPage: BlitzPage = () => { + const query = useRouterQuery() + const [resetPasswordMutation, { isSuccess }] = useMutation(resetPassword) + + return ( +
+

Set a New Password

+ + {isSuccess ? ( +
+

Password Reset Successfully

+

+ Go to the homepage +

+
+ ) : ( +
{ + try { + await resetPasswordMutation(values) + } catch (error) { + if (error.name === "ResetPasswordError") { + return { + [FORM_ERROR]: error.message, + } + } else { + return { + [FORM_ERROR]: + "Sorry, we had an unexpected error. Please try again.", + } + } + } + }} + > + + + + )} +
+ ) +} + +ResetPasswordPage.redirectAuthenticatedTo = "/" +ResetPasswordPage.getLayout = (page) => {page} + +export default ResetPasswordPage diff --git a/app/auth/pages/signup.tsx b/app/auth/pages/signup.tsx new file mode 100644 index 0000000..0ef1d0f --- /dev/null +++ b/app/auth/pages/signup.tsx @@ -0,0 +1,19 @@ +import { useRouter, BlitzPage, Routes } from "blitz" + +import BaseLayout from "../../core/layouts/base-layout" +import { SignupForm } from "../components/signup-form" + +const SignupPage: BlitzPage = () => { + const router = useRouter() + + return ( +
+ router.push(Routes.Home())} /> +
+ ) +} + +SignupPage.redirectAuthenticatedTo = "/" +SignupPage.getLayout = (page) => {page} + +export default SignupPage diff --git a/app/auth/validations.ts b/app/auth/validations.ts new file mode 100644 index 0000000..e5cc870 --- /dev/null +++ b/app/auth/validations.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +const password = z.string().min(10).max(100) + +export const Signup = z.object({ + email: z.string().email(), + password, +}) + +export const Login = z.object({ + email: z.string().email(), + password: z.string(), +}) + +export const ForgotPassword = z.object({ + email: z.string().email(), +}) + +export const ResetPassword = z + .object({ + password: password, + passwordConfirmation: password, + token: z.string(), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: "Passwords don't match", + path: ["passwordConfirmation"], // set the path of the error + }) + +export const ChangePassword = z.object({ + currentPassword: z.string(), + newPassword: password, +}) diff --git a/app/core/components/form.tsx b/app/core/components/form.tsx new file mode 100644 index 0000000..7a63865 --- /dev/null +++ b/app/core/components/form.tsx @@ -0,0 +1,84 @@ +import { useState, ReactNode, PropsWithoutRef } from "react" +import { FormProvider, useForm, UseFormProps } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +export interface FormProps> + extends Omit, "onSubmit"> { + /** All your form fields */ + children?: ReactNode + /** Text to display in the submit button */ + submitText?: string + schema?: S + onSubmit: (values: z.infer) => Promise + initialValues?: UseFormProps>["defaultValues"] +} + +interface OnSubmitResult { + FORM_ERROR?: string + + [prop: string]: any +} + +export const FORM_ERROR = "FORM_ERROR" + +export function Form>({ + children, + submitText, + schema, + initialValues, + onSubmit, + ...props +}: FormProps) { + const ctx = useForm>({ + mode: "onBlur", + resolver: schema ? zodResolver(schema) : undefined, + defaultValues: initialValues, + }) + const [formError, setFormError] = useState(null) + + return ( + +
{ + 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} + > + {/* Form fields supplied as children are rendered here */} + {children} + + {formError && ( +
+ {formError} +
+ )} + + {submitText && ( + + )} + + +
+
+ ) +} + +export default Form diff --git a/app/core/components/labeled-text-field.tsx b/app/core/components/labeled-text-field.tsx new file mode 100644 index 0000000..44294f7 --- /dev/null +++ b/app/core/components/labeled-text-field.tsx @@ -0,0 +1,58 @@ +import { forwardRef, PropsWithoutRef } from "react" +import { useFormContext } from "react-hook-form" + +export interface LabeledTextFieldProps extends PropsWithoutRef { + /** Field name. */ + name: string + /** Field label. */ + label: string + /** Field type. Doesn't include radio buttons and checkboxes */ + type?: "text" | "password" | "email" | "number" + outerProps?: PropsWithoutRef +} + +export const LabeledTextField = forwardRef( + ({ 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 ( +
+ + + {error && ( +
+ {error} +
+ )} + + +
+ ) + } +) + +export default LabeledTextField diff --git a/app/core/hooks/use-current-customer.ts b/app/core/hooks/use-current-customer.ts new file mode 100644 index 0000000..4ffd522 --- /dev/null +++ b/app/core/hooks/use-current-customer.ts @@ -0,0 +1,11 @@ +import { useQuery } from "blitz" + +import getCurrentCustomer from "../../customers/queries/get-current-customer" + +export default function useCurrentCustomer() { + const [customer] = useQuery(getCurrentCustomer, null) + return { + customer, + hasCompletedOnboarding: Boolean(!!customer && customer.accountSid && customer.authToken), + } +} diff --git a/app/core/hooks/use-customer-phone-number.ts b/app/core/hooks/use-customer-phone-number.ts new file mode 100644 index 0000000..62ef545 --- /dev/null +++ b/app/core/hooks/use-customer-phone-number.ts @@ -0,0 +1,15 @@ +import { useQuery } from "blitz" + +import getCurrentCustomerPhoneNumber from "../../phone-numbers/queries/get-current-customer-phone-number" +import useCurrentCustomer from "./use-current-customer" + +export default function useCustomerPhoneNumber() { + const { hasCompletedOnboarding } = useCurrentCustomer() + const [customerPhoneNumber] = useQuery( + getCurrentCustomerPhoneNumber, + {}, + { enabled: hasCompletedOnboarding } + ) + + return customerPhoneNumber +} diff --git a/app/core/hooks/use-require-onboarding.ts b/app/core/hooks/use-require-onboarding.ts new file mode 100644 index 0000000..f12eadc --- /dev/null +++ b/app/core/hooks/use-require-onboarding.ts @@ -0,0 +1,24 @@ +import { Routes, useRouter } from "blitz" + +import useCurrentCustomer from "./use-current-customer" +import useCustomerPhoneNumber from "./use-customer-phone-number" + +export default function useRequireOnboarding() { + const router = useRouter() + const { customer, hasCompletedOnboarding } = useCurrentCustomer() + const customerPhoneNumber = useCustomerPhoneNumber() + + if (!hasCompletedOnboarding) { + throw router.push(Routes.StepTwo()) + } + + /*if (!customer.paddleCustomerId || !customer.paddleSubscriptionId) { + throw router.push(Routes.StepTwo()); + return; + }*/ + + console.log("customerPhoneNumber", customerPhoneNumber) + if (!customerPhoneNumber) { + throw router.push(Routes.StepThree()) + } +} diff --git a/app/core/layouts/base-layout.tsx b/app/core/layouts/base-layout.tsx new file mode 100644 index 0000000..a783511 --- /dev/null +++ b/app/core/layouts/base-layout.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react" +import { Head } from "blitz" + +type LayoutProps = { + title?: string + children: ReactNode +} + +const BaseLayout = ({ title, children }: LayoutProps) => { + return ( + <> + + {title || "virtual-phone"} + + + + {children} + + ) +} + +export default BaseLayout diff --git a/src/components/layout/footer.tsx b/app/core/layouts/layout/footer.tsx similarity index 75% rename from src/components/layout/footer.tsx rename to app/core/layouts/layout/footer.tsx index 822c18a..eb01a3a 100644 --- a/src/components/layout/footer.tsx +++ b/app/core/layouts/layout/footer.tsx @@ -1,26 +1,23 @@ -import type { ReactNode } from "react"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import type { ReactNode } from "react" +import Link from "next/link" +import { useRouter } from "next/router" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faPhoneAlt as fasPhone, faTh as fasTh, faComments as fasComments, faCog as fasCog, -} from "@fortawesome/pro-solid-svg-icons"; +} from "@fortawesome/pro-solid-svg-icons" import { faPhoneAlt as farPhone, faTh as farTh, faComments as farComments, faCog as farCog, -} from "@fortawesome/pro-regular-svg-icons"; +} from "@fortawesome/pro-regular-svg-icons" export default function Footer() { return ( -