diff --git a/backend/src/middleware/email/emailMiddleware.js b/backend/src/middleware/email/emailMiddleware.js index bea1bf9b3..f92da3368 100644 --- a/backend/src/middleware/email/emailMiddleware.js +++ b/backend/src/middleware/email/emailMiddleware.js @@ -5,7 +5,8 @@ import { signupTemplate, resetPasswordTemplate, wrongAccountTemplate, -} from './templates/templateBuilder' + emailVerificationTemplate, +} from './templateBuilder' const hasEmailConfig = CONFIG.SMTP_HOST && CONFIG.SMTP_PORT const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD @@ -57,8 +58,17 @@ const sendPasswordResetMail = async (resolve, root, args, context, resolveInfo) return true } +const sendEmailVerificationMail = async (resolve, root, args, context, resolveInfo) => { + const response = await resolve(root, args, context, resolveInfo) + const { email, nonce, name } = response + await sendMail(emailVerificationTemplate({ email, nonce, name })) + delete response.nonce + return response +} + export default { Mutation: { + AddEmailAddress: sendEmailVerificationMail, requestPasswordReset: sendPasswordResetMail, Signup: sendSignupMail, SignupByInvitation: sendSignupMail, diff --git a/backend/src/middleware/email/templateBuilder.js b/backend/src/middleware/email/templateBuilder.js new file mode 100644 index 000000000..4b7bcc7cd --- /dev/null +++ b/backend/src/middleware/email/templateBuilder.js @@ -0,0 +1,77 @@ +import mustache from 'mustache' +import CONFIG from '../../config' + +import * as templates from './templates' + +const from = '"Human Connection" ' +const supportUrl = 'https://human-connection.org/en/contact' + +export const signupTemplate = ({ email, nonce }) => { + const subject = 'Willkommen, Bienvenue, Welcome to Human Connection!' + const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI) + actionUrl.searchParams.set('nonce', nonce) + actionUrl.searchParams.set('email', email) + + return { + from, + to: email, + subject, + html: mustache.render( + templates.layout, + { actionUrl, supportUrl, subject }, + { content: templates.signup }, + ), + } +} + +export const emailVerificationTemplate = ({ email, nonce, name }) => { + const subject = 'Neue E-Mail Adresse | New E-Mail Address' + const actionUrl = new URL('/settings/my-email-address/verify', CONFIG.CLIENT_URI) + actionUrl.searchParams.set('nonce', nonce) + actionUrl.searchParams.set('email', email) + + return { + from, + to: email, + subject, + html: mustache.render( + templates.layout, + { actionUrl, name, nonce, supportUrl, subject }, + { content: templates.emailVerification }, + ), + } +} + +export const resetPasswordTemplate = ({ email, nonce, name }) => { + const subject = 'Neues Passwort | Reset Password' + const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI) + actionUrl.searchParams.set('nonce', nonce) + actionUrl.searchParams.set('email', email) + + return { + from, + to: email, + subject, + html: mustache.render( + templates.layout, + { actionUrl, name, nonce, supportUrl, subject }, + { content: templates.passwordReset }, + ), + } +} + +export const wrongAccountTemplate = ({ email }) => { + const subject = 'Falsche Mailadresse? | Wrong E-mail?' + const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI) + + return { + from, + to: email, + subject, + html: mustache.render( + templates.layout, + { actionUrl, supportUrl }, + { content: templates.wrongAccount }, + ), + } +} diff --git a/backend/src/middleware/email/templates/emailVerification.html b/backend/src/middleware/email/templates/emailVerification.html new file mode 100644 index 000000000..e185beb53 --- /dev/null +++ b/backend/src/middleware/email/templates/emailVerification.html @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/email/templates/index.js b/backend/src/middleware/email/templates/index.js new file mode 100644 index 000000000..594cae334 --- /dev/null +++ b/backend/src/middleware/email/templates/index.js @@ -0,0 +1,11 @@ +import fs from 'fs' +import path from 'path' + +const readFile = fileName => fs.readFileSync(path.join(__dirname, fileName), 'utf-8') + +export const signup = readFile('./signup.html') +export const passwordReset = readFile('./resetPassword.html') +export const wrongAccount = readFile('./wrongAccount.html') +export const emailVerification = readFile('./emailVerification.html') + +export const layout = readFile('./layout.html') diff --git a/backend/src/middleware/email/templates/layout.html b/backend/src/middleware/email/templates/layout.html new file mode 100644 index 000000000..014288229 --- /dev/null +++ b/backend/src/middleware/email/templates/layout.html @@ -0,0 +1,256 @@ + + + + + + + + + + {{ subject }} + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + diff --git a/backend/src/middleware/email/templates/resetPassword.html b/backend/src/middleware/email/templates/resetPassword.html index e0dde53e5..ff8be01f2 100644 --- a/backend/src/middleware/email/templates/resetPassword.html +++ b/backend/src/middleware/email/templates/resetPassword.html @@ -1,448 +1,189 @@ - - + + + + + - - - - - - - Neues Passwort | Reset Password + + + + + - - - - - - - - - - - - - - - - - - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - + + + + - + + + + + + +
+ + + +
-

- Human Connection gGmbH
Bahnhofstraße 11, 73235 Weilheim / - Teck
Germany
-

+ style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;"> +

+ Hallo {{ name }}!

+

Du hast also dein Passwort vergessen? Kein Problem! Mit Klick auf diesen Button + kannst Du innerhalb der nächsten 24 Stunden Dein Passwort zurücksetzen:

+
+ + + + + +
+ Passwort + zurücksetzen +
+
- +
+ + +
+

Falls Du kein neues Passwort angefordert hast, kannst Du diese E-Mail einfach + ignorieren. Wenn Du noch Fragen hast, melde Dich gerne bei + unserem Support Team!

- - - +
+ + + + + + + +
+

Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in + Dein Browserfenster kopieren: {{{ nonce }}}

+

Bis bald bei Human Connection!

+

– Dein Human Connection Team

+
+

–––––––––––––––––––––––––––––––––––––––––––––––

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/email/templates/signup.html b/backend/src/middleware/email/templates/signup.html index e4be8c02f..4d875ff57 100644 --- a/backend/src/middleware/email/templates/signup.html +++ b/backend/src/middleware/email/templates/signup.html @@ -1,485 +1,214 @@ - - + + + + + - - - - - - - Willkommen, Bienvenue, Welcome to Human Connection + + + + + - - - - - - - - - - - - - - - - - - - -
- - - -
- Dein Anmeldelink. | Here is your signup link. -
-
- ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ 
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - + + + + - + + + + + + +
+ + + + + + +
-

- Human Connection gGmbH
Bahnhofstraße 11, 73235 Weilheim / - Teck
Germany
-

+ style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;"> +

+ Willkommen bei Human Connection!

+

Danke, dass Du dich angemeldet hast – wir freuen uns, Dich dabei zu haben. Jetzt + fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können ... Bitte bestätige + Deine E-Mail Adresse:

+
+ + + + + +
+ Bestätige + Deine E-Mail Adresse +
+ +
+

–––––––––––––––––––––––––––––––––––––––––––––––

- +
+ + + + + +
+

Falls Du Dich nicht selbst bei Human Connection angemeldet hast, schau doch mal vorbei! + Wir sind ein gemeinnütziges Aktionsnetzwerk – von Menschen für Menschen.

+

PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese + E-Mail einfach ignorieren. ;)

+
+

–––––––––––––––––––––––––––––––––––––––––––––––

- - - +
+ + + + + + + +
+

Melde Dich gerne bei + unserem Support Team, wenn Du Fragen hast.

+

Bis bald bei Human Connection!

+

– Dein Human Connection Team

+
+

–––––––––––––––––––––––––––––––––––––––––––––––

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/email/templates/templateBuilder.js b/backend/src/middleware/email/templates/templateBuilder.js deleted file mode 100644 index fdeb47a89..000000000 --- a/backend/src/middleware/email/templates/templateBuilder.js +++ /dev/null @@ -1,48 +0,0 @@ -import fs from 'fs' -import path from 'path' -import mustache from 'mustache' -import CONFIG from '../../../config' - -const from = '"Human Connection" ' -const supportUrl = 'https://human-connection.org/en/contact' - -const signupHtml = fs.readFileSync(path.join(__dirname, './signup.html'), 'utf-8') -const passwordResetHtml = fs.readFileSync(path.join(__dirname, './resetPassword.html'), 'utf-8') -const wrongAccountHtml = fs.readFileSync(path.join(__dirname, './wrongAccount.html'), 'utf-8') - -export const signupTemplate = ({ email, nonce }) => { - const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI) - actionUrl.searchParams.set('nonce', nonce) - actionUrl.searchParams.set('email', email) - - return { - from, - to: email, - subject: 'Willkommen, Bienvenue, Welcome to Human Connection!', - html: mustache.render(signupHtml, { actionUrl, supportUrl }), - } -} - -export const resetPasswordTemplate = ({ email, nonce, name }) => { - const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI) - actionUrl.searchParams.set('nonce', nonce) - actionUrl.searchParams.set('email', email) - - return { - from, - to: email, - subject: 'Neues Passwort | Reset Password', - html: mustache.render(passwordResetHtml, { actionUrl, name, nonce, supportUrl }), - } -} - -export const wrongAccountTemplate = ({ email }) => { - const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI) - - return { - from, - to: email, - subject: 'Falsche Mailadresse? | Wrong E-mail?', - html: mustache.render(wrongAccountHtml, { actionUrl, supportUrl }), - } -} diff --git a/backend/src/middleware/email/templates/wrongAccount.html b/backend/src/middleware/email/templates/wrongAccount.html index b8e6f6f57..3cea69c4f 100644 --- a/backend/src/middleware/email/templates/wrongAccount.html +++ b/backend/src/middleware/email/templates/wrongAccount.html @@ -1,448 +1,189 @@ - - + + + + + - - - - - - - Falsche Mailadresse? | Wrong E-mail? + + + + + - - - - - - - - - - - - - - - - - - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - + + + + - + + + + + + +
+ + + +
-

- Human Connection gGmbH
Bahnhofstraße 11, 73235 Weilheim / - Teck
Germany
-

+ style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;"> +

+ Hallo!

+

Du hast bei uns ein neues Password angefordert – leider haben wir aber keinen + Account mit Deiner E-Mailadresse gefunden. Kann es sein, dass Du mit einer anderen Adresse bei uns + angemeldet bist?

+
+ + + + + +
+ Versuch' + es mit einer anderen E-Mail +
+
- +
+ + +
+

Wenn Du noch keinen Account bei Human Connection hast oder Dein Password gar nicht ändern willst, + kannst Du diese E-Mail einfach ignorieren!

- - - +
+ + + + + + + +
+

Ansonsten hilft Dir unser + Support Team gerne weiter.

+

Bis bald bei Human Connection!

+

– Dein Human Connection Team

+
+

–––––––––––––––––––––––––––––––––––––––––––––––

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index ce98090ad..59ae06c07 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -170,6 +170,8 @@ const permissions = shield( block: isAuthenticated, unblock: isAuthenticated, markAsRead: isAuthenticated, + AddEmailAddress: isAuthenticated, + VerifyEmailAddress: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/models/UnverifiedEmailAddress.js b/backend/src/models/UnverifiedEmailAddress.js new file mode 100644 index 000000000..7b37b9a39 --- /dev/null +++ b/backend/src/models/UnverifiedEmailAddress.js @@ -0,0 +1,12 @@ +module.exports = { + email: { type: 'string', primary: true, lowercase: true, email: true }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + nonce: { type: 'string', token: true }, + belongsTo: { + type: 'relationship', + relationship: 'BELONGS_TO', + target: 'User', + direction: 'out', + eager: true, + }, +} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index a7d3c8252..08362b69f 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -5,6 +5,7 @@ export default { User: require('./User.js'), InvitationCode: require('./InvitationCode.js'), EmailAddress: require('./EmailAddress.js'), + UnverifiedEmailAddress: require('./UnverifiedEmailAddress.js'), SocialMedia: require('./SocialMedia.js'), Post: require('./Post.js'), Comment: require('./Comment.js'), diff --git a/backend/src/schema/resolvers/emails.js b/backend/src/schema/resolvers/emails.js new file mode 100644 index 000000000..ce93a28e9 --- /dev/null +++ b/backend/src/schema/resolvers/emails.js @@ -0,0 +1,92 @@ +import generateNonce from './helpers/generateNonce' +import Resolver from './helpers/Resolver' +import existingEmailAddress from './helpers/existingEmailAddress' +import { UserInputError } from 'apollo-server' +import Validator from 'neode/build/Services/Validator.js' + +export default { + Mutation: { + AddEmailAddress: async (_parent, args, context, _resolveInfo) => { + let response + try { + const { neode } = context + await new Validator(neode, neode.model('UnverifiedEmailAddress'), args) + } catch (e) { + throw new UserInputError('must be a valid email') + } + + // check email does not belong to anybody + await existingEmailAddress(_parent, args, context) + + const nonce = generateNonce() + const { + user: { id: userId }, + } = context + const { email } = args + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async txc => { + const result = await txc.run( + ` + MATCH (user:User {id: $userId}) + MERGE (user)<-[:BELONGS_TO]-(email:UnverifiedEmailAddress {email: $email, nonce: $nonce}) + SET email.createdAt = toString(datetime()) + RETURN email, user + `, + { userId, email, nonce }, + ) + return result.records.map(record => ({ + name: record.get('user').properties.name, + ...record.get('email').properties, + })) + }) + try { + const txResult = await writeTxResultPromise + response = txResult[0] + } finally { + session.close() + } + return response + }, + VerifyEmailAddress: async (_parent, args, context, _resolveInfo) => { + let response + const { + user: { id: userId }, + } = context + const { nonce, email } = args + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async txc => { + const result = await txc.run( + ` + MATCH (user:User {id: $userId})-[:PRIMARY_EMAIL]->(previous:EmailAddress) + MATCH (user)<-[:BELONGS_TO]-(email:UnverifiedEmailAddress {email: $email, nonce: $nonce}) + MERGE (user)-[:PRIMARY_EMAIL]->(email) + SET email:EmailAddress + SET email.verifiedAt = toString(datetime()) + REMOVE email:UnverifiedEmailAddress + DETACH DELETE previous + RETURN email + `, + { userId, email, nonce }, + ) + return result.records.map(record => record.get('email').properties) + }) + try { + const txResult = await writeTxResultPromise + response = txResult[0] + } catch (e) { + if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') + throw new UserInputError('A user account with this email already exists.') + throw new Error(e) + } finally { + session.close() + } + if (!response) throw new UserInputError('Invalid nonce or no email address found.') + return response + }, + }, + EmailAddress: { + ...Resolver('EmailAddress', { + undefinedToNull: ['verifiedAt'], + }), + }, +} diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js new file mode 100644 index 000000000..ea3491d1e --- /dev/null +++ b/backend/src/schema/resolvers/emails.spec.js @@ -0,0 +1,298 @@ +import Factory from '../../seed/factories' +import { gql } from '../../jest/helpers' +import { getDriver, neode as getNeode } from '../../bootstrap/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +const factory = Factory() +const neode = getNeode() + +let mutate +let authenticatedUser +let user +let variables +const driver = getDriver() + +beforeEach(async () => { + variables = {} +}) + +beforeAll(() => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + mutate = createTestClient(server).mutate +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('AddEmailAddress', () => { + const mutation = gql` + mutation($email: String!) { + AddEmailAddress(email: $email) { + email + verifiedAt + createdAt + } + } + ` + beforeEach(() => { + variables = { ...variables, email: 'new-email@example.org' } + }) + + describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + + it('throws AuthorizationError', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { AddEmailAddress: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + user = await factory.create('User', { id: '567', email: 'user@example.org' }) + authenticatedUser = await user.toJson() + }) + + describe('email attribute is not a valid email', () => { + beforeEach(() => { + variables = { ...variables, email: 'foobar' } + }) + + it('throws UserInputError', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { AddEmailAddress: null }, + errors: [{ message: 'must be a valid email' }], + }) + }) + }) + + describe('email attribute is a valid email', () => { + it('creates a new unverified `EmailAddress` node', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { + AddEmailAddress: { + email: 'new-email@example.org', + verifiedAt: null, + createdAt: expect.any(String), + }, + }, + errors: undefined, + }) + }) + + it('connects `UnverifiedEmailAddress` to the authenticated user', async () => { + await mutate({ mutation, variables }) + const result = await neode.cypher(` + MATCH(u:User)-[:PRIMARY_EMAIL]->(:EmailAddress {email: "user@example.org"}) + MATCH(u:User)<-[:BELONGS_TO]-(e:UnverifiedEmailAddress {email: "new-email@example.org"}) + RETURN e + `) + const email = neode.hydrateFirst(result, 'e', neode.model('UnverifiedEmailAddress')) + await expect(email.toJson()).resolves.toMatchObject({ + email: 'new-email@example.org', + nonce: expect.any(String), + }) + }) + + describe('if another `UnverifiedEmailAddress` node already exists with that email', () => { + it('throws no unique constraint violation error', async () => { + await factory.create('UnverifiedEmailAddress', { + createdAt: '2019-09-24T14:00:01.565Z', + email: 'new-email@example.org', + }) + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { + AddEmailAddress: { + email: 'new-email@example.org', + verifiedAt: null, + }, + }, + errors: undefined, + }) + }) + }) + + describe('but if another user owns an `EmailAddress` already with that email', () => { + it('throws UserInputError because of unique constraints', async () => { + await factory.create('User', { email: 'new-email@example.org' }) + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { AddEmailAddress: null }, + errors: [{ message: 'A user account with this email already exists.' }], + }) + }) + }) + }) + }) +}) + +describe('VerifyEmailAddress', () => { + const mutation = gql` + mutation($email: String!, $nonce: String!) { + VerifyEmailAddress(email: $email, nonce: $nonce) { + email + createdAt + verifiedAt + } + } + ` + + beforeEach(() => { + variables = { ...variables, email: 'to-be-verified@example.org', nonce: '123456' } + }) + + describe('unauthenticated', () => { + beforeEach(() => { + authenticatedUser = null + }) + + it('throws AuthorizationError', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { VerifyEmailAddress: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + user = await factory.create('User', { id: '567', email: 'user@example.org' }) + authenticatedUser = await user.toJson() + }) + + describe('if no unverified `EmailAddress` node exists', () => { + it('throws UserInputError', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { VerifyEmailAddress: null }, + errors: [{ message: 'Invalid nonce or no email address found.' }], + }) + }) + }) + + describe('given a `UnverifiedEmailAddress`', () => { + let emailAddress + beforeEach(async () => { + emailAddress = await factory.create('UnverifiedEmailAddress', { + nonce: 'abcdef', + verifiedAt: null, + createdAt: new Date().toISOString(), + email: 'to-be-verified@example.org', + }) + }) + + describe('given invalid nonce', () => { + it('throws UserInputError', async () => { + variables.nonce = 'asdfgh' + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { VerifyEmailAddress: null }, + errors: [{ message: 'Invalid nonce or no email address found.' }], + }) + }) + }) + + describe('given valid nonce for `UnverifiedEmailAddress` node', () => { + beforeEach(() => { + variables = { ...variables, nonce: 'abcdef' } + }) + + describe('but the address does not belong to the authenticated user', () => { + it('throws UserInputError', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { VerifyEmailAddress: null }, + errors: [{ message: 'Invalid nonce or no email address found.' }], + }) + }) + }) + + describe('and the `UnverifiedEmailAddress` belongs to the authenticated user', () => { + beforeEach(async () => { + await emailAddress.relateTo(user, 'belongsTo') + }) + + it('adds `verifiedAt`', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { + VerifyEmailAddress: { + email: 'to-be-verified@example.org', + verifiedAt: expect.any(String), + createdAt: expect.any(String), + }, + }, + errors: undefined, + }) + }) + + it('connects the new `EmailAddress` as PRIMARY', async () => { + await mutate({ mutation, variables }) + const result = await neode.cypher(` + MATCH(u:User {id: "567"})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: "to-be-verified@example.org"}) + RETURN e + `) + const email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email.toJson()).resolves.toMatchObject({ + email: 'to-be-verified@example.org', + }) + }) + + it('removes previous PRIMARY relationship', async () => { + const cypherStatement = ` + MATCH(u:User {id: "567"})-[:PRIMARY_EMAIL]->(e:EmailAddress {email: "user@example.org"}) + RETURN e + ` + let result = await neode.cypher(cypherStatement) + let email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email.toJson()).resolves.toMatchObject({ + email: 'user@example.org', + }) + await mutate({ mutation, variables }) + result = await neode.cypher(cypherStatement) + email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email).toBe(false) + }) + + it('removes previous `EmailAddress` node', async () => { + const cypherStatement = ` + MATCH(u:User {id: "567"})<-[:BELONGS_TO]-(e:EmailAddress {email: "user@example.org"}) + RETURN e + ` + let result = await neode.cypher(cypherStatement) + let email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email.toJson()).resolves.toMatchObject({ + email: 'user@example.org', + }) + await mutate({ mutation, variables }) + result = await neode.cypher(cypherStatement) + email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email).toBe(false) + }) + + describe('Edge case: In the meantime someone created an `EmailAddress` node with the given email', () => { + beforeEach(async () => { + await factory.create('EmailAddress', { email: 'to-be-verified@example.org' }) + }) + + it('throws UserInputError because of unique constraints', async () => { + await expect(mutate({ mutation, variables })).resolves.toMatchObject({ + data: { VerifyEmailAddress: null }, + errors: [{ message: 'A user account with this email already exists.' }], + }) + }) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/helpers/existingEmailAddress.js b/backend/src/schema/resolvers/helpers/existingEmailAddress.js new file mode 100644 index 000000000..007d2de6b --- /dev/null +++ b/backend/src/schema/resolvers/helpers/existingEmailAddress.js @@ -0,0 +1,26 @@ +import { UserInputError } from 'apollo-server' +export default async function alreadyExistingMail(_parent, args, context) { + let { email } = args + email = email.toLowerCase() + const cypher = ` + MATCH (email:EmailAddress {email: $email}) + OPTIONAL MATCH (email)-[:BELONGS_TO]-(user) + RETURN email, user + ` + let transactionRes + const session = context.driver.session() + try { + transactionRes = await session.run(cypher, { email }) + } finally { + session.close() + } + const [result] = transactionRes.records.map(record => { + return { + alreadyExistingEmail: record.get('email').properties, + user: record.get('user') && record.get('user').properties, + } + }) + const { alreadyExistingEmail, user } = result || {} + if (user) throw new UserInputError('A user account with this email already exists.') + return alreadyExistingEmail +} diff --git a/backend/src/schema/resolvers/helpers/generateNonce.js b/backend/src/schema/resolvers/helpers/generateNonce.js new file mode 100644 index 000000000..4dde1df04 --- /dev/null +++ b/backend/src/schema/resolvers/helpers/generateNonce.js @@ -0,0 +1,4 @@ +import uuid from 'uuid/v4' +export default function generateNonce() { + return uuid().substring(0, 6) +} diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index 72e499038..bd62b32c3 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -1,41 +1,16 @@ import { UserInputError } from 'apollo-server' -import uuid from 'uuid/v4' import { neode } from '../../bootstrap/neo4j' import fileUpload from './fileUpload' import encryptPassword from '../../helpers/encryptPassword' +import generateNonce from './helpers/generateNonce' +import existingEmailAddress from './helpers/existingEmailAddress' const instance = neode() -const alreadyExistingMail = async (_parent, args, context) => { - let { email } = args - email = email.toLowerCase() - const cypher = ` - MATCH (email:EmailAddress {email: $email}) - OPTIONAL MATCH (email)-[:PRIMARY_EMAIL]-(user) - RETURN email, user - ` - let transactionRes - const session = context.driver.session() - try { - transactionRes = await session.run(cypher, { email }) - } finally { - session.close() - } - const [result] = transactionRes.records.map(record => { - return { - alreadyExistingEmail: record.get('email').properties, - user: record.get('user') && record.get('user').properties, - } - }) - const { alreadyExistingEmail, user } = result || {} - if (user) throw new UserInputError('User account with this email already exists.') - return alreadyExistingEmail -} - export default { Mutation: { CreateInvitationCode: async (_parent, args, context, _resolveInfo) => { - args.token = uuid().substring(0, 6) + args.token = generateNonce() const { user: { id: userId }, } = context @@ -54,9 +29,9 @@ export default { return response }, Signup: async (_parent, args, context) => { - const nonce = uuid().substring(0, 6) + const nonce = generateNonce() args.nonce = nonce - let emailAddress = await alreadyExistingMail(_parent, args, context) + let emailAddress = await existingEmailAddress(_parent, args, context) if (emailAddress) return emailAddress try { emailAddress = await instance.create('EmailAddress', args) @@ -67,9 +42,9 @@ export default { }, SignupByInvitation: async (_parent, args, context) => { const { token } = args - const nonce = uuid().substring(0, 6) + const nonce = generateNonce() args.nonce = nonce - let emailAddress = await alreadyExistingMail(_parent, args, context) + let emailAddress = await existingEmailAddress(_parent, args, context) if (emailAddress) return emailAddress try { const result = await instance.cypher( diff --git a/backend/src/schema/resolvers/registration.spec.js b/backend/src/schema/resolvers/registration.spec.js index 81326004d..0f3af5a8d 100644 --- a/backend/src/schema/resolvers/registration.spec.js +++ b/backend/src/schema/resolvers/registration.spec.js @@ -257,7 +257,7 @@ describe('SignupByInvitation', () => { it('throws unique violation error', async () => { await expect(mutate({ mutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'User account with this email already exists.' }], + errors: [{ message: 'A user account with this email already exists.' }], }) }) }) @@ -307,6 +307,7 @@ describe('Signup', () => { it('is allowed to signup users by email', async () => { await expect(mutate({ mutation, variables })).resolves.toMatchObject({ data: { Signup: { email: 'someuser@example.org' } }, + errors: undefined, }) }) @@ -342,7 +343,7 @@ describe('Signup', () => { it('throws UserInputError error because of unique constraint violation', async () => { await expect(mutate({ mutation, variables })).resolves.toMatchObject({ - errors: [{ message: 'User account with this email already exists.' }], + errors: [{ message: 'A user account with this email already exists.' }], }) }) }) @@ -351,6 +352,7 @@ describe('Signup', () => { it('resolves with the already existing email', async () => { await expect(mutate({ mutation, variables })).resolves.toMatchObject({ data: { Signup: { email: 'someuser@example.org' } }, + errors: undefined, }) }) @@ -359,6 +361,7 @@ describe('Signup', () => { await expect(neode.all('EmailAddress')).resolves.toHaveLength(2) await expect(mutate({ mutation, variables })).resolves.toMatchObject({ data: { Signup: { email: 'someuser@example.org' } }, + errors: undefined, }) await expect(neode.all('EmailAddress')).resolves.toHaveLength(2) }) diff --git a/backend/src/schema/types/type/EmailAddress.gql b/backend/src/schema/types/type/EmailAddress.gql index 4bf8ff724..1748139bd 100644 --- a/backend/src/schema/types/type/EmailAddress.gql +++ b/backend/src/schema/types/type/EmailAddress.gql @@ -20,4 +20,9 @@ type Mutation { about: String termsAndConditionsAgreedVersion: String! ): User + AddEmailAddress(email: String!): EmailAddress + VerifyEmailAddress( + nonce: String! + email: String! + ): EmailAddress } diff --git a/backend/src/seed/factories/emailAddresses.js b/backend/src/seed/factories/emailAddresses.js index 0212dca53..41b1fe96c 100644 --- a/backend/src/seed/factories/emailAddresses.js +++ b/backend/src/seed/factories/emailAddresses.js @@ -1,16 +1,21 @@ import faker from 'faker' +export function defaults({ args }) { + const defaults = { + email: faker.internet.email(), + verifiedAt: new Date().toISOString(), + } + args = { + ...defaults, + ...args, + } + return args +} + export default function create() { return { factory: async ({ args, neodeInstance }) => { - const defaults = { - email: faker.internet.email(), - verifiedAt: new Date().toISOString(), - } - args = { - ...defaults, - ...args, - } + args = defaults({ args }) return neodeInstance.create('EmailAddress', args) }, } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index ab09b438d..5fd6f2d82 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -9,6 +9,7 @@ import createTag from './tags.js' import createSocialMedia from './socialMedia.js' import createLocation from './locations.js' import createEmailAddress from './emailAddresses.js' +import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js' export const seedServerHost = 'http://127.0.0.1:4001' @@ -32,6 +33,7 @@ const factories = { SocialMedia: createSocialMedia, Location: createLocation, EmailAddress: createEmailAddress, + UnverifiedEmailAddress: createUnverifiedEmailAddresss, } export const cleanDatabase = async (options = {}) => { diff --git a/backend/src/seed/factories/unverifiedEmailAddresses.js b/backend/src/seed/factories/unverifiedEmailAddresses.js new file mode 100644 index 000000000..94e32af6e --- /dev/null +++ b/backend/src/seed/factories/unverifiedEmailAddresses.js @@ -0,0 +1,10 @@ +import { defaults } from './emailAddresses.js' + +export default function create() { + return { + factory: async ({ args, neodeInstance }) => { + args = defaults({ args }) + return neodeInstance.create('UnverifiedEmailAddress', args) + }, + } +} diff --git a/webapp/components/Registration/Signup.spec.js b/webapp/components/Registration/Signup.spec.js index afcf94c37..ed78d47a2 100644 --- a/webapp/components/Registration/Signup.spec.js +++ b/webapp/components/Registration/Signup.spec.js @@ -115,7 +115,7 @@ describe('Signup', () => { mocks.$apollo.mutate = jest .fn() .mockRejectedValue( - new Error('UserInputError: User account with this email already exists.'), + new Error('UserInputError: A user account with this email already exists.'), ) }) diff --git a/webapp/components/Registration/Signup.vue b/webapp/components/Registration/Signup.vue index dcf4f0e88..36a43f180 100644 --- a/webapp/components/Registration/Signup.vue +++ b/webapp/components/Registration/Signup.vue @@ -128,7 +128,7 @@ export default { } catch (err) { const { message } = err const mapping = { - 'User account with this email already exists': 'email-exists', + 'A user account with this email already exists': 'email-exists', 'Invitation code already used or does not exist': 'invalid-invitation-token', } for (const [pattern, key] of Object.entries(mapping)) { diff --git a/webapp/graphql/EmailAddress.js b/webapp/graphql/EmailAddress.js new file mode 100644 index 000000000..675ec6bed --- /dev/null +++ b/webapp/graphql/EmailAddress.js @@ -0,0 +1,20 @@ +import gql from 'graphql-tag' + +export const AddEmailAddressMutation = gql` + mutation($email: String!) { + AddEmailAddress(email: $email) { + email + createdAt + } + } +` + +export const VerifyEmailAddressMutation = gql` + mutation($email: String!, $nonce: String!) { + VerifyEmailAddress(email: $email, nonce: $nonce) { + email + verifiedAt + createdAt + } + } +` diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 7b7bc3ec1..c547e063f 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -158,6 +158,27 @@ "labelBio": "Über dich", "success": "Deine Daten wurden erfolgreich aktualisiert!" }, + "email": { + "validation": { + "same-email": "Das ist deine aktuelle E-Mail Addresse" + }, + "name": "Deine E-Mail", + "labelEmail": "E-Mail Adresse ändern", + "labelNewEmail": "Neue E-Mail Adresse", + "labelNonce": "Bestätigungscode eingeben", + "success": "Eine neue E-Mail Addresse wurde registriert.", + "submitted": "Eine E-Mail zur Bestätigung deiner Adresse wurde an {email} gesendet.", + "change-successful": "Deine E-Mail Adresse wurde erfolgreich geändert.", + "verification-error": { + "message": "Deine E-Mail Adresse konnte nicht verifiziert werden.", + "support": "Wenn das Problem weiterhin besteht, kontaktiere uns gerne per E-Mail an", + "explanation": "Das kann verschiedene Ursachen haben:", + "reason": { + "invalid-nonce": "Ist der Bestätigungscode falsch?", + "no-email-request": "Bist du dir sicher, dass du eine Änderung deiner E-Mail Adresse angefragt hattest?" + } + } + }, "validation": { "slug": { "regex": "Es sind nur Kleinbuchstaben, Zahlen, Unterstriche oder Bindestriche erlaubt.", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 8204d4741..48fd36c0d 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -2,7 +2,7 @@ "maintenance": { "title": "Human Connection is under maintenance", "explanation": "At the moment we are doing some scheduled maintenance, please try again later.", - "questions": "Any Questions or concerns, send an email to" + "questions": "Any Questions or concerns, send an email to" }, "index": { "no-results": "No contributions found.", @@ -159,6 +159,27 @@ "labelBio": "About You", "success": "Your data was successfully updated!" }, + "email": { + "validation": { + "same-email": "This is your current email address" + }, + "name": "Your email", + "labelEmail": "Change your email address", + "labelNewEmail": "New email Address", + "labelNonce": "Enter your code", + "success": "A new email address has been registered.", + "submitted": "An email to verify your address has been sent to {email}.", + "change-successful": "Your email address has been changed successfully.", + "verification-error": { + "message": "Your email could not be changed.", + "explanation": "This can have different causes:", + "reason": { + "invalid-nonce": "Is the confirmation code invalid?", + "no-email-request": "Are you certain that you requested a change of your email address?" + }, + "support": "If the problem persists, please contact us by email at" + } + }, "validation": { "slug": { "regex": "Allowed characters are only lowercase letters, numbers, underscores and hyphens.", @@ -254,7 +275,7 @@ "users": { "name": "Users", "form": { - "placeholder": "E-Mail, name or description" + "placeholder": "email, name or description" }, "table": { "columns": { diff --git a/webapp/pages/settings.vue b/webapp/pages/settings.vue index 67493c333..5795792f9 100644 --- a/webapp/pages/settings.vue +++ b/webapp/pages/settings.vue @@ -23,6 +23,10 @@ export default { name: this.$t('settings.data.name'), path: `/settings`, }, + { + name: this.$t('settings.email.name'), + path: `/settings/my-email-address`, + }, { name: this.$t('settings.security.name'), path: `/settings/security`, diff --git a/webapp/pages/settings/index.vue b/webapp/pages/settings/index.vue index d32d9a91b..934b10558 100644 --- a/webapp/pages/settings/index.vue +++ b/webapp/pages/settings/index.vue @@ -31,14 +31,7 @@ :placeholder="$t('settings.data.labelBio')" /> diff --git a/webapp/pages/settings/my-email-address/enter-nonce.spec.js b/webapp/pages/settings/my-email-address/enter-nonce.spec.js new file mode 100644 index 000000000..abb6a71bf --- /dev/null +++ b/webapp/pages/settings/my-email-address/enter-nonce.spec.js @@ -0,0 +1,53 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import EnterNoncePage from './enter-nonce.vue' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('EnterNoncePage', () => { + let mocks + let wrapper + + beforeEach(() => { + wrapper = null + mocks = { + $t: jest.fn(t => t), + $route: { + query: {}, + }, + $router: { + replace: jest.fn(), + }, + } + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(EnterNoncePage, { + mocks, + localVue, + }) + } + + describe('form', () => { + describe('submit', () => { + it('renders form errors', () => { + wrapper = Wrapper() + wrapper.find('form').trigger('submit') + expect(mocks.$router.replace).not.toHaveBeenCalled() + }) + + describe('entering a nonce', () => { + it('redirects to my-email-address/verify', () => { + wrapper = Wrapper() + wrapper.find('#nonce').setValue('foobar') + wrapper.find('form').trigger('submit') + expect(mocks.$router.replace).toHaveBeenCalled() + }) + }) + }) + }) + }) +}) diff --git a/webapp/pages/settings/my-email-address/enter-nonce.vue b/webapp/pages/settings/my-email-address/enter-nonce.vue new file mode 100644 index 000000000..85755953a --- /dev/null +++ b/webapp/pages/settings/my-email-address/enter-nonce.vue @@ -0,0 +1,59 @@ + + + diff --git a/webapp/pages/settings/my-email-address/index.spec.js b/webapp/pages/settings/my-email-address/index.spec.js new file mode 100644 index 000000000..8bf1bb986 --- /dev/null +++ b/webapp/pages/settings/my-email-address/index.spec.js @@ -0,0 +1,116 @@ +import { config, mount, createLocalVue } from '@vue/test-utils' +import EmailSettingsIndexPage from './index.vue' +import Vuex from 'vuex' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Vuex) +localVue.use(Styleguide) + +config.stubs['sweetalert-icon'] = '' + +describe('EmailSettingsIndexPage', () => { + let store + let mocks + let wrapper + + beforeEach(() => { + wrapper = null + store = new Vuex.Store({ + getters: { + 'auth/user': () => { + return { id: 'u23', email: 'some-mail@example.org' } + }, + }, + }) + mocks = { + $t: jest.fn(t => t), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $apollo: { + mutate: jest.fn().mockResolvedValue(), + }, + $router: { + push: jest.fn(), + }, + } + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(EmailSettingsIndexPage, { + store, + mocks, + localVue, + }) + } + + describe('form', () => { + describe('submit', () => { + beforeEach(jest.useFakeTimers) + + describe('email unchanged', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.find('form').trigger('submit') + }) + + it('displays form errors', () => { + expect(wrapper.text()).not.toContain('settings.email.submitted') + expect(wrapper.text()).toContain('settings.email.validation.same-email') + }) + + it('does not call $apollo.mutate', () => { + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + }) + + describe('enter another email', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.find('#email').setValue('yet-another-email@example.org') + wrapper.find('form').trigger('submit') + }) + + it('calls $apollo.mutate', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalled() + }) + + it('no form errors', () => { + expect(wrapper.text()).not.toContain('settings.email.validation.same-email') + expect(wrapper.text()).toContain('settings.email.submitted') + }) + + describe('after timeout', () => { + beforeEach(jest.runAllTimers) + + it('redirects to `my-email-address/enter-nonce`', () => { + expect(mocks.$router.push).toHaveBeenCalledWith({ + path: 'my-email-address/enter-nonce', + query: { email: 'yet-another-email@example.org' }, + }) + }) + }) + }) + + describe('if backend responds with unique constraint violation', () => { + beforeEach(() => { + mocks.$apollo.mutate = jest.fn().mockRejectedValue({ + message: 'User account already exists', + }) + wrapper = Wrapper() + wrapper.find('#email').setValue('already-taken@example.org') + wrapper.find('form').trigger('submit') + }) + + it('translates error message', () => { + expect(wrapper.text()).toContain('registration.signup.form.errors.email-exists') + }) + }) + }) + }) + }) +}) diff --git a/webapp/pages/settings/my-email-address/index.vue b/webapp/pages/settings/my-email-address/index.vue new file mode 100644 index 000000000..fcfeb941d --- /dev/null +++ b/webapp/pages/settings/my-email-address/index.vue @@ -0,0 +1,113 @@ + + + diff --git a/webapp/pages/settings/my-email-address/verify.spec.js b/webapp/pages/settings/my-email-address/verify.spec.js new file mode 100644 index 000000000..d0f098bb0 --- /dev/null +++ b/webapp/pages/settings/my-email-address/verify.spec.js @@ -0,0 +1,164 @@ +import { config, mount, createLocalVue } from '@vue/test-utils' +import EmailVerifyPage from './verify.vue' +import Vuex from 'vuex' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Vuex) +localVue.use(Styleguide) + +config.stubs['client-only'] = '' +config.stubs['sweetalert-icon'] = '' + +describe('EmailVerifyPage', () => { + let store + let mocks + let wrapper + let setUser + + beforeEach(() => { + setUser = jest.fn() + wrapper = null + store = new Vuex.Store({ + getters: { + 'auth/user': () => { + return { id: 'u23', email: 'some-mail@example.org' } + }, + }, + mutations: { + 'auth/SET_USER': setUser, + }, + }) + mocks = { + $t: jest.fn(t => t), + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $router: { + replace: jest.fn(), + }, + store, + } + }) + + describe('asyncData', () => { + const asyncDataAction = () => { + const context = { + store: mocks.store, + query: {}, + app: { + apolloProvider: { + defaultClient: mocks.$apollo, + }, + }, + } + return EmailVerifyPage.asyncData(context) + } + + describe('backend sends successful response', () => { + beforeEach(() => { + mocks = { + ...mocks, + $apollo: { + mutate: jest.fn().mockResolvedValue({ + data: { + VerifyEmailAddress: { + email: 'verified-email@example.org', + }, + }, + }), + }, + } + }) + + it('sets `success` to true', async () => { + await expect(asyncDataAction()).resolves.toEqual({ + success: true, + }) + }) + + it("updates current user's email", async () => { + await asyncDataAction() + expect(setUser).toHaveBeenCalledWith({}, { id: 'u23', email: 'verified-email@example.org' }) + }) + }) + + describe('backend sends unsuccessful response', () => { + beforeEach(() => { + mocks = { + ...mocks, + $apollo: { + mutate: jest.fn().mockRejectedValue({ + data: { VerifyEmailAddress: null }, + errors: [{ message: 'User account already exists with that email' }], + }), + }, + } + }) + + it('sets `success` to false', async () => { + await expect(asyncDataAction()).resolves.toEqual({ + success: false, + }) + }) + + it('does not updates current user', async () => { + await asyncDataAction() + expect(setUser).not.toHaveBeenCalled() + }) + }) + }) + + describe('mount', () => { + beforeEach(jest.useFakeTimers) + const Wrapper = () => { + return mount(EmailVerifyPage, { + store, + mocks, + localVue, + }) + } + + describe('given successful verification', () => { + beforeEach(() => { + mocks = { ...mocks, success: true } + wrapper = Wrapper() + }) + + it('shows success message', () => { + expect(wrapper.text()).toContain('settings.email.change-successful') + }) + + describe('after timeout', () => { + beforeEach(jest.runAllTimers) + + it('redirects to email settings page', () => { + expect(mocks.$router.replace).toHaveBeenCalledWith({ + name: 'settings-my-email-address', + }) + }) + }) + }) + + describe('given unsuccessful verification', () => { + beforeEach(() => { + mocks = { ...mocks, success: false } + wrapper = Wrapper() + }) + + it('shows success message', () => { + expect(wrapper.text()).toContain('settings.email.verification-error') + }) + + describe('after timeout', () => { + beforeEach(jest.runAllTimers) + + it('does not redirect', () => { + expect(mocks.$router.replace).not.toHaveBeenCalledWith() + }) + }) + }) + }) +}) diff --git a/webapp/pages/settings/my-email-address/verify.vue b/webapp/pages/settings/my-email-address/verify.vue new file mode 100644 index 000000000..21e4fd775 --- /dev/null +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -0,0 +1,99 @@ + + + + +