Build a Project Management Tool with Vue.js, Node.js and Apollo — Part 3

Kenzo Takahashi
ITNEXT
Published in
8 min readAug 10, 2018

--

Thank you for giving me lots of claps! As of this writing, I got almost 300 claps in total.

In part 1 of this series, we built a simple GraphQL API.

In part2 of this series, we built MongoDB schemas and an email form.

In this part, we are going to complete authentication.

Part 4 — building a workspace

Part 5 — CRUD functionality for folders

The code for this tutorial is available here. Or if you want to follow along, you can clone part2 branch here.

Signup

Open up server/src/resolver.js and add the following:

const { User, Team } = require('./models')const JWT_SECRET = process.env.JWT_SECRETfunction randomChoice(arr) {
return arr[Math.floor(arr.length * Math.random())]
}
const avatarColors = [
"D81B60","F06292","F48FB1","FFB74D","FF9800","F57C00","00897B","4DB6AC","80CBC4",
"80DEEA","4DD0E1","00ACC1","9FA8DA","7986CB","3949AB","8E24AA","BA68C8","CE93D8"
]
const resolvers = {
...
Mutation: {
...
async signup (_, {id, firstname, lastname, password}) {
const user = await User.findById(id)
const common = {
firstname,
lastname,
name: `${firstname} ${lastname}`,
avatarColor: randomChoice(avatarColors),
password: await bcrypt.hash(password, 10),
status: 'Active'
}
if (user.role === 'Owner') {
const team = await Team.create({
name: `${common.name}'s Team`
})
user.set({
...common,
team: team.id,
jobTitle: 'CEO/Owner/Founder'
})
} else {
user.set(common)
}
await user.save()
const token = jwt.sign({id: user.id, email: user.email}, JWT_SECRET)
return {token, user}
},
},
}

It finds the user data that was created in captureEmail and sets some fields.

As you can see, we randomly pick an avatar color from 10 colors. This is a lazy approach because it’s possible to pick the same color even if you only have a few members. I encourage you to write a better algorithm and share it in a comment. I would be happy to incorporate it in my code.

signup function is used for both Owners and other users. Later we are going to build accounts page where you can invite other users.

If the user is an owner, it also creates a team. Finally it signs the user in and returns the token along with the user object.

Add JWT_SECRET environmental variable to .env and we are ready to test it! Make sure you restart the server.

MONGODB_URI=mongodb://localhost:27017/enamel_tutorial
JWT_SECRET=thisissecret

Open up the playground and signup the user you created last time. You need to go to the database to copy-paste the user id.

If you go check your database, you should see that your team has been created. Notice it’s under folders. That’s because Team is a special type of Folder.

Now the API is working, let’s build a signup form.

client/src/router.js now looks like:

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import Signup from './views/Signup.vue'
Vue.use(Router)const router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'home',
component: Home,
meta: { title: 'enamel' }
},
{
path: '/signup/:id',
name: 'signup',
component: Signup,
meta: { title: 'Signup - enamel' }
},
]
})
router.afterEach((to, from) => {
document.title = to.meta.title
})
export default router

I added title meta fields so that each route shows a different document title.

Add the signup query to client/src/constants/query.gql :

mutation Signup($id: String!, $firstname: String!, $lastname: String!, $password: String!) {
signup(id: $id, firstname: $firstname, lastname: $lastname, password: $password) {
token
user {
id
email
}
}
}

Here is the signup Vue component. Nothing too crazy here. After signing up the user, it saves the token and user id to localstorage. Then it should take you to workplace, but since we haven’t built yet, it prints the message to the console for now.

// client/src/views/Signup.vue
<template>
<el-container>
<el-header >
</el-header>
<el-main>
<div class="container-center">
<div>Welcome to enamel! Finish setting up your account</div>
<div v-if="error" class="error">
{{ error }}
</div>
<el-form ref="form" :model="form">
<el-form-item>
<label>First name</label>
<el-input v-model="form.firstname" placeholder="Your first name"></el-input>
<label>Last name</label>
<el-input v-model="form.lastname" placeholder="Your last name"></el-input>
<label>Password</label>
<el-input v-model="form.password" type="password" placeholder="Password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="signup">Complete</el-button>
</el-form-item>
</el-form>
</div></el-main>
</el-container>
</template><script>
import { Signup } from '../constants/query.gql'
export default {
data() {
return {
error: false,
form: {
firstname: '',
lastname: '',
password: '',
}
}
},
methods: {
async signup() {
const { firstname, lastname, password } = this.form
if (!(firstname && lastname && password)) {
this.error = 'Please complete the form'
return
}
this.$apollo.mutate({
mutation: Signup,
variables: {
id: this.$route.params.id,
firstname,
lastname,
password
}
}).then(({data: {signup}}) => {
const id = signup.user.id
const token = signup.token
this.saveUserData(id, token)
// this.$router.push({name: 'workspace'})
console.log('success!') // For now just print
}).catch((error) => {
this.error = 'Something went wrong'
console.log(error)
})
},
saveUserData (id, token) {
localStorage.setItem('user-id', id)
localStorage.setItem('user-token', token)
this.$root.$data.userId = localStorage.getItem('user-id')
},
}
}
</script>
<style scoped>.el-button {
width: 100%;
}
.error {
padding-top: 10px;
}
</style>

So how do we get to the signup page? Recall from the last post that after submitting the email form, you should see a message that says “Please check your email”.

In production, I send an email with a signup link. However, you might not want to bother with setting up your email. So I’m going to make the email part of this app a bonus material.

For now, copy-paste your id to the address bar.

If you submit the form, it overrides the existing data and creates another team object. This is obviously not what you want, but it’s suffice for now. (It actually remains this way in production, but hey, you gotta move fast!)

Login

Le’t repeat the same process and create a login functionality. Add this to server/src/resolvers.js :

async login (_, {email, password}) {
const user = await User.findOne({email})
if (!user) {
throw new Error('No user with that email')
}
const valid = await bcrypt.compare(password, user.password)
if (!valid) {
throw new Error('Incorrect password')
}
const token = jwt.sign({id: user.id, email}, JWT_SECRET)
return {token, user}
},

I almost always test a GraphQL API using playground first and then create a frontend. Since this is similar to signup, we are going to skip that and move onto frontend, but I encourage you to try it on playground.

Router:

...
import Login from './views/Login.vue'
Vue.use(Router)const router = new Router({
mode: 'history',
routes: [
...
{
path: '/login',
name: 'login',
component: Login,
meta: { title: 'Login - enamel' }
}
]
})

query.gql:

mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
user {
id
email
}
}
}

Login.vue:

<template>
<el-container>
<el-header>
</el-header>
<el-main>
<div class="container-center">
<h2>Log in</h2>

<div v-if="error" class="error">
{{ error }}
</div>
<el-form ref="form" :model="form">
<el-form-item>
<label>Email</label>
<el-input v-model="form.email" placeholder="Email"></el-input>
<label>Password</label>
<el-input v-model="form.password" type="password" placeholder="Password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click.once="login">Log in</el-button>
</el-form-item>
</el-form>
<div>
<span>Don't have an account?</span>
<router-link :to="{name: 'home'}" class="link">Create an account</router-link>
</div>
</div>
</el-main>
</el-container>
</template><script>
import { Login } from '../constants/query.gql'
export default {
data() {
return {
error: false,
form: {
email: '',
password: '',
}
}
},
methods: {
async login() {
const { email, password } = this.form
if (email && password) {
this.$apollo.mutate({
mutation: Login,
variables: { email, password }
}).then(async (data) => {
const login = data.data.login
const id = login.user.id
const token = login.token
this.saveUserData(id, token)
// this.$router.push({name: 'workspace'})
console.log('success')
}).catch((error) => {
this.error = 'Invalid email or password'
console.log(error)
})
}
},
saveUserData (id, token) {
localStorage.setItem('user-id', id)
localStorage.setItem('user-token', token)
this.$root.$data.userId = localStorage.getItem('user-id')
},
}
}
</script>
<style scoped>
.el-button {
width: 100%;
}
</style>

It should work as you expect:

Bonus Material: Sending Email

For those of you who actually want to setup your email, I will walk through the code.

Initially I used SendGrid. SendGrid has a free plan and an easy API. It was good… until they blocked my account. I contacted the support but got no response so far. The only reason I can think of is that I set the sender as noreply@enamel.tech. I own the domain, but I don’t have the business email account. I thought as long as I own the domain, I can use whatever email address I want.

Anyway, I’m too cheap to buy a business email in this early stage(remember, I have only 1 paying customer), so I settled with nodemailer using my personal email address.

We have already installed nodemailer, so we just need to import it. Here is how you setup nodemailer for gmail:

const nodeMailer = require('nodemailer')
const { welcomeEmail } = require('./emails')
const transporter = nodeMailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
user: process.env.FROM_EMAIL,
pass: process.env.GMAIL_PASSWORD
}
})

Create a new file server/src/emails.js and add this code:

const url = process.env.CLIENT_URL
const fromEmail = process.env.FROM_EMAIL
module.exports.welcomeEmail = function(email, user) {
const text = `
Hi,
Thank you for choosing enamel!
You are just one click away from completing your account registration.
Confirm your email:\n
${url}/signup/${user.id}
`
return {
to: `${email}`,
from: {
address: fromEmail,
name: 'enamel'
},
subject: 'Please complete your registration',
text
}
}

We need an environment variable for client url as well as email and password. Make sure you restart the server after change.

MONGODB_URI=mongodb://localhost:27017/enamel_tutorial
JWT_SECRET=thisissecret
CLIENT_URL=http://localhost:8080
FROM_EMAIL=youremail@gmail.com
GMAIL_PASSWORD=mypassword

Finally, add one line tocaptureEmail:

async captureEmail (_, {email}) {
const isEmailTaken = await User.findOne({email})
if (isEmailTaken) {
throw new Error('This email is already taken')
}
const user = await User.create({
email,
role: 'Owner',
status: 'Pending'
})
// New code
transporter.sendMail(welcomeEmail(email, user))
return user
},

If you enter your email in the email form(make sure there is no user with the same email), you should get the email from yourself.

Coming Up: Creating Workplace

That’s it for part 3! Check out the github for reference if you need it.

Now that we have built a foundation for this app, we can accelerate from here. I know it’s been a little bit boring so far, so thank you for your patience. I would not structure my tutorial in this manner for beginners. For beginners, it’s best to ignore backend and create UI first so that you quickly get an idea of what the app looks like. But you are not a beginner, right?

In part 4, we are going to create workplace where you can create folders and tasks.

if you liked this post, please give it some claps! It motivates me to write the next part sooner.

--

--