diff --git a/client/components/admin.vue b/client/components/admin.vue
index aed83164..c2dc8bee 100644
--- a/client/components/admin.vue
+++ b/client/components/admin.vue
@@ -66,6 +66,9 @@
v-list-tile(to='/api')
v-list-tile-avatar: v-icon call_split
v-list-tile-title {{ $t('admin:api.title') }}
+ v-list-tile(to='/mail')
+ v-list-tile-avatar: v-icon email
+ v-list-tile-title {{ $t('admin:mail.title') }}
v-list-tile(to='/system')
v-list-tile-avatar: v-icon tune
v-list-tile-title {{ $t('admin:system.title') }}
@@ -121,6 +124,7 @@ const router = new VueRouter({
{ path: '/search', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-search.vue') },
{ path: '/storage', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-storage.vue') },
{ path: '/api', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-api.vue') },
+ { path: '/mail', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-mail.vue') },
{ path: '/system', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-system.vue') },
{ path: '/utilities', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-utilities.vue') },
{ path: '/dev', component: () => import(/* webpackChunkName: "admin-dev" */ './admin/admin-dev.vue') },
diff --git a/client/components/admin/admin-mail.vue b/client/components/admin/admin-mail.vue
new file mode 100644
index 00000000..ba0eab4b
--- /dev/null
+++ b/client/components/admin/admin-mail.vue
@@ -0,0 +1,206 @@
+
+ v-container(fluid, grid-list-lg)
+ v-layout(row wrap)
+ v-flex(xs12)
+ .admin-header
+ img(src='/svg/icon-new-post.svg', alt='Mail', style='width: 80px;')
+ .admin-header-title
+ .headline.primary--text {{ $t('admin:mail.title') }}
+ .subheading.grey--text {{ $t('admin:mail.subtitle') }}
+ v-spacer
+ v-btn(color='success', depressed, @click='save', large)
+ v-icon(left) check
+ span {{$t('common:actions.apply')}}
+ v-form.pt-3
+ v-layout(row wrap)
+ v-flex(lg6 xs12)
+ v-form
+ v-card.wiki-form
+ v-toolbar(color='primary', dark, dense, flat)
+ v-toolbar-title
+ .subheading {{ $t('admin:mail.configuration') }}
+ v-subheader Sender
+ .px-3.pb-3
+ v-text-field(
+ outline
+ v-model='config.senderName'
+ label='Sender Name'
+ required
+ :counter='255'
+ prepend-icon='person'
+ )
+ v-text-field(
+ outline
+ v-model='config.senderEmail'
+ label='Sender Email'
+ required
+ :counter='255'
+ prepend-icon='email'
+ )
+ v-divider
+ v-subheader SMTP Settings
+ .px-3.pb-3
+ v-text-field(
+ outline
+ v-model='config.host'
+ label='Host'
+ required
+ :counter='255'
+ prepend-icon='memory'
+ )
+ v-text-field(
+ outline
+ v-model='config.port'
+ label='Port'
+ required
+ prepend-icon='router'
+ persistent-hint
+ hint='Usually 465 (recommended), 587 or 25.'
+ style='max-width: 300px;'
+ )
+ v-switch(
+ v-model='config.secure'
+ label='Secure (TLS)'
+ color='primary'
+ persistent-hint
+ hint='Should be enabled when using port 465, otherwise turned off (587 or 25).'
+ prepend-icon='vpn_lock'
+ )
+ v-text-field.mt-3(
+ outline
+ v-model='config.user'
+ label='Username'
+ required
+ :counter='255'
+ prepend-icon='lock_outline'
+ )
+ v-text-field(
+ outline
+ v-model='config.pass'
+ label='Password'
+ required
+ prepend-icon='lock'
+ type='password'
+ )
+
+ v-flex(lg6 xs12)
+ v-card.wiki-form
+ v-form
+ v-toolbar(color='primary', dark, dense, flat)
+ v-toolbar-title
+ .subheading {{ $t('admin:mail.dkim') }}
+ .pa-3
+ .body-2.grey--text.text--darken-2 DKIM (DomainKeys Identified Mail) provides a layer of security on all emails sent from Wiki.js by providing the means for recipients to validate the domain name and ensure the message authenticity.
+ v-switch(
+ v-model='config.useDKIM'
+ label='Use DKIM'
+ color='primary'
+ prepend-icon='vpn_key'
+ )
+ v-text-field(
+ outline
+ v-model='config.dkimDomainName'
+ label='Domain Name'
+ :counter='255'
+ prepend-icon='vpn_key'
+ :disabled='!config.useDKIM'
+ )
+ v-text-field(
+ outline
+ v-model='config.dkimKeySelector'
+ label='Key Selector'
+ :counter='255'
+ prepend-icon='vpn_key'
+ :disabled='!config.useDKIM'
+ )
+ v-text-field(
+ outline
+ v-model='config.dkimPrivateKey'
+ label='Private Key'
+ prepend-icon='vpn_key'
+ persistent-hint
+ hint='Private key for the selector in PEM format'
+ :disabled='!config.useDKIM'
+ )
+
+
+
+
+
+
diff --git a/client/graph/admin/mail/mail-mutation-save-config.gql b/client/graph/admin/mail/mail-mutation-save-config.gql
new file mode 100644
index 00000000..ed280713
--- /dev/null
+++ b/client/graph/admin/mail/mail-mutation-save-config.gql
@@ -0,0 +1,36 @@
+mutation (
+ $senderName: String!,
+ $senderEmail: String!,
+ $host: String!,
+ $port: Int!,
+ $secure: Boolean!,
+ $user: String!,
+ $pass: String!,
+ $useDKIM: Boolean!,
+ $dkimDomainName: String!,
+ $dkimKeySelector: String!,
+ $dkimPrivateKey: String!
+) {
+ mail {
+ updateConfig(
+ senderName: $senderName,
+ senderEmail: $senderEmail,
+ host: $host,
+ port: $port,
+ secure: $secure,
+ user: $user,
+ pass: $pass,
+ useDKIM: $useDKIM,
+ dkimDomainName: $dkimDomainName,
+ dkimKeySelector: $dkimKeySelector,
+ dkimPrivateKey: $dkimPrivateKey
+ ) {
+ responseResult {
+ succeeded
+ errorCode
+ slug
+ message
+ }
+ }
+ }
+}
diff --git a/client/graph/admin/mail/mail-query-config.gql b/client/graph/admin/mail/mail-query-config.gql
new file mode 100644
index 00000000..0cdc8c19
--- /dev/null
+++ b/client/graph/admin/mail/mail-query-config.gql
@@ -0,0 +1,17 @@
+{
+ mail {
+ config {
+ senderName
+ senderEmail
+ host
+ port
+ secure
+ user
+ pass
+ useDKIM
+ dkimDomainName
+ dkimKeySelector
+ dkimPrivateKey
+ }
+ }
+}
diff --git a/client/static/svg/icon-new-post.svg b/client/static/svg/icon-new-post.svg
new file mode 100644
index 00000000..1fdeb21c
--- /dev/null
+++ b/client/static/svg/icon-new-post.svg
@@ -0,0 +1,39 @@
+
+
+
diff --git a/package.json b/package.json
index 13c5051e..c3074004 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"mysql2": "1.6.4",
"node-2fa": "1.1.2",
"node-cache": "4.2.0",
+ "nodemailer": "4.7.0",
"oauth2orize": "1.11.0",
"objection": "1.4.0",
"ora": "3.0.0",
diff --git a/server/core/mail.js b/server/core/mail.js
new file mode 100644
index 00000000..fc71b679
--- /dev/null
+++ b/server/core/mail.js
@@ -0,0 +1,74 @@
+const nodemailer = require('nodemailer')
+const _ = require('lodash')
+const fs = require('fs-extra')
+const path = require('path')
+
+/* global WIKI */
+
+module.exports = {
+ transport: null,
+ templates: {},
+ init() {
+ if (_.get(WIKI.config, 'mail.host', '').length > 2) {
+ let conf = {
+ host: WIKI.config.mail.host,
+ port: WIKI.config.mail.port,
+ secure: WIKI.config.mail.secure
+ }
+ if (_.get(WIKI.config, 'mail.user', '').length > 1) {
+ conf = {
+ ...conf,
+ auth: {
+ user: WIKI.config.mail.user,
+ pass: WIKI.config.mail.pass
+ }
+ }
+ }
+ if (_.get(WIKI.config, 'mail.useDKIM', false)) {
+ conf = {
+ ...conf,
+ dkim: {
+ domainName: WIKI.config.mail.dkimDomainName,
+ keySelector: WIKI.config.mail.dkimKeySelector,
+ privateKey: WIKI.config.mail.dkimPrivateKey
+ }
+ }
+ }
+ this.transport = nodemailer.createTransport(conf)
+ } else {
+ WIKI.logger.warn('Mail is not setup! Please set the configuration in the administration area!')
+ this.transport = null
+ }
+ return this
+ },
+ async send(opts) {
+ if (!this.transport) {
+ WIKI.logger.warn('Cannot send email because mail is not setup in the administration area!')
+ throw new WIKI.Error.MailNotSetup()
+ }
+ await this.loadTemplate(opts.template)
+ return this.transport.sendMail({
+ from: 'noreply@requarks.io',
+ to: opts.to,
+ subject: `${opts.subject} - ${WIKI.config.title}`,
+ text: opts.text,
+ html: _.get(this.templates, opts.template)({
+ logo: '',
+ siteTitle: WIKI.config.title,
+ copyright: 'Powered by Wiki.js',
+ ...opts.data
+ })
+ })
+ },
+ async loadTemplate(key) {
+ if (_.has(this.templates, key)) { return }
+ const keyKebab = _.kebabCase(key)
+ try {
+ const rawTmpl = await fs.readFile(path.join(WIKI.SERVERPATH, `templates/${keyKebab}.html`), 'utf8')
+ _.set(this.templates, key, _.template(rawTmpl))
+ } catch (err) {
+ WIKI.logger.warn(err)
+ throw new WIKI.Error.MailTemplateFailed()
+ }
+ }
+}
diff --git a/server/db/migrations/2.0.0.js b/server/db/migrations/2.0.0.js
index d2f68e03..b515032a 100644
--- a/server/db/migrations/2.0.0.js
+++ b/server/db/migrations/2.0.0.js
@@ -170,6 +170,15 @@ exports.up = knex => {
table.string('createdAt').notNullable()
table.string('updatedAt').notNullable()
})
+ // USER KEYS ---------------------------
+ .createTable('userKeys', table => {
+ table.charset('utf8mb4')
+ table.increments('id').primary()
+ table.string('kind').notNullable()
+ table.string('key').notNullable()
+ table.string('createdAt').notNullable()
+ table.string('validUntil').notNullable()
+ })
// USERS -------------------------------
.createTable('users', table => {
table.charset('utf8mb4')
@@ -185,6 +194,8 @@ exports.up = knex => {
table.string('pictureUrl')
table.string('timezone').notNullable().defaultTo('America/New_York')
table.boolean('isSystem').notNullable().defaultTo(false)
+ table.boolean('isActive').notNullable().defaultTo(false)
+ table.boolean('isVerified').notNullable().defaultTo(false)
table.string('createdAt').notNullable()
table.string('updatedAt').notNullable()
})
@@ -240,6 +251,9 @@ exports.up = knex => {
table.integer('pageId').unsigned().references('id').inTable('pages')
table.string('localeCode', 2).references('code').inTable('locales')
})
+ .table('userKeys', table => {
+ table.integer('userId').unsigned().references('id').inTable('users')
+ })
.table('users', table => {
table.string('providerKey').references('key').inTable('authentication').notNullable().defaultTo('local')
table.string('localeCode', 2).references('code').inTable('locales').notNullable().defaultTo('en')
@@ -267,5 +281,6 @@ exports.down = knex => {
.dropTableIfExists('settings')
.dropTableIfExists('storage')
.dropTableIfExists('tags')
+ .dropTableIfExists('userKeys')
.dropTableIfExists('users')
}
diff --git a/server/graph/resolvers/mail.js b/server/graph/resolvers/mail.js
new file mode 100644
index 00000000..9bb0845d
--- /dev/null
+++ b/server/graph/resolvers/mail.js
@@ -0,0 +1,46 @@
+const _ = require('lodash')
+const graphHelper = require('../../helpers/graph')
+
+/* global WIKI */
+
+module.exports = {
+ Query: {
+ async mail() { return {} }
+ },
+ Mutation: {
+ async mail() { return {} }
+ },
+ MailQuery: {
+ async config(obj, args, context, info) {
+ return WIKI.config.mail
+ }
+ },
+ MailMutation: {
+ async updateConfig(obj, args, context) {
+ try {
+ WIKI.config.mail = {
+ senderName: args.senderName,
+ senderEmail: args.senderEmail,
+ host: args.host,
+ port: args.port,
+ secure: args.secure,
+ user: args.user,
+ pass: args.pass,
+ useDKIM: args.useDKIM,
+ dkimDomainName: args.dkimDomainName,
+ dkimKeySelector: args.dkimKeySelector,
+ dkimPrivateKey: args.dkimPrivateKey,
+ }
+ await WIKI.configSvc.saveToDb(['mail'])
+
+ WIKI.mail.init()
+
+ return {
+ responseResult: graphHelper.generateSuccess('Mail configuration updated successfully')
+ }
+ } catch (err) {
+ return graphHelper.generateError(err)
+ }
+ }
+ }
+}
diff --git a/server/graph/schemas/mail.graphql b/server/graph/schemas/mail.graphql
new file mode 100644
index 00000000..1ac3ffe8
--- /dev/null
+++ b/server/graph/schemas/mail.graphql
@@ -0,0 +1,57 @@
+# ===============================================
+# MAIL
+# ===============================================
+
+extend type Query {
+ mail: MailQuery
+}
+
+extend type Mutation {
+ mail: MailMutation
+}
+
+# -----------------------------------------------
+# QUERIES
+# -----------------------------------------------
+
+type MailQuery {
+ config: MailConfig @auth(requires: ["manage:system"])
+}
+
+# -----------------------------------------------
+# MUTATIONS
+# -----------------------------------------------
+
+type MailMutation {
+ updateConfig(
+ senderName: String!
+ senderEmail: String!
+ host: String!
+ port: Int!
+ secure: Boolean!
+ user: String!
+ pass: String!
+ useDKIM: Boolean!
+ dkimDomainName: String!
+ dkimKeySelector: String!
+ dkimPrivateKey: String!
+ ): DefaultResponse @auth(requires: ["manage:system"])
+}
+
+# -----------------------------------------------
+# TYPES
+# -----------------------------------------------
+
+type MailConfig {
+ senderName: String!
+ senderEmail: String!
+ host: String!
+ port: Int!
+ secure: Boolean!
+ user: String!
+ pass: String!
+ useDKIM: Boolean!
+ dkimDomainName: String!
+ dkimKeySelector: String!
+ dkimPrivateKey: String!
+}
diff --git a/server/helpers/error.js b/server/helpers/error.js
index 345aed58..05e57626 100644
--- a/server/helpers/error.js
+++ b/server/helpers/error.js
@@ -1,6 +1,14 @@
const CustomError = require('custom-error-instance')
module.exports = {
+ AuthAccountBanned: CustomError('AuthAccountBanned', {
+ message: 'Your account has been disabled.',
+ code: 1016
+ }),
+ AuthAccountNotVerified: CustomError('AuthAccountNotVerified', {
+ message: 'You must verify your account before your can login.',
+ code: 1017
+ }),
AuthGenericError: CustomError('AuthGenericError', {
message: 'An unexpected error occured during login.',
code: 1001
@@ -45,6 +53,14 @@ module.exports = {
message: 'Input data is invalid.',
code: 1013
}),
+ MailNotSetup: CustomError('MailNotSetup', {
+ message: 'Mail is not setup yet.',
+ code: 1014
+ }),
+ MailTemplateFailed: CustomError('MailTemplateFailed', {
+ message: 'Mail template failed to load.',
+ code: 1015
+ }),
LocaleInvalidNamespace: CustomError('LocaleInvalidNamespace', {
message: 'Invalid locale or namespace.',
code: 1009
diff --git a/server/master.js b/server/master.js
index be8fdab9..7a3e55e1 100644
--- a/server/master.js
+++ b/server/master.js
@@ -19,6 +19,7 @@ module.exports = async () => {
WIKI.auth = require('./core/auth').init()
WIKI.lang = require('./core/localization').init()
+ WIKI.mail = require('./core/mail').init()
// ----------------------------------------
// Load middlewares
diff --git a/server/models/users.js b/server/models/users.js
index 2174d75e..ca096e52 100644
--- a/server/models/users.js
+++ b/server/models/users.js
@@ -34,6 +34,8 @@ module.exports = class User extends Model {
location: {type: 'string'},
pictureUrl: {type: 'string'},
isSystem: {type: 'boolean'},
+ isActive: {type: 'boolean'},
+ isVerified: {type: 'boolean'},
createdAt: {type: 'string'},
updatedAt: {type: 'string'}
}
@@ -351,7 +353,24 @@ module.exports = class User extends Model {
locale: 'en',
defaultEditor: 'markdown',
tfaIsActive: false,
- isSystem: false
+ isSystem: false,
+ isActive: true,
+ isVerified: false
+ })
+
+ // Send verification email
+ await WIKI.mail.send({
+ template: 'accountVerify',
+ to: email,
+ subject: 'Verify your account',
+ data: {
+ preheadertext: 'Verify your account in order to gain access to the wiki.',
+ title: 'Verify your account',
+ content: 'Click the button below in order to verify your account and gain access to the wiki.',
+ buttonLink: 'http://www.google.com',
+ buttonText: 'Verify'
+ },
+ text: `You must open the following link in your browser to verify your account and gain access to the wiki: http://www.google.com`
})
return true
} else {
diff --git a/server/modules/authentication/local/authentication.js b/server/modules/authentication/local/authentication.js
index b212c03c..5f4120e9 100644
--- a/server/modules/authentication/local/authentication.js
+++ b/server/modules/authentication/local/authentication.js
@@ -19,7 +19,13 @@ module.exports = {
}).then((user) => {
if (user) {
return user.verifyPassword(uPassword).then(() => {
- done(null, user)
+ if (!user.isActive) {
+ done(new WIKI.Error.AuthAccountBanned(), null)
+ } else if (!user.isVerified) {
+ done(new WIKI.Error.AuthAccountNotVerified(), null)
+ } else {
+ done(null, user)
+ }
}).catch((err) => {
done(err, null)
})
diff --git a/server/setup.js b/server/setup.js
index f9623ec6..ea5c061f 100644
--- a/server/setup.js
+++ b/server/setup.js
@@ -204,7 +204,9 @@ module.exports = () => {
name: 'Administrator',
locale: 'en',
defaultEditor: 'markdown',
- tfaIsActive: false
+ tfaIsActive: false,
+ isActive: true,
+ isVerified: true
})
await adminUser.$relatedQuery('groups').relate(adminGroup.id)
@@ -222,7 +224,9 @@ module.exports = () => {
locale: 'en',
defaultEditor: 'markdown',
tfaIsActive: false,
- isSystem: true
+ isSystem: true,
+ isActive: true,
+ isVerified: true
})
await guestUser.$relatedQuery('groups').relate(guestGroup.id)
diff --git a/server/templates/account-verify.html b/server/templates/account-verify.html
new file mode 100644
index 00000000..3d55d420
--- /dev/null
+++ b/server/templates/account-verify.html
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= preheadertext %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+ <%= title %>
+ <%= content %>
+ |
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ <%= copyright %>
+ |
+
+
+
+
+
+
+
+
+
+
+