From 0db35d607719500e9c9cec059239b7b0aec3b63a Mon Sep 17 00:00:00 2001 From: craineum Date: Tue, 25 Oct 2022 08:05:26 -0400 Subject: [PATCH] Feature/49 sign up verification (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ➕ Add crypto library to auth functions for sign up verify token generation Co-authored-by: nataliadiaz2 Co-authored-by: Sunjay Armstead Co-authored-by: Jesse House Co-authored-by: Jesse House Co-authored-by: Ryan --- .env.example | 6 ++ .../migration.sql | 2 + api/db/schema.prisma | 1 + api/package.json | 3 +- api/src/emails/user-verification.ts | 29 ++++++ api/src/functions/auth.ts | 20 +++- api/src/graphql/users.sdl.ts | 2 + api/src/lib/mailer.ts | 41 ++++++++ api/src/services/users/users.scenarios.ts | 1 + api/src/services/users/users.test.ts | 94 ++++++++++++++++++- api/src/services/users/users.ts | 36 +++++++ redwood.toml | 2 +- web/jest.config.js | 2 + web/jest.setup.js | 14 +++ web/src/Routes.tsx | 8 +- .../Admin/User/EditUserCell/EditUserCell.tsx | 6 -- web/src/components/Admin/User/User/User.tsx | 4 + .../User/UserFormTeams/UserFormTeams.test.tsx | 16 ++-- .../UserFormTeamsCell.mock.ts | 22 +++++ .../UserFormTeamsCell.mocks.ts | 24 ----- .../UserFormTeamsCell.test.tsx | 4 +- web/src/components/Admin/User/Users/Users.tsx | 1 + .../components/Navigation/Navigation.test.tsx | 5 +- .../Verification/Verification.mock.ts | 1 + .../Verification/Verification.stories.tsx | 12 +++ .../Verification/Verification.test.tsx | 11 +++ .../components/Verification/Verification.tsx | 45 +++++++++ web/src/pages/LoginPage/LoginPage.tsx | 3 + .../VerificationPage.stories.tsx | 7 ++ .../VerificationPage.test.tsx | 14 +++ .../VerificationPage/VerificationPage.tsx | 7 ++ .../VerificationResetPage.stories.tsx | 12 +++ .../VerificationResetPage.test.tsx | 14 +++ .../VerificationResetPage.tsx | 93 ++++++++++++++++++ yarn.lock | 17 +++- 35 files changed, 524 insertions(+), 55 deletions(-) create mode 100644 api/db/migrations/20220914175103_verify_token_add_to_user/migration.sql create mode 100644 api/src/emails/user-verification.ts create mode 100644 api/src/lib/mailer.ts create mode 100644 web/jest.setup.js create mode 100644 web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mock.ts delete mode 100644 web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mocks.ts create mode 100644 web/src/components/Verification/Verification.mock.ts create mode 100644 web/src/components/Verification/Verification.stories.tsx create mode 100644 web/src/components/Verification/Verification.test.tsx create mode 100644 web/src/components/Verification/Verification.tsx create mode 100644 web/src/pages/VerificationPage/VerificationPage.stories.tsx create mode 100644 web/src/pages/VerificationPage/VerificationPage.test.tsx create mode 100644 web/src/pages/VerificationPage/VerificationPage.tsx create mode 100644 web/src/pages/VerificationResetPage/VerificationResetPage.stories.tsx create mode 100644 web/src/pages/VerificationResetPage/VerificationResetPage.test.tsx create mode 100644 web/src/pages/VerificationResetPage/VerificationResetPage.tsx diff --git a/.env.example b/.env.example index 4f31f54..2afce6d 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +APP_NAME='Redwood Template App' SESSION_SECRET='its dangerous to go alone. take this 🗡' DATABASE_URL=postgresql://postgres:development@localhost:5432/redwood_dev @@ -9,4 +10,9 @@ ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=password USERS_PASSWORD=password +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_KEY= + # LOG_LEVEL=debug diff --git a/api/db/migrations/20220914175103_verify_token_add_to_user/migration.sql b/api/db/migrations/20220914175103_verify_token_add_to_user/migration.sql new file mode 100644 index 0000000..5d7bc46 --- /dev/null +++ b/api/db/migrations/20220914175103_verify_token_add_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "verifyToken" TEXT; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index f2df1b6..537c607 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -23,6 +23,7 @@ model User { updatedAt DateTime @updatedAt createdAt DateTime @default(now()) memberships Membership[] + verifyToken String? } model Membership { diff --git a/api/package.json b/api/package.json index decc8c1..5f67cc9 100644 --- a/api/package.json +++ b/api/package.json @@ -5,7 +5,8 @@ "dependencies": { "@redwoodjs/api": "^3.2.0", "@redwoodjs/api-server": "^3.2.0", - "@redwoodjs/graphql-server": "^3.2.0" + "@redwoodjs/graphql-server": "^3.2.0", + "nodemailer": "^6.7.8" }, "devDependencies": { "@types/chance": "^1.1.3", diff --git a/api/src/emails/user-verification.ts b/api/src/emails/user-verification.ts new file mode 100644 index 0000000..e7a53dd --- /dev/null +++ b/api/src/emails/user-verification.ts @@ -0,0 +1,29 @@ +import type { User } from '@prisma/client' + +import { logger } from 'src/lib/logger' + +const email = { + subject: () => 'Verify Email', + htmlBody: (user: User) => { + const link = `${process.env.DOMAIN}/verification?verifyToken=${user.verifyToken}` + const appName = process.env.APP_NAME + + if (process.env.NODE_ENV === 'development') { + logger.debug(link) + } + + return ` +
Hi ${userNameWithFallback(user)},
+

Please find below a link to verify your email for the ${appName}:

+ ${link} +

If you did not request an account, please ignore this email.

+ ` + }, +} + +// TODO: extract to utils that can be shared with api and web +const userNameWithFallback = (user: User) => { + return user.name || user.nickname || user.email +} + +export { email } diff --git a/api/src/functions/auth.ts b/api/src/functions/auth.ts index 4fc4f2d..d21d47a 100644 --- a/api/src/functions/auth.ts +++ b/api/src/functions/auth.ts @@ -1,6 +1,10 @@ +import { randomUUID } from 'node:crypto' + import { DbAuthHandler } from '@redwoodjs/api' +import { email as verificationEmail } from 'src/emails/user-verification' import { db } from 'src/lib/db' +import { sendEmail } from 'src/lib/mailer' export const handler = async (event, context) => { const forgotPasswordOptions = { @@ -52,6 +56,9 @@ export const handler = async (event, context) => { if (!user.active) { throw new Error('User not Active') } + if (user.verifyToken) { + throw new Error('User not Verified') + } return user }, @@ -112,15 +119,22 @@ export const handler = async (event, context) => { // If this returns anything else, it will be returned by the // `signUp()` function in the form of: `{ message: 'String here' }`. // eslint-disable-next-line - handler: ({ username, hashedPassword, salt, userAttributes }) => { - return db.user.create({ + handler: async ({ username, hashedPassword, salt, userAttributes }) => { + const user = await db.user.create({ data: { email: username, hashedPassword: hashedPassword, salt: salt, - // name: userAttributes.name + verifyToken: randomUUID(), }, }) + + sendEmail({ + to: user.email, + subject: verificationEmail.subject(), + html: verificationEmail.htmlBody(user), + }) + return user }, errors: { diff --git a/api/src/graphql/users.sdl.ts b/api/src/graphql/users.sdl.ts index 00b854e..8862883 100644 --- a/api/src/graphql/users.sdl.ts +++ b/api/src/graphql/users.sdl.ts @@ -45,5 +45,7 @@ export const schema = gql` updateUser(id: String!, input: UpdateUserInput!): User! @requireAuth(roles: ["super admin"]) removeUser(id: String!): User! @requireAuth(roles: ["super admin"]) + verifyUser(token: String!): Boolean! @skipAuth + verifyReset(email: String!): String! @skipAuth } ` diff --git a/api/src/lib/mailer.ts b/api/src/lib/mailer.ts new file mode 100644 index 0000000..d1db5a5 --- /dev/null +++ b/api/src/lib/mailer.ts @@ -0,0 +1,41 @@ +import * as nodemailer from 'nodemailer' + +import { logger } from 'src/lib/logger' + +interface Options { + to: string | string[] + subject: string + text?: string + html?: string +} + +export async function sendEmail({ to, subject, text, html }: Options) { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_KEY, + }, + }) + + return transporter.sendMail( + { + from: 'support@redwoodtemplate.com', + to: Array.isArray(to) ? to : [to], + subject, + text, + html, + }, + (error) => { + if (error) { + logger.error( + `Failed to send '${subject}' email, check SMTP configuration` + ) + logger.debug('This error can be ignored in development') + logger.error(error) + } + } + ) +} diff --git a/api/src/services/users/users.scenarios.ts b/api/src/services/users/users.scenarios.ts index c76ce2f..109a86b 100644 --- a/api/src/services/users/users.scenarios.ts +++ b/api/src/services/users/users.scenarios.ts @@ -10,6 +10,7 @@ export const standard = defineScenario({ one: { data: { email: 'String4589593', + verifyToken: 'HarryPotter', ...DEFAULT_FIELDS, }, }, diff --git a/api/src/services/users/users.test.ts b/api/src/services/users/users.test.ts index 9c3a1ea..9d461be 100644 --- a/api/src/services/users/users.test.ts +++ b/api/src/services/users/users.test.ts @@ -1,8 +1,24 @@ import { db } from 'src/lib/db' -import { users, user, createUser, updateUser, removeUser } from './users' +import { + users, + user, + createUser, + updateUser, + removeUser, + verifyReset, + verifyUser, +} from './users' import type { AssociationsScenario, StandardScenario } from './users.scenarios' +const mockSendEmail = jest.fn() + +jest.mock('src/lib/mailer', () => { + return { + sendEmail: () => mockSendEmail(), + } +}) + describe('users', () => { scenario('returns all users', async (scenario: StandardScenario) => { const result = await users() @@ -30,8 +46,6 @@ describe('users', () => { expect(result.active).toEqual(true) expect(result.admin).toEqual(false) expect(result.email).toEqual('String4652567') - expect(result.hashedPassword).toBeTruthy() - expect(result.salt).toBeTruthy() expect(result.createdAt.getTime()).toBeGreaterThanOrEqual( before.getTime() ) @@ -187,4 +201,78 @@ describe('users', () => { expect(result.active).toEqual(false) expect(result.admin).toEqual(false) }) + + describe('verify user', () => { + scenario('with verify token', async (scenario: StandardScenario) => { + const result = await verifyUser({ token: scenario.user.one.verifyToken }) + const user = await db.user.findUnique({ + where: { id: scenario.user.one.id }, + }) + + expect(result).toEqual(true) + expect(user.verifyToken).toEqual(null) + }) + + scenario('without verify token', async (scenario: StandardScenario) => { + const result = await verifyUser({ token: scenario.user.two.verifyToken }) + const user = await db.user.findUnique({ + where: { id: scenario.user.two.id }, + }) + + expect(result).toEqual(true) + expect(user.verifyToken).toEqual(null) + }) + + scenario( + 'with invalid verify token', + async (scenario: StandardScenario) => { + const result = await verifyUser({ token: 'invalid' }) + const user = await db.user.findUnique({ + where: { id: scenario.user.one.id }, + }) + + expect(result).toEqual(false) + expect(user.verifyToken).toEqual(scenario.user.one.verifyToken) + } + ) + }) + + describe('verify reset', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + scenario( + 'with existing email with verify token', + async (scenario: StandardScenario) => { + const email = scenario.user.one.email + + expect(mockSendEmail.mock.calls.length).toBe(0) + const result = await verifyReset({ email }) + expect(result).toEqual(email) + expect(mockSendEmail.mock.calls.length).toBe(1) + } + ) + + scenario( + 'with existing email with no verify token', + async (scenario: StandardScenario) => { + const email = scenario.user.two.email + + expect(mockSendEmail.mock.calls.length).toBe(0) + const result = await verifyReset({ email }) + expect(result).toEqual(email) + expect(mockSendEmail.mock.calls.length).toBe(0) + } + ) + + scenario('with email that does not exist', async () => { + const email = 'does@not.exist' + + expect(mockSendEmail.mock.calls.length).toBe(0) + const result = await verifyReset({ email }) + expect(result).toEqual(email) + expect(mockSendEmail.mock.calls.length).toBe(0) + }) + }) }) diff --git a/api/src/services/users/users.ts b/api/src/services/users/users.ts index 8584c87..b8acaa8 100644 --- a/api/src/services/users/users.ts +++ b/api/src/services/users/users.ts @@ -6,7 +6,9 @@ import type { UserResolvers, } from 'types/graphql' +import { email as verificationEmail } from 'src/emails/user-verification' import { db } from 'src/lib/db' +import { sendEmail } from 'src/lib/mailer' const parseRoles = (roleIds) => roleIds?.reduce((acc, id) => { @@ -79,6 +81,40 @@ export const removeUser: MutationResolvers['removeUser'] = async ({ id }) => { return user } +export const verifyReset: MutationResolvers['verifyReset'] = async ({ + email, +}) => { + const user = await db.user.findUnique({ + where: { email }, + }) + if (user?.verifyToken) { + sendEmail({ + to: user.email, + subject: verificationEmail.subject(), + html: verificationEmail.htmlBody(user), + }) + } + return email +} + +export const verifyUser: MutationResolvers['verifyUser'] = async ({ + token, +}) => { + if (token === null) return true + const user = await db.user.findFirst({ + where: { verifyToken: token }, + }) + if (user) { + await db.user.update({ + where: { id: user.id }, + data: { verifyToken: null }, + }) + return true + } else { + return false + } +} + export const User: UserResolvers = { memberships: (_obj, { root }) => db.membership.findMany({ where: { userId: root.id } }), diff --git a/redwood.toml b/redwood.toml index 8b02a7f..207d2f9 100644 --- a/redwood.toml +++ b/redwood.toml @@ -6,7 +6,7 @@ # https://redwoodjs.com/docs/app-configuration-redwood-toml [web] - title = "Redwood Template App" + title = "${APP_NAME}" port = "${PORT:8910}" apiUrl = "/.redwood/functions" # you can customise graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths includeEnvironmentVariables = [] # any ENV vars that should be available to the web side, see https://redwoodjs.com/docs/environment-variables#web diff --git a/web/jest.config.js b/web/jest.config.js index 0e54869..60058a0 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -1,8 +1,10 @@ // More info at https://redwoodjs.com/docs/project-configuration-dev-test-build +const path = require('path') const config = { rootDir: '../', preset: '@redwoodjs/testing/config/jest/web', + setupFilesAfterEnv: [path.resolve(__dirname, './jest.setup.js')], } module.exports = config diff --git a/web/jest.setup.js b/web/jest.setup.js new file mode 100644 index 0000000..8234661 --- /dev/null +++ b/web/jest.setup.js @@ -0,0 +1,14 @@ +/* eslint-env jest */ +const fg = require('fast-glob') + +const mocks = fg.sync('**/*.mock.{js,ts,jsx,tsx}', { + cwd: global.__RWJS_TESTROOT_DIR, + ignore: ['node_modules', '**/*Cell/*'], + absolute: true, +}) + +beforeAll(() => { + for (const m of mocks) { + require(m) + } +}) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 18bef32..76e220d 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -14,8 +14,14 @@ const Routes = () => { + + + + + + @@ -44,8 +50,6 @@ const Routes = () => { - - ) diff --git a/web/src/components/Admin/User/EditUserCell/EditUserCell.tsx b/web/src/components/Admin/User/EditUserCell/EditUserCell.tsx index 53c92d8..7497702 100644 --- a/web/src/components/Admin/User/EditUserCell/EditUserCell.tsx +++ b/web/src/components/Admin/User/EditUserCell/EditUserCell.tsx @@ -30,12 +30,6 @@ const UPDATE_USER_MUTATION = gql` mutation UpdateUserMutation($id: String!, $input: UpdateUserInput!) { updateUser(id: $id, input: $input) { id - email - name - nickname - pronouns - active - admin } } ` diff --git a/web/src/components/Admin/User/User/User.tsx b/web/src/components/Admin/User/User/User.tsx index b4e89ab..1e354d0 100644 --- a/web/src/components/Admin/User/User/User.tsx +++ b/web/src/components/Admin/User/User/User.tsx @@ -85,6 +85,10 @@ const User = ({ user }) => { Admin {checkboxInputTag('admin', user.admin)} + + Verified + {checkboxInputTag('verified', user.verified)} + Teams diff --git a/web/src/components/Admin/User/UserFormTeams/UserFormTeams.test.tsx b/web/src/components/Admin/User/UserFormTeams/UserFormTeams.test.tsx index 182baad..30b7d0d 100644 --- a/web/src/components/Admin/User/UserFormTeams/UserFormTeams.test.tsx +++ b/web/src/components/Admin/User/UserFormTeams/UserFormTeams.test.tsx @@ -3,12 +3,12 @@ import userEvent from '@testing-library/user-event' import { Form } from '@redwoodjs/forms' import { render, screen, within } from '@redwoodjs/testing/web' -import { standard } from '../UserFormTeamsCell/UserFormTeamsCell.mocks' +import { standard } from '../UserFormTeamsCell/UserFormTeamsCell.mock' import { UserFormTeams } from './UserFormTeams' -const [firstTeam] = standard().userFormTeams.teams -const [firstRole, secondRole] = standard().userFormTeams.roles +const [firstTeam] = standard().teams +const [firstRole, secondRole] = standard().roles describe('UserFormTeams', () => { describe('when a value is not selected', () => { @@ -17,10 +17,10 @@ describe('UserFormTeams', () => {
) @@ -31,7 +31,7 @@ describe('UserFormTeams', () => { it('renders team name successfully', () => { renderComponent() - const [firstTeam, secondTeam] = standard().userFormTeams.teams + const [firstTeam, secondTeam] = standard().teams const firstElement = firstTeam.name const secondElement = secondTeam.name userEvent.selectOptions( @@ -63,9 +63,9 @@ describe('UserFormTeams', () => {
y} /> diff --git a/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mock.ts b/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mock.ts new file mode 100644 index 0000000..e664770 --- /dev/null +++ b/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mock.ts @@ -0,0 +1,22 @@ +export const standard = () => ({ + teams: [ + { + id: '1', + name: 'team1', + }, + { + id: '2', + name: 'team2', + }, + ], + roles: [ + { + id: '3', + name: 'foo_role', + }, + { + id: '4', + name: 'admin', + }, + ], +}) diff --git a/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mocks.ts b/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mocks.ts deleted file mode 100644 index d55e04e..0000000 --- a/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.mocks.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const standard = () => ({ - userFormTeams: { - teams: [ - { - id: '1', - name: 'team1', - }, - { - id: '2', - name: 'team2', - }, - ], - roles: [ - { - id: '3', - name: 'foo_role', - }, - { - id: '4', - name: 'admin', - }, - ], - }, -}) diff --git a/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.test.tsx b/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.test.tsx index 4f7ac56..a48f92a 100644 --- a/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.test.tsx +++ b/web/src/components/Admin/User/UserFormTeamsCell/UserFormTeamsCell.test.tsx @@ -1,7 +1,7 @@ import { render } from '@redwoodjs/testing/web' import { Loading, Failure, Success } from './UserFormTeamsCell' -import { standard } from './UserFormTeamsCell.mocks' +import { standard } from './UserFormTeamsCell.mock' jest.mock('../UserFormTeams/UserFormTeams', () => ({ UserFormTeams: () => { @@ -24,7 +24,7 @@ describe('UserFormTeamsCell', () => { it('renders Success successfully', async () => { expect(() => { - render() + render() }).not.toThrow() }) }) diff --git a/web/src/components/Admin/User/Users/Users.tsx b/web/src/components/Admin/User/Users/Users.tsx index 124c38f..e3991c9 100644 --- a/web/src/components/Admin/User/Users/Users.tsx +++ b/web/src/components/Admin/User/Users/Users.tsx @@ -70,6 +70,7 @@ const Users = ({ users }) => { Pronouns Active Admin + Verified Updated at Created at Actions diff --git a/web/src/components/Navigation/Navigation.test.tsx b/web/src/components/Navigation/Navigation.test.tsx index b04c0b3..c73c7f2 100644 --- a/web/src/components/Navigation/Navigation.test.tsx +++ b/web/src/components/Navigation/Navigation.test.tsx @@ -11,10 +11,6 @@ import { Navigation } from './Navigation' const renderComponent = (props = {}) => render() describe('Navigation', () => { - afterEach(() => { - jest.clearAllMocks() - }) - it('renders navigation component', () => { renderComponent() expect(screen.getByTestId('nav')).toBeVisible() @@ -36,6 +32,7 @@ describe('Navigation', () => { it('shows logout when authenticated', async () => { mockCurrentUser({ id: 'foobar' }) + renderComponent() await waitFor(() => { expect(screen.getByText('Logout')).toBeInTheDocument() diff --git a/web/src/components/Verification/Verification.mock.ts b/web/src/components/Verification/Verification.mock.ts new file mode 100644 index 0000000..b615c8f --- /dev/null +++ b/web/src/components/Verification/Verification.mock.ts @@ -0,0 +1 @@ +mockGraphQLMutation('VerificationMutation', { verifyUser: true }) diff --git a/web/src/components/Verification/Verification.stories.tsx b/web/src/components/Verification/Verification.stories.tsx new file mode 100644 index 0000000..b085612 --- /dev/null +++ b/web/src/components/Verification/Verification.stories.tsx @@ -0,0 +1,12 @@ +import type { ComponentMeta } from '@storybook/react' + +import { Verification } from './Verification' + +export const generated = () => { + return +} + +export default { + title: 'Components/Verification', + component: Verification, +} as ComponentMeta diff --git a/web/src/components/Verification/Verification.test.tsx b/web/src/components/Verification/Verification.test.tsx new file mode 100644 index 0000000..e4b238d --- /dev/null +++ b/web/src/components/Verification/Verification.test.tsx @@ -0,0 +1,11 @@ +import { render } from '@redwoodjs/testing/web' + +import { Verification } from './Verification' + +describe('Verification', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/web/src/components/Verification/Verification.tsx b/web/src/components/Verification/Verification.tsx new file mode 100644 index 0000000..ae1e0c9 --- /dev/null +++ b/web/src/components/Verification/Verification.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react' + +import { navigate, routes } from '@redwoodjs/router' +import { MetaTags, useMutation } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/dist/toast' + +const VERIFY_TOKEN_MUTATION = gql` + mutation VerificationMutation($token: String!) { + verifyUser: verifyUser(token: $token) + } +` +const Verification = ({ token }) => { + const [verifyUser, { loading, error }] = useMutation(VERIFY_TOKEN_MUTATION, { + onCompleted: () => { + toast.success('Account Verified') + navigate(routes.login()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + useEffect(() => { + verifyUser({ variables: { token } }) + }, [verifyUser, token]) + + return ( + <> + +
+
+

Verify Account

+
+
+

+ {loading && 'Loading...'} + {error && 'Error, unable to verify account'} +

+
+
+ + ) +} + +export { Verification } diff --git a/web/src/pages/LoginPage/LoginPage.tsx b/web/src/pages/LoginPage/LoginPage.tsx index dcdafd2..ce2bb9b 100644 --- a/web/src/pages/LoginPage/LoginPage.tsx +++ b/web/src/pages/LoginPage/LoginPage.tsx @@ -34,6 +34,9 @@ const LoginPage = () => { if (response.message) { toast(response.message) } else if (response.error) { + if (response.error === 'User not Verified') { + navigate(routes.verificationReset({ email: data.username })) + } toast.error(response.error) } else { toast.success('Welcome back!') diff --git a/web/src/pages/VerificationPage/VerificationPage.stories.tsx b/web/src/pages/VerificationPage/VerificationPage.stories.tsx new file mode 100644 index 0000000..e54f1db --- /dev/null +++ b/web/src/pages/VerificationPage/VerificationPage.stories.tsx @@ -0,0 +1,7 @@ +import { VerificationPage } from './VerificationPage' + +export const generated = (args) => { + return +} + +export default { title: 'Pages/VerificationPage' } diff --git a/web/src/pages/VerificationPage/VerificationPage.test.tsx b/web/src/pages/VerificationPage/VerificationPage.test.tsx new file mode 100644 index 0000000..aa427d7 --- /dev/null +++ b/web/src/pages/VerificationPage/VerificationPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import VerificationPage from './VerificationPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('VerificationPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/web/src/pages/VerificationPage/VerificationPage.tsx b/web/src/pages/VerificationPage/VerificationPage.tsx new file mode 100644 index 0000000..84f666d --- /dev/null +++ b/web/src/pages/VerificationPage/VerificationPage.tsx @@ -0,0 +1,7 @@ +import { Verification } from 'src/components/Verification' + +const VerificationPage = ({ verifyToken }) => ( + +) + +export default VerificationPage diff --git a/web/src/pages/VerificationResetPage/VerificationResetPage.stories.tsx b/web/src/pages/VerificationResetPage/VerificationResetPage.stories.tsx new file mode 100644 index 0000000..ce83284 --- /dev/null +++ b/web/src/pages/VerificationResetPage/VerificationResetPage.stories.tsx @@ -0,0 +1,12 @@ +import type { ComponentMeta } from '@storybook/react' + +import VerificationResetPage from './VerificationResetPage' + +export const generated = () => { + return +} + +export default { + title: 'Pages/VerificationResetPage', + component: VerificationResetPage, +} as ComponentMeta diff --git a/web/src/pages/VerificationResetPage/VerificationResetPage.test.tsx b/web/src/pages/VerificationResetPage/VerificationResetPage.test.tsx new file mode 100644 index 0000000..07b728b --- /dev/null +++ b/web/src/pages/VerificationResetPage/VerificationResetPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import VerificationResetPage from './VerificationResetPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('VerificationResetPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/web/src/pages/VerificationResetPage/VerificationResetPage.tsx b/web/src/pages/VerificationResetPage/VerificationResetPage.tsx new file mode 100644 index 0000000..dcc08f9 --- /dev/null +++ b/web/src/pages/VerificationResetPage/VerificationResetPage.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef } from 'react' + +import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { MetaTags, useMutation } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +const VERIFY_RESET_MUTATION = gql` + mutation VerificationResetMutation($email: String!) { + email: verifyReset(email: $email) + } +` + +const VerificationResetPage = ({ email }) => { + const [verifyReset, { loading }] = useMutation(VERIFY_RESET_MUTATION, { + onCompleted: (response) => { + toast.success( + 'A link to verify your account was sent to ' + response.email + ) + navigate(routes.login()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const emailRef = useRef() + useEffect(() => { + emailRef.current.focus() + }, []) + + const onSubmit = async (data) => { + verifyReset({ variables: { email: data.email } }) + } + + return ( + <> + + +
+ +
+
+
+

+ Resend Verification Email +

+
+ +
+
+
+
+ + + + +
+ +
+ + Send Email + +
+
+
+
+
+
+
+ + ) +} + +export default VerificationResetPage diff --git a/yarn.lock b/yarn.lock index 3d89174..7a923f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7260,6 +7260,7 @@ __metadata: "@redwoodjs/graphql-server": ^3.2.0 "@types/chance": ^1.1.3 chance: ^1.1.8 + nodemailer: ^6.7.8 languageName: unknown linkType: soft @@ -17631,6 +17632,13 @@ __metadata: languageName: node linkType: hard +"nodemailer@npm:^6.7.8": + version: 6.8.0 + resolution: "nodemailer@npm:6.8.0" + checksum: 6f8fd051ff33e1330903b0013f65a14a672731de8506a931a7012c582b1ea507257186244213108b966e30d41a297f8de9e295ace61a8f0a70222913255ad667 + languageName: node + linkType: hard + "nodemon@npm:2.0.20": version: 2.0.20 resolution: "nodemon@npm:2.0.20" @@ -23257,7 +23265,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:5.10.0, undici@npm:^5.5.1, undici@npm:^5.8.0": +"undici@npm:5.10.0, undici@npm:^5.5.1": version: 5.10.0 resolution: "undici@npm:5.10.0" checksum: c67eec014c92d40b27a271d0127d0297299a0507feb67e581a0bd9fb5ce32974d3a05eba2bd19357d6231a7c2bbbc15ed4cef43c2738b5210b275e585547b09c @@ -23271,6 +23279,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^5.8.0": + version: 5.8.1 + resolution: "undici@npm:5.8.1" + checksum: 9f1285950f153c28ab9b7e39cf3d4b8d1701c8e2037bde8460adc5f1fa3d3d5ab4336994310fccc9ac6679f1dacdca016b760ea2328cdab41e5e340faa684fd7 + languageName: node + linkType: hard + "unfetch@npm:^4.2.0": version: 4.2.0 resolution: "unfetch@npm:4.2.0"