From fc4278ca7b0e113374e7a0592f77161c209fedfd Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 31 Jul 2021 22:33:18 +0800 Subject: [PATCH] migrate to blitzjs --- .babelrc | 8 - .editorconfig | 11 + .eslintrc | 3 - .eslintrc.js | 3 + .gitignore | 58 +- .husky/.gitignore | 1 + .husky/pre-commit | 5 + .husky/pre-push | 6 + .idea/.gitignore | 5 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/virtual-phone.blitz.iml | 13 + .npmrc | 2 + .prettierignore | 7 + .travis.yml | 11 - .vscode/extensions.json | 12 + .vscode/settings.json | 7 + app/api/_types.ts | 4 + app/api/ddd.ts | 16 + .../api/newsletter/_mailchimp.ts | 20 +- app/api/newsletter/subscribe.ts | 59 + app/api/queue/fetch-calls.ts | 38 + app/api/queue/fetch-messages.ts | 38 + app/api/queue/insert-calls.ts | 59 + app/api/queue/insert-messages.ts | 78 + app/api/queue/send-message.ts | 34 + app/api/queue/set-twilio-webhooks.ts | 43 + app/auth/components/login-form.tsx | 61 + app/auth/components/signup-form.tsx | 49 + app/auth/mutations/change-password.ts | 24 + app/auth/mutations/forgot-password.test.ts | 61 + app/auth/mutations/forgot-password.ts | 42 + app/auth/mutations/login.ts | 31 + app/auth/mutations/logout.ts | 5 + app/auth/mutations/reset-password.test.ts | 83 + app/auth/mutations/reset-password.ts | 48 + app/auth/mutations/signup.ts | 18 + app/auth/pages/forgot-password.tsx | 52 + app/auth/pages/login.tsx | 26 + app/auth/pages/reset-password.tsx | 65 + app/auth/pages/signup.tsx | 19 + app/auth/validations.ts | 33 + app/core/components/form.tsx | 84 + app/core/components/labeled-text-field.tsx | 58 + app/core/hooks/use-current-customer.ts | 11 + app/core/hooks/use-customer-phone-number.ts | 15 + app/core/hooks/use-require-onboarding.ts | 24 + app/core/layouts/base-layout.tsx | 22 + .../core/layouts}/layout/footer.tsx | 37 +- .../core/layouts}/layout/index.tsx | 54 +- src/tailwind.css => app/core/styles/index.css | 0 app/customers/queries/get-current-customer.ts | 21 + app/messages/api/webhook/incoming-message.ts | 131 + app/messages/components/conversation.tsx | 82 + .../components/conversations-list.tsx | 34 + app/messages/components/new-message-area.tsx | 94 + app/messages/hooks/use-conversation.ts | 19 + app/messages/mutations/send-message.ts | 47 + app/messages/pages/messages.tsx | 25 + app/messages/pages/messages/[recipient].tsx | 39 + app/messages/queries/get-conversation.ts | 31 + app/messages/queries/get-conversations.ts | 41 + .../components}/onboarding-layout.tsx | 100 +- app/onboarding/mutations/set-phone-number.ts | 40 + .../mutations/set-twilio-api-fields.ts | 26 + app/onboarding/pages/welcome/step-one.tsx | 23 + app/onboarding/pages/welcome/step-three.tsx | 124 + .../onboarding}/pages/welcome/step-two.tsx | 81 +- app/pages/404.tsx | 19 + app/pages/_app.tsx | 49 + app/pages/_document.tsx | 23 + app/pages/index.test.tsx | 39 + app/pages/index.tsx | 273 + app/phone-calls/api/webhook/incoming-call.ts | 3 + app/phone-calls/api/webhook/outgoing-call.ts | 3 + .../components/phone-calls-list.tsx | 24 + app/phone-calls/hooks/use-phone-calls.ts | 15 + app/phone-calls/pages/calls.tsx | 25 + app/phone-calls/queries/get-phone-calls.ts | 32 + .../get-current-customer-phone-number.ts | 16 + .../queries/get-customer-phone-number.ts | 19 + babel.config.js | 4 + blitz.config.ts | 36 + db/_encryption.ts | 37 + db/index.ts | 7 + .../20210726100838_init/migration.sql | 57 + .../migration.sql | 137 + .../migration.sql | 12 + .../migration.sql | 5 + db/migrations/migration_lock.toml | 3 + db/schema.prisma | 154 + db/seeds.ts | 16 + global.d.ts | 3 + {lib => integrations}/logger.ts | 6 +- jest.config.js | 16 +- jest/helpers.ts | 104 - jest/setup.ts | 24 - jest/testing-library.ts | 1 - lib/__tests__/session-helpers.ts | 30 - lib/session-helpers.ts | 183 - lib/utils/cookies.ts | 79 - lib/utils/hkdf.ts | 7 - mailers/forgot-password-mailer.ts | 47 + next-env.d.ts | 3 - next.config.js | 104 - package-lock.json | 27781 +++++++++------- package.json | 158 +- postcss.config.js | 7 +- public/favicon.ico | Bin 0 -> 556 bytes public/logo.png | Bin 0 -> 33469 bytes public/robots.txt | 2 - public/static/favicon.ico | Bin 15086 -> 0 bytes .../static/fonts/inter/Inter-italic.var.woff2 | Bin 241052 -> 0 bytes .../static/fonts/inter/Inter-roman.var.woff2 | Bin 226100 -> 0 bytes .../static/illustrations/data-analytics.svg | 5714 ---- public/static/illustrations/learn-coding.svg | 386 - public/static/illustrations/support-team.svg | 463 - public/static/logo.svg | 31 - .../pages/__snapshots__/index.tsx.snap | 330 - .../pages/account/settings/index.tsx | 171 - src/__tests__/pages/account/settings/team.tsx | 323 - src/__tests__/pages/api/auth/sign-in.ts | 221 - src/__tests__/pages/api/auth/sign-up.ts | 76 - .../api/subscription/_subscription-created.ts | 164 - .../pages/api/subscription/webhook.ts | 111 - src/__tests__/pages/api/team/invite-member.ts | 90 - src/__tests__/pages/api/user/session.ts | 21 - src/__tests__/pages/api/user/update-user.ts | 112 - src/__tests__/pages/auth/sign-in.tsx | 51 - src/__tests__/pages/index.tsx | 46 - src/__tests__/pages/team/invitation.tsx | 131 - src/components/alert.tsx | 115 - src/components/auth/auth-page.tsx | 224 - src/components/avatar.tsx | 15 - src/components/billing/billing-plans.tsx | 261 - src/components/button.tsx | 58 - src/components/connected-layout.tsx | 158 - src/components/divider.tsx | 9 - src/components/icons.tsx | 5 - src/components/layout/header.tsx | 90 - src/components/loading.tsx | 23 - src/components/logo.tsx | 15 - src/components/modal.tsx | 71 - src/components/outside-alerter.tsx | 33 - src/components/settings/danger-zone.tsx | 110 - .../settings/profile-informations.tsx | 138 - src/components/settings/settings-layout.tsx | 52 - src/components/settings/settings-section.tsx | 26 - src/components/settings/update-password.tsx | 141 - src/components/toggle.tsx | 41 - src/database/_encryption.ts | 33 - src/database/customer.ts | 68 - src/database/message.ts | 86 - src/database/phone-call.ts | 53 - src/database/phone-number.ts | 67 - src/database/subscriptions.ts | 151 - src/fonts.css | 17 - src/hooks/use-auth.ts | 67 - src/hooks/use-conversation.ts | 71 - src/hooks/use-paddle.ts | 49 - src/hooks/use-request.ts | 22 - src/hooks/use-subscription.ts | 92 - src/hooks/use-user.ts | 52 - src/pages/_app.tsx | 36 - src/pages/_document.tsx | 60 - src/pages/api/_redirect.ts | 6 - src/pages/api/_send-email.ts | 45 - src/pages/api/_types.ts | 4 - src/pages/api/auth/session.ts | 10 - src/pages/api/auth/sign-in.ts | 71 - src/pages/api/auth/sign-up.ts | 94 - src/pages/api/conversation/[recipient].ts | 54 - .../conversation/[recipient]/send-message.ts | 80 - src/pages/api/ddd.ts | 55 - src/pages/api/newsletter/subscribe.ts | 65 - src/pages/api/queue/fetch-calls.ts | 40 - src/pages/api/queue/fetch-messages.ts | 40 - src/pages/api/queue/insert-calls.ts | 32 - src/pages/api/queue/insert-messages.ts | 37 - src/pages/api/queue/send-message.ts | 34 - src/pages/api/queue/set-twilio-webhooks.ts | 40 - .../subscription/_subscription-cancelled.ts | 92 - .../api/subscription/_subscription-created.ts | 131 - .../_subscription-payment-succeeded.ts | 90 - .../api/subscription/_subscription-updated.ts | 123 - src/pages/api/subscription/update.ts | 88 - src/pages/api/subscription/webhook.ts | 78 - src/pages/api/user/add-phone-number.ts | 58 - src/pages/api/user/delete-user.ts | 50 - src/pages/api/user/list-twilio-numbers.ts | 21 - src/pages/api/user/update-user.ts | 115 - src/pages/api/webhook/incoming-call.ts | 5 - src/pages/api/webhook/incoming-message.ts | 76 - src/pages/api/webhook/outgoing-call.ts | 5 - src/pages/auth/forgot-password.tsx | 127 - src/pages/auth/sign-in.tsx | 12 - src/pages/auth/sign-out.tsx | 34 - src/pages/auth/sign-up.tsx | 12 - src/pages/calls.tsx | 37 - src/pages/index.tsx | 268 - src/pages/keypad.tsx | 119 - src/pages/messages/[recipient].tsx | 173 - src/pages/messages/index.tsx | 51 - src/pages/settings/account.tsx | 56 - src/pages/settings/billing.tsx | 109 - src/pages/settings/index.tsx | 55 - src/pages/welcome/step-one.tsx | 22 - src/pages/welcome/step-three.tsx | 107 - src/state.ts | 50 - src/subscription/_paddle-api.ts | 51 - src/subscription/plans.ts | 61 - src/supabase/client.ts | 10 - src/supabase/server.ts | 11 - tailwind.config.js | 37 +- test/setup.ts | 4 + test/utils.tsx | 105 + tsconfig.json | 23 +- types.ts | 17 + 218 files changed, 19100 insertions(+), 27038 deletions(-) delete mode 100644 .babelrc create mode 100644 .editorconfig delete mode 100644 .eslintrc create mode 100644 .eslintrc.js create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/virtual-phone.blitz.iml create mode 100644 .npmrc create mode 100644 .prettierignore delete mode 100644 .travis.yml create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 app/api/_types.ts create mode 100644 app/api/ddd.ts rename {src/pages => app}/api/newsletter/_mailchimp.ts (54%) create mode 100644 app/api/newsletter/subscribe.ts create mode 100644 app/api/queue/fetch-calls.ts create mode 100644 app/api/queue/fetch-messages.ts create mode 100644 app/api/queue/insert-calls.ts create mode 100644 app/api/queue/insert-messages.ts create mode 100644 app/api/queue/send-message.ts create mode 100644 app/api/queue/set-twilio-webhooks.ts create mode 100644 app/auth/components/login-form.tsx create mode 100644 app/auth/components/signup-form.tsx create mode 100644 app/auth/mutations/change-password.ts create mode 100644 app/auth/mutations/forgot-password.test.ts create mode 100644 app/auth/mutations/forgot-password.ts create mode 100644 app/auth/mutations/login.ts create mode 100644 app/auth/mutations/logout.ts create mode 100644 app/auth/mutations/reset-password.test.ts create mode 100644 app/auth/mutations/reset-password.ts create mode 100644 app/auth/mutations/signup.ts create mode 100644 app/auth/pages/forgot-password.tsx create mode 100644 app/auth/pages/login.tsx create mode 100644 app/auth/pages/reset-password.tsx create mode 100644 app/auth/pages/signup.tsx create mode 100644 app/auth/validations.ts create mode 100644 app/core/components/form.tsx create mode 100644 app/core/components/labeled-text-field.tsx create mode 100644 app/core/hooks/use-current-customer.ts create mode 100644 app/core/hooks/use-customer-phone-number.ts create mode 100644 app/core/hooks/use-require-onboarding.ts create mode 100644 app/core/layouts/base-layout.tsx rename {src/components => app/core/layouts}/layout/footer.tsx (75%) rename {src/components => app/core/layouts}/layout/index.tsx (72%) rename src/tailwind.css => app/core/styles/index.css (100%) create mode 100644 app/customers/queries/get-current-customer.ts create mode 100644 app/messages/api/webhook/incoming-message.ts create mode 100644 app/messages/components/conversation.tsx create mode 100644 app/messages/components/conversations-list.tsx create mode 100644 app/messages/components/new-message-area.tsx create mode 100644 app/messages/hooks/use-conversation.ts create mode 100644 app/messages/mutations/send-message.ts create mode 100644 app/messages/pages/messages.tsx create mode 100644 app/messages/pages/messages/[recipient].tsx create mode 100644 app/messages/queries/get-conversation.ts create mode 100644 app/messages/queries/get-conversations.ts rename {src/components/welcome => app/onboarding/components}/onboarding-layout.tsx (52%) create mode 100644 app/onboarding/mutations/set-phone-number.ts create mode 100644 app/onboarding/mutations/set-twilio-api-fields.ts create mode 100644 app/onboarding/pages/welcome/step-one.tsx create mode 100644 app/onboarding/pages/welcome/step-three.tsx rename {src => app/onboarding}/pages/welcome/step-two.tsx (55%) create mode 100644 app/pages/404.tsx create mode 100644 app/pages/_app.tsx create mode 100644 app/pages/_document.tsx create mode 100644 app/pages/index.test.tsx create mode 100644 app/pages/index.tsx create mode 100644 app/phone-calls/api/webhook/incoming-call.ts create mode 100644 app/phone-calls/api/webhook/outgoing-call.ts create mode 100644 app/phone-calls/components/phone-calls-list.tsx create mode 100644 app/phone-calls/hooks/use-phone-calls.ts create mode 100644 app/phone-calls/pages/calls.tsx create mode 100644 app/phone-calls/queries/get-phone-calls.ts create mode 100644 app/phone-numbers/queries/get-current-customer-phone-number.ts create mode 100644 app/phone-numbers/queries/get-customer-phone-number.ts create mode 100644 babel.config.js create mode 100644 blitz.config.ts create mode 100644 db/_encryption.ts create mode 100644 db/index.ts create mode 100644 db/migrations/20210726100838_init/migration.sql create mode 100644 db/migrations/20210727115631_import_models/migration.sql create mode 100644 db/migrations/20210727125716_user_role_enum/migration.sql create mode 100644 db/migrations/20210728061812_default_created_at_to_now/migration.sql create mode 100644 db/migrations/migration_lock.toml create mode 100644 db/schema.prisma create mode 100644 db/seeds.ts create mode 100644 global.d.ts rename {lib => integrations}/logger.ts (76%) delete mode 100644 jest/helpers.ts delete mode 100644 jest/setup.ts delete mode 100644 jest/testing-library.ts delete mode 100644 lib/__tests__/session-helpers.ts delete mode 100644 lib/session-helpers.ts delete mode 100644 lib/utils/cookies.ts delete mode 100644 lib/utils/hkdf.ts create mode 100644 mailers/forgot-password-mailer.ts delete mode 100644 next-env.d.ts delete mode 100644 next.config.js create mode 100755 public/favicon.ico create mode 100644 public/logo.png delete mode 100644 public/robots.txt delete mode 100644 public/static/favicon.ico delete mode 100644 public/static/fonts/inter/Inter-italic.var.woff2 delete mode 100644 public/static/fonts/inter/Inter-roman.var.woff2 delete mode 100644 public/static/illustrations/data-analytics.svg delete mode 100644 public/static/illustrations/learn-coding.svg delete mode 100644 public/static/illustrations/support-team.svg delete mode 100644 public/static/logo.svg delete mode 100644 src/__tests__/pages/__snapshots__/index.tsx.snap delete mode 100644 src/__tests__/pages/account/settings/index.tsx delete mode 100644 src/__tests__/pages/account/settings/team.tsx delete mode 100644 src/__tests__/pages/api/auth/sign-in.ts delete mode 100644 src/__tests__/pages/api/auth/sign-up.ts delete mode 100644 src/__tests__/pages/api/subscription/_subscription-created.ts delete mode 100644 src/__tests__/pages/api/subscription/webhook.ts delete mode 100644 src/__tests__/pages/api/team/invite-member.ts delete mode 100644 src/__tests__/pages/api/user/session.ts delete mode 100644 src/__tests__/pages/api/user/update-user.ts delete mode 100644 src/__tests__/pages/auth/sign-in.tsx delete mode 100644 src/__tests__/pages/index.tsx delete mode 100644 src/__tests__/pages/team/invitation.tsx delete mode 100644 src/components/alert.tsx delete mode 100644 src/components/auth/auth-page.tsx delete mode 100644 src/components/avatar.tsx delete mode 100644 src/components/billing/billing-plans.tsx delete mode 100644 src/components/button.tsx delete mode 100644 src/components/connected-layout.tsx delete mode 100644 src/components/divider.tsx delete mode 100644 src/components/icons.tsx delete mode 100644 src/components/layout/header.tsx delete mode 100644 src/components/loading.tsx delete mode 100644 src/components/logo.tsx delete mode 100644 src/components/modal.tsx delete mode 100644 src/components/outside-alerter.tsx delete mode 100644 src/components/settings/danger-zone.tsx delete mode 100644 src/components/settings/profile-informations.tsx delete mode 100644 src/components/settings/settings-layout.tsx delete mode 100644 src/components/settings/settings-section.tsx delete mode 100644 src/components/settings/update-password.tsx delete mode 100644 src/components/toggle.tsx delete mode 100644 src/database/_encryption.ts delete mode 100644 src/database/customer.ts delete mode 100644 src/database/message.ts delete mode 100644 src/database/phone-call.ts delete mode 100644 src/database/phone-number.ts delete mode 100644 src/database/subscriptions.ts delete mode 100644 src/fonts.css delete mode 100644 src/hooks/use-auth.ts delete mode 100644 src/hooks/use-conversation.ts delete mode 100644 src/hooks/use-paddle.ts delete mode 100644 src/hooks/use-request.ts delete mode 100644 src/hooks/use-subscription.ts delete mode 100644 src/hooks/use-user.ts delete mode 100644 src/pages/_app.tsx delete mode 100644 src/pages/_document.tsx delete mode 100644 src/pages/api/_redirect.ts delete mode 100644 src/pages/api/_send-email.ts delete mode 100644 src/pages/api/_types.ts delete mode 100644 src/pages/api/auth/session.ts delete mode 100644 src/pages/api/auth/sign-in.ts delete mode 100644 src/pages/api/auth/sign-up.ts delete mode 100644 src/pages/api/conversation/[recipient].ts delete mode 100644 src/pages/api/conversation/[recipient]/send-message.ts delete mode 100644 src/pages/api/ddd.ts delete mode 100644 src/pages/api/newsletter/subscribe.ts delete mode 100644 src/pages/api/queue/fetch-calls.ts delete mode 100644 src/pages/api/queue/fetch-messages.ts delete mode 100644 src/pages/api/queue/insert-calls.ts delete mode 100644 src/pages/api/queue/insert-messages.ts delete mode 100644 src/pages/api/queue/send-message.ts delete mode 100644 src/pages/api/queue/set-twilio-webhooks.ts delete mode 100644 src/pages/api/subscription/_subscription-cancelled.ts delete mode 100644 src/pages/api/subscription/_subscription-created.ts delete mode 100644 src/pages/api/subscription/_subscription-payment-succeeded.ts delete mode 100644 src/pages/api/subscription/_subscription-updated.ts delete mode 100644 src/pages/api/subscription/update.ts delete mode 100644 src/pages/api/subscription/webhook.ts delete mode 100644 src/pages/api/user/add-phone-number.ts delete mode 100644 src/pages/api/user/delete-user.ts delete mode 100644 src/pages/api/user/list-twilio-numbers.ts delete mode 100644 src/pages/api/user/update-user.ts delete mode 100644 src/pages/api/webhook/incoming-call.ts delete mode 100644 src/pages/api/webhook/incoming-message.ts delete mode 100644 src/pages/api/webhook/outgoing-call.ts delete mode 100644 src/pages/auth/forgot-password.tsx delete mode 100644 src/pages/auth/sign-in.tsx delete mode 100644 src/pages/auth/sign-out.tsx delete mode 100644 src/pages/auth/sign-up.tsx delete mode 100644 src/pages/calls.tsx delete mode 100644 src/pages/index.tsx delete mode 100644 src/pages/keypad.tsx delete mode 100644 src/pages/messages/[recipient].tsx delete mode 100644 src/pages/messages/index.tsx delete mode 100644 src/pages/settings/account.tsx delete mode 100644 src/pages/settings/billing.tsx delete mode 100644 src/pages/settings/index.tsx delete mode 100644 src/pages/welcome/step-one.tsx delete mode 100644 src/pages/welcome/step-three.tsx delete mode 100644 src/state.ts delete mode 100644 src/subscription/_paddle-api.ts delete mode 100644 src/subscription/plans.ts delete mode 100644 src/supabase/client.ts delete mode 100644 src/supabase/server.ts create mode 100644 test/setup.ts create mode 100644 test/utils.tsx create mode 100644 types.ts 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 ( -