Iworb's blog

Full Stack (Node + Express + MongoDb + Vue + Nuxt) application. Part 7: Visual part

2018-02-20

In the frontend we should save our user in the store. Let’s make new one client/store/auth.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const state = () => ({
user: null
})

export const mutations = {
SET_USER: (state, user) => {
state.user = user
}
}

export const actions = {
async login ({commit}, {username, password}) {
const data = await this.$axios.$post('/login', {username, password})
commit('SET_USER', data)
this.$router.push({path: '/'})
},
async logout ({commit}) {
await this.$axios.post('/logout')
commit('SET_USER', null)
this.$router.push({path: '/login'})
}
}

Each store (Vuex) have it’s own state, mutations and actions. Mutations are sync and actions could be async. For more details read Vuex documentation.
We have some new dependencies:

1
yarn add axios @nuxtjs/axios

Axios is package for requests.
Good thing abour Nuxt is SSR. You could set user on the server side if there’s user in request. Unfortunately, this action could be performed only in the client/store/index.js point, so let’s make it:

1
2
3
4
5
export const actions = {
nuxtServerInit ({commit}, {req}) {
if (req.user) commit('auth/SET_USER', req.user)
}
}

NB: commit to auth storage and call SET_USER mutation.
Now let’s change index.vue page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<template>
<div class="container">
<h1>Please login to see the secret content</h1>
<form v-if="!$store.state.auth.user" @submit.prevent="login">
<p class="error" v-if="formError">{{ formError }}</p>
<p><i>To login, use <b>camin</b> as username and <b>qwe123</b> as password.</i></p>
<p>Username: <input type="text" v-model="formUsername" name="username" /></p>
<p>Password: <input type="password" v-model="formPassword" name="password" /></p>
<button type="submit">Login</button>
</form>
<div v-else>
Hello {{ $store.state.auth.user.name }}!
<pre>I am the secret content, I am shown only when the use is connected.</pre>
<p><i>You can also refresh this page, you'll still be connected!</i></p>
<button @click="logout">Logout</button>
</div>
</div>
</template>

<script>
export default {
data () {
return {
formError: null,
formUsername: '',
formPassword: ''
}
},
methods: {
async login() {
try {
await this.$store.dispatch('auth/login', {
username: this.formUsername,
password: this.formPassword
})
this.formUsername = ''
this.formPassword = ''
this.formError = null
} catch (e) {
this.formError = e.message
}
},
async logout() {
try {
await this.$store.dispatch('auth/logout')
} catch (e) {
this.formError = e.message
}
}
}
}
</script>

<style lang="stylus">
.container
padding 100px
.error
color red
</style>

Now we can login and stayed login until logout.

Visual framework: Vuetify

There’s two popular frameworks for material design: Vuetify and Quasar.
Personally I prefer Quasar, because he has more use cases. But it does not support SSR currently (v0.15.0-beta.13), so I have to use Vuetify:

1
yarn add vuetify roboto-fontface nuxt-material-design-icons

NB: nuxt-material-design-icons takes really long time to install, so be patient.
Let’s extend our nuxt.config.js to include all things:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
module.exports = {
loading: {color: '#3B8070'},
srcDir: 'client/',
// [1]
build: {
vendor: [
'vuetify',
'axios',
'roboto-fontface'
],
extractCss: true
},
// [2]
modules: [
'@nuxtjs/axios',
'nuxt-material-design-icons'
],
// [3]
plugins: [
'~/plugins/axios',
'~plugins/vuetify.js'
],
// [4]
css: [
`~assets/style/app.styl`
],
// [5]
router: {
middleware: 'auth'
}
}

Nuxt can build single vendor package [1]. It’s good way to pack frequently using libraries, so let’s include vuetify, axios and roboto-fontface into vendor build.
Next thing is modules [2] which extends default Nuxt functionality.
Plugins [3] are our custom plugins for Vuetify and some error handling for axios.
Custom css [4] lands in css property.
To restrict our routes to authenticated users we could set middleware [5].
Plugins, css and middlewares are things that should be written by yourself, because they’re extend Nuxt functionality for your own purposes.
Let’s take a look at plugins.
client/plugins/axios.js - redirect to the login page if you not authorized anymore:

1
2
3
4
5
6
7
8
export default function ({$axios, redirect}) {
$axios.onError(error => {
const code = parseInt(error.response && error.response.status)
if (code === 401) {
return redirect('/login')
}
})
}

client/plugins/vuetify.js - include Vuetify with custom colors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Vue from 'vue'
import Vuetify from 'vuetify'
import colors from 'vuetify/es5/util/colors'

Vue.use(Vuetify, {
theme:
{
primary: colors.purple.base,
secondary: colors.grey.lighten1,
accent: colors.purple.lighten3,
error: colors.red.darken4,
warning: colors.amber.darken1,
info: colors.blue.darken4,
success: colors.green.darken3
}
})

Our css file is simple atm. client/assets/style/app.styl:

1
2
3
4
@require '~vuetify/src/stylus/main.styl'

.page
@extend .fade-transition

And last thing is middleware client/middleware/auth.js:

1
2
3
4
5
6
7
8
import {routeOption} from '../utilities'

export default function ({store, redirect, route}) {
if (routeOption(route, 'auth', false)) return
if (!store.state.auth.user) {
return redirect('/login')
}
}

There’s some utilities to disable auth on some pages. I borrowed the idea from @nuxtjs/axios, here’s client/utilities.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const isUnset = o => typeof o === 'undefined' || o === null
export const isSet = o => !isUnset(o)

export const isSameURL = (a, b) => a.split('?')[0] === b.split('?')[0]

export const isRelativeURL = u =>
/^\/[a-zA-Z0-9@\-%_~][/a-zA-Z0-9@\-%_~]{1,200}$/.test(u)

export const routeOption = (route, key, value) => {
return route.matched.some(m => {
if (process.browser) {
// Browser
return Object.values(m.components).some(
component => component.options[key] === value
)
} else {
// SSR
return Object.values(m.components).some(component =>
Object.values(component._Ctor).some(
ctor => ctor.options && ctor.options[key] === value
)
)
}
})
}

Layouts and pages

Let’s split our login page and index one.
This pages should has different layout - clean for login and drawer based for other pages.
Clean layout is simple client/layouts/clean.vue:

1
2
3
4
5
<template>
<v-app>
<nuxt/>
</v-app>
</template>

But client/layouts/default.vue is bit complicated:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<template>
<v-app>
<!-- [1] -->
<v-navigation-drawer
clipped
fixed
:value="$store.state.drawer"
app
>
<v-list dense>
<v-list-tile router v-for="(item, i) in items" :key="i" :to="item.to">
<v-list-tile-action>
<v-icon v-html="item.icon"></v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title v-text="item.title"></v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click.stop="logoutDialog = true">
<v-list-tile-action>
<v-icon>all_out</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>Sign Out</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<v-toolbar app fixed clipped-left color="primary" dark>
<v-toolbar-side-icon @click.prevent="$store.commit('TOGGLE_DRAWER')"/>
<v-toolbar-title>Application</v-toolbar-title>
</v-toolbar>
<v-content>
<v-container fluid fill-height>
<nuxt />
</v-container>
</v-content>
<v-dialog
v-model="logoutDialog"
max-width="500px"
>
<v-card tile>
<v-toolbar card dark color="error">
<v-toolbar-title>Sign Out</v-toolbar-title>
</v-toolbar>
<v-card-text>
Do you really want to logout?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" flat @click.stop="logoutDialog = false">No</v-btn>
<v-btn color="error" @click.stop="logout">Yes</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</template>

<script>
export default {
data() {
return {
logoutDialog: false,
items: [
{ icon: "home", title: "Home", to: "/" },
{ icon: "face", title: "Demo", to: "/demo" },
{ icon: "view_list", title: "Posts", to: "/posts" }
]
};
},
methods: {
async logout () {
try {
this.logoutDialog = false
await this.$store.dispatch('auth/logout')
} catch (e) {
console.log(e.message)
}
}
}
};
</script>

There’s some new things to know.
Drawer visibility property [1] is moved to an index store and toggle with mutation client/store/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const state = () => ({
drawer: true
})

export const mutations = {
TOGGLE_DRAWER (state) {
state.drawer = !state.drawer
}
}

export const actions = {
nuxtServerInit ({commit}, {req}) {
if (req.user) commit('auth/SET_USER', req.user)
}
}

Now let’s make client/pages/login,vue page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<template>
<v-content>
<v-container fluid fill-height>
<v-layout align-center justify-center>
<v-flex xs12 sm8 md4>
<v-form @submit.prevent="login">
<v-card class="elevation-12">
<v-toolbar dark color="primary">
<v-toolbar-title>Sign In</v-toolbar-title>
</v-toolbar>
<v-card-text>

<v-text-field
prepend-icon="person"
v-model="form.login.values.username"
label="Username"
type="text"
:error-messages="form.login.errors.username"
></v-text-field>
<v-text-field
prepend-icon="lock"
v-model="form.login.values.password"
label="Password"
:append-icon="e1 ? 'visibility' : 'visibility_off'"
:append-icon-cb="() => (e1 = !e1)"
:type="e1 ? 'password' : 'text'"
:error-messages="form.login.errors.password"
></v-text-field>

</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" type="submit">Sign In</v-btn>
</v-card-actions>
</v-card>
</v-form>
</v-flex>
</v-layout>
</v-container>
</v-content>
</template>

<script>
// [1]
import {resetFormErrors, catchFormErrors} from '../mixins'

export default {
// [2]
layout: 'clean',
auth: false,
mixins: [resetFormErrors, catchFormErrors],
data () {
return {
e1: true,
form: {
login: {
values: {
username: '',
password: ''
},
errors: {
username: [],
password: []
}
}
}
}
},
methods: {
async login () {
try {
this.resetFormErrors(this.form.login)
await this.$store.dispatch('auth/login', this.form.login.values)
} catch (e) {
this.catchFormErrors(this.form.login, e)
}
}
}
}
</script>

We should define our layout [2] if it’s not default.
There’s some mixins [1] I made for better error catching for forms. Why mixins? Because forms are using a lot in each application, different pages should handle form validation.
So I decided to make 2 mixins for reset form errors and passing errors to form.
client/mixins/resetFormErrors.js:

1
2
3
4
5
6
7
8
9
10
11
export default {
methods: {
resetFormErrors (form) {
if (form && form.errors) {
Object.keys(form.errors).forEach(k => {
form.errors[k] = []
})
}
}
}
}

client/mixins/catchFormErrors.js (we’re passing our form errors as fields variable inside error in the server side):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
methods: {
catchFormErrors (form, err) {
if (form && form.errors && err.response && err.response.data && err.response.data.error && err.response.data.error.fields) {
const fields = err.response.data.error.fields
Object.keys(fields).forEach(k => {
if (form.errors.hasOwnProperty(k) && typeof form.errors[k] === 'object') {
form.errors[k].push(fields[k])
}
})
}
}
}
}

And index.js file to gather mixins:

1
2
3
4
5
6
7
import catchFormErrors from './catchFormErrors'
import resetFormErrors from './resetFormErrors'

export {
catchFormErrors,
resetFormErrors
}

Now we can clean index.vue page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div>
<div>
Hello {{ $store.state.auth.user.name }}!
<pre>I am the secret content, I am shown only when the user is connected.</pre>
<p><i>You can also refresh this page, you'll still be connected!</i></p>
</div>
<div>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi feugiat diam et semper commodo. Praesent et lobortis nisi. Nulla laoreet urna sed sem fringilla, non consectetur dui feugiat. Proin elit eros, sagittis sit amet condimentum id, consectetur id diam. Integer sed nunc vitae nulla rutrum malesuada. Nunc aliquet augue eget mattis aliquam. Praesent eleifend varius orci rutrum auctor. Phasellus neque ipsum, euismod nec massa eget, aliquam porta ex.

Vivamus dapibus enim in suscipit porta. Vivamus ac pulvinar nisl. Proin id arcu ut leo pellentesque facilisis at lobortis sapien. In rutrum varius massa, sed egestas enim volutpat in. Nunc pharetra sagittis dui, id bibendum dolor congue et. Nam iaculis, felis in pretium sagittis, massa est lobortis leo, vel tincidunt tellus sem eu arcu. Mauris ultricies suscipit congue. Sed quis lobortis enim. Cras in tellus eu erat auctor vehicula. Nam urna nisi, tristique vel fringilla vitae, fringilla quis ligula. Fusce dictum ipsum ut ornare euismod. Fusce pulvinar justo nec neque laoreet scelerisque.

Aenean lorem neque, sollicitudin at ex ut, ullamcorper condimentum diam. Fusce enim quam, ultricies nec magna ut, porttitor commodo nibh. Curabitur vitae suscipit elit. Vestibulum sit amet libero lobortis, accumsan dolor congue, iaculis neque. Nunc sodales sollicitudin augue quis porta. Vestibulum lacus quam, elementum id euismod vitae, sollicitudin at massa. Maecenas eget ultrices tellus. Nullam eu placerat quam.

Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nulla porttitor dui id neque vehicula, ut suscipit metus laoreet. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut fermentum enim nec egestas accumsan. Nulla sit amet orci sit amet justo egestas efficitur. Praesent vel libero metus. Mauris quam sapien, condimentum non varius eget, tincidunt et lectus.

Nunc porttitor pretium purus ut maximus. Curabitur viverra imperdiet sapien, eget tincidunt ligula tincidunt volutpat. Vivamus faucibus laoreet diam non pellentesque. Cras egestas dui at rutrum maximus. Sed vehicula lectus a lorem convallis, eget varius nulla dignissim. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nam vel sapien non augue finibus scelerisque ut ac nibh. Sed non facilisis quam.
</div>
</div>
</template>

NB: there’s no Demo and Posts pages atm. Menu items defined for future purposes and doen’t works.
This part wraps up 07-visual git branch.