Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/49 sign up verification #100

Merged
merged 15 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "verifyToken" TEXT;
1 change: 1 addition & 0 deletions api/db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ model User {
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
memberships Membership[]
verifyToken String?
}

model Membership {
Expand Down
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions api/src/emails/user-verification.ts
Original file line number Diff line number Diff line change
@@ -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 `
<div> Hi ${userNameWithFallback(user)}, </div>
<p>Please find below a link to verify your email for the ${appName}:</p>
<a href="${link}">${link}</a>
<p>If you did not request an account, please ignore this email.</p>
`
},
}

// 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 }
20 changes: 17 additions & 3 deletions api/src/functions/auth.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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
},

Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions api/src/graphql/users.sdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
`
41 changes: 41 additions & 0 deletions api/src/lib/mailer.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
)
}
1 change: 1 addition & 0 deletions api/src/services/users/users.scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const standard = defineScenario<Prisma.UserCreateArgs>({
one: {
data: {
email: 'String4589593',
verifyToken: 'HarryPotter',
...DEFAULT_FIELDS,
},
},
Expand Down
94 changes: 91 additions & 3 deletions api/src/services/users/users.test.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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)
})
})
})
36 changes: 36 additions & 0 deletions api/src/services/users/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 && 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 } }),
Expand Down
2 changes: 1 addition & 1 deletion redwood.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions web/jest.config.js
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions web/jest.setup.js
Original file line number Diff line number Diff line change
@@ -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)
}
})
8 changes: 6 additions & 2 deletions web/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ const Routes = () => {
<Route path="/signup" page={SignupPage} name="signup" />
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
<Route path="/verification" page={VerificationPage} name="verification" />
<Route path="/verification-reset" page={VerificationResetPage} name="verificationReset" />
<Set wrap={MainLayout}>
<Route path="/" page={HomePage} name="home" />
<Route notfound page={NotFoundPage} />
<Route path="/forbidden" page={ForbiddenPage} name="forbidden" />
</Set>
<Set wrap={MainLayout}>
<Private unauthenticated="forbidden">
<Set wrap={ProfileLayout}>
<Route path="/profile" page={ProfileEditProfilePage} name="profile" />
Expand Down Expand Up @@ -44,8 +50,6 @@ const Routes = () => {
</Set>
</Set>
</Private>
<Route notfound page={NotFoundPage} />
<Route path="/forbidden" page={ForbiddenPage} name="forbidden" />
</Set>
</Router>
)
Expand Down
Loading