From 8a05de5b2d907a1c8f2994c2232d12294908786f Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 14:14:57 +0200 Subject: [PATCH 01/30] Sketch backend test to change Email Address --- backend/src/schema/resolvers/emails.spec.js | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 backend/src/schema/resolvers/emails.spec.js diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js new file mode 100644 index 000000000..c947fa966 --- /dev/null +++ b/backend/src/schema/resolvers/emails.spec.js @@ -0,0 +1,36 @@ +describe('AddEmailAddress', () => { + it.todo('throws AuthorizationError') + describe('authenticated', () => { + it.todo('creates a new unverified `EmailAddress` node') + it.todo('connects EmailAddress to the authenticated user') + + describe('even if an unverified `EmailAddress` already exists with that email', () =>{ + it.todo('creates a new unverified `EmailAddress` node') + }) + }) +}) + +describe('VerifyEmailAddress', () => { + it.todo('throws AuthorizationError') + describe('authenticated', () => { + describe('if no unverified `EmailAddress` node exists', () => { + it.todo('throws UserInputError') + }) + + describe('given invalid nonce', () => { + it.todo('throws UserInputError') + }) + + describe('given valid nonce for unverified `EmailAddress` node', () => { + describe('but the address does not belong to the authenticated user', () => { + it.todo('throws UserInputError') + }) + + describe('and the `EmailAddress` belongs to the authenticated user', () => { + it.todo('verifies the `EmailAddress`') + it.todo('connects the new `EmailAddress` as PRIMARY') + it.todo('removes previous PRIMARY relationship') + }) + }) + }) +}) From 3b6cd55c0f72bc1913a001904ac3e6261ef1363b Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 14:50:16 +0200 Subject: [PATCH 02/30] Implement unauthenticated part --- backend/src/schema/resolvers/emails.spec.js | 94 ++++++++++++++++++- .../src/schema/types/type/EmailAddress.gql | 5 + 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js index c947fa966..dfd6c8752 100644 --- a/backend/src/schema/resolvers/emails.spec.js +++ b/backend/src/schema/resolvers/emails.spec.js @@ -1,5 +1,66 @@ +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', () => { - it.todo('throws AuthorizationError') + 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', () => { it.todo('creates a new unverified `EmailAddress` node') it.todo('connects EmailAddress to the authenticated user') @@ -7,11 +68,40 @@ describe('AddEmailAddress', () => { describe('even if an unverified `EmailAddress` already exists with that email', () =>{ it.todo('creates a new unverified `EmailAddress` node') }) + + describe('but if a verified `EmailAddress` already exists with that email', () =>{ + it.todo('throws UserInputError because of unique constraints') + }) }) }) describe('VerifyEmailAddress', () => { - it.todo('throws AuthorizationError') + const mutation = gql` + mutation($email: String!, $nonce: String!) { + VerifyEmailAddress(email: $email, nonce: $nonce){ + email + createdAt + } + } + ` + + 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', () => { describe('if no unverified `EmailAddress` node exists', () => { it.todo('throws UserInputError') 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 } From 73d5abd7245894a181f03a2c2b6a8b121efba3e1 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 15:58:12 +0200 Subject: [PATCH 03/30] Implement AddEmail resolver --- backend/src/helpers/generateNonce.js | 4 ++ .../src/middleware/permissionsMiddleware.js | 2 + backend/src/schema/resolvers/emails.js | 41 ++++++++++++++++ backend/src/schema/resolvers/emails.spec.js | 47 +++++++++++++++---- 4 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 backend/src/helpers/generateNonce.js create mode 100644 backend/src/schema/resolvers/emails.js diff --git a/backend/src/helpers/generateNonce.js b/backend/src/helpers/generateNonce.js new file mode 100644 index 000000000..4dde1df04 --- /dev/null +++ b/backend/src/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/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/schema/resolvers/emails.js b/backend/src/schema/resolvers/emails.js new file mode 100644 index 000000000..281c7d8ce --- /dev/null +++ b/backend/src/schema/resolvers/emails.js @@ -0,0 +1,41 @@ +import generateNonce from '../../helpers/generateNonce' +import Resolver from './helpers/Resolver' + +export default { + Mutation: { + AddEmailAddress: async (_parent, args, context, _resolveInfo) => { + let response + 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:EmailAddress {email: $email, nonce: $nonce}) + SET email.createdAt = toString(datetime()) + RETURN email + `, + { userId, email, nonce }, + ) + return result.records.map(record => record.get('email').properties) + }) + try { + const txResult = await writeTxResultPromise + response = txResult[0] + } finally { + session.close() + } + return response + }, + VerifyEmailAddress: async (_parent, args, context, _resolveInfo) => {}, + }, + EmailAddress: { + ...Resolver('EmailAddress', { + undefinedToNull: ['verifiedAt'], + }), + }, +} diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js index dfd6c8752..ec9e831e3 100644 --- a/backend/src/schema/resolvers/emails.spec.js +++ b/backend/src/schema/resolvers/emails.spec.js @@ -37,7 +37,7 @@ afterEach(async () => { describe('AddEmailAddress', () => { const mutation = gql` mutation($email: String!) { - AddEmailAddress(email: $email){ + AddEmailAddress(email: $email) { email verifiedAt createdAt @@ -62,14 +62,43 @@ describe('AddEmailAddress', () => { }) describe('authenticated', () => { - it.todo('creates a new unverified `EmailAddress` node') - it.todo('connects EmailAddress to the authenticated user') - - describe('even if an unverified `EmailAddress` already exists with that email', () =>{ - it.todo('creates a new unverified `EmailAddress` node') + beforeEach(async () => { + user = await factory.create('User', { email: 'user@example.org' }) + authenticatedUser = await user.toJson() }) - describe('but if a verified `EmailAddress` already exists with that 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 `EmailAddress` to the authenticated user', async () => { + await mutate({ mutation, variables }) + const result = await neode.cypher(` + MATCH(u:User)-[:PRIMARY_EMAIL]->(p:EmailAddress {email: "user@example.org"}) + MATCH(u:User)<-[:BELONGS_TO]-(e:EmailAddress {email: "new-email@example.org"}) + RETURN e + `) + const email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email.toJson()).resolves.toMatchObject({ + email: 'new-email@example.org', + nonce: expect.any(String), + }) + }) + + describe('if a lone `EmailAddress` node already exists with that email', () => { + it.todo('returns this `EmailAddress` node') + }) + + describe('but if another user owns an `EmailAddress` already with that email', () => { it.todo('throws UserInputError because of unique constraints') }) }) @@ -78,7 +107,7 @@ describe('AddEmailAddress', () => { describe('VerifyEmailAddress', () => { const mutation = gql` mutation($email: String!, $nonce: String!) { - VerifyEmailAddress(email: $email, nonce: $nonce){ + VerifyEmailAddress(email: $email, nonce: $nonce) { email createdAt } @@ -117,7 +146,7 @@ describe('VerifyEmailAddress', () => { }) describe('and the `EmailAddress` belongs to the authenticated user', () => { - it.todo('verifies the `EmailAddress`') + it.todo('adds `verifiedAt`') it.todo('connects the new `EmailAddress` as PRIMARY') it.todo('removes previous PRIMARY relationship') }) From 8c13234af98603a37e2830b668faed0e36ee1157 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 16:20:27 +0200 Subject: [PATCH 04/30] Handle edge case It might be that people try to register email addresses that they don't own. Then if the actual owner tries to add this email address, she should not get a unique constraint violation. Instead the email will be re-used. Is this a security issue? Because we re-use the nonce? :thinking: --- backend/src/schema/resolvers/emails.js | 7 +++- backend/src/schema/resolvers/emails.spec.js | 26 ++++++++++++- .../resolvers/helpers/existingEmailAddress.js | 26 +++++++++++++ .../resolvers}/helpers/generateNonce.js | 0 backend/src/schema/resolvers/registration.js | 39 ++++--------------- .../src/schema/resolvers/registration.spec.js | 7 +++- webapp/components/Registration/Signup.spec.js | 2 +- webapp/components/Registration/Signup.vue | 2 +- 8 files changed, 69 insertions(+), 40 deletions(-) create mode 100644 backend/src/schema/resolvers/helpers/existingEmailAddress.js rename backend/src/{ => schema/resolvers}/helpers/generateNonce.js (100%) diff --git a/backend/src/schema/resolvers/emails.js b/backend/src/schema/resolvers/emails.js index 281c7d8ce..664df2803 100644 --- a/backend/src/schema/resolvers/emails.js +++ b/backend/src/schema/resolvers/emails.js @@ -1,10 +1,13 @@ -import generateNonce from '../../helpers/generateNonce' +import generateNonce from './helpers/generateNonce' import Resolver from './helpers/Resolver' +import existingEmailAddress from './helpers/existingEmailAddress' export default { Mutation: { AddEmailAddress: async (_parent, args, context, _resolveInfo) => { - let response + let response = await existingEmailAddress(_parent, args, context) + if (response) return response + const nonce = generateNonce() const { user: { id: userId }, diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js index ec9e831e3..5029622ae 100644 --- a/backend/src/schema/resolvers/emails.spec.js +++ b/backend/src/schema/resolvers/emails.spec.js @@ -95,11 +95,33 @@ describe('AddEmailAddress', () => { }) describe('if a lone `EmailAddress` node already exists with that email', () => { - it.todo('returns this `EmailAddress` node') + it('returns this `EmailAddress` node', async () => { + await factory.create('EmailAddress', { + verifiedAt: null, + 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, + createdAt: '2019-09-24T14:00:01.565Z', // this is to make sure it's the one above + }, + }, + errors: undefined, + }) + }) }) describe('but if another user owns an `EmailAddress` already with that email', () => { - it.todo('throws UserInputError because of unique constraints') + 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.' }], + }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/helpers/existingEmailAddress.js b/backend/src/schema/resolvers/helpers/existingEmailAddress.js new file mode 100644 index 000000000..d05122df1 --- /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)-[: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('A user account with this email already exists.') + return alreadyExistingEmail +} diff --git a/backend/src/helpers/generateNonce.js b/backend/src/schema/resolvers/helpers/generateNonce.js similarity index 100% rename from backend/src/helpers/generateNonce.js rename to backend/src/schema/resolvers/helpers/generateNonce.js 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/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)) { From e51124f316414641405eda8d1591c6df367adebe Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 17:09:15 +0200 Subject: [PATCH 05/30] Resolvers for EmailAddress implemented --- backend/src/schema/resolvers/emails.js | 32 +++- backend/src/schema/resolvers/emails.spec.js | 190 ++++++++++++++------ 2 files changed, 169 insertions(+), 53 deletions(-) diff --git a/backend/src/schema/resolvers/emails.js b/backend/src/schema/resolvers/emails.js index 664df2803..d2f76ba39 100644 --- a/backend/src/schema/resolvers/emails.js +++ b/backend/src/schema/resolvers/emails.js @@ -1,6 +1,7 @@ import generateNonce from './helpers/generateNonce' import Resolver from './helpers/Resolver' import existingEmailAddress from './helpers/existingEmailAddress' +import { UserInputError } from 'apollo-server' export default { Mutation: { @@ -34,7 +35,36 @@ export default { } return response }, - VerifyEmailAddress: async (_parent, args, context, _resolveInfo) => {}, + 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})-[previous:PRIMARY_EMAIL]->(:EmailAddress) + MATCH (user)<-[:BELONGS_TO]-(email:EmailAddress {email: $email, nonce: $nonce}) + MERGE (user)-[:PRIMARY_EMAIL]->(email) + SET email.verifiedAt = toString(datetime()) + DELETE previous + RETURN email + `, + { userId, email, nonce }, + ) + return result.records.map(record => record.get('email').properties) + }) + try { + const txResult = await writeTxResultPromise + response = txResult[0] + } finally { + session.close() + } + if (!response) throw new UserInputError('Invalid nonce or no email address found.') + return response + }, }, EmailAddress: { ...Resolver('EmailAddress', { diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js index 5029622ae..6ec66ca65 100644 --- a/backend/src/schema/resolvers/emails.spec.js +++ b/backend/src/schema/resolvers/emails.spec.js @@ -67,59 +67,65 @@ describe('AddEmailAddress', () => { authenticatedUser = await user.toJson() }) - 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, - }) + describe('email attribute is not a valid email', () => { + it.todo('throws UserInputError') }) - it('connects `EmailAddress` to the authenticated user', async () => { - await mutate({ mutation, variables }) - const result = await neode.cypher(` - MATCH(u:User)-[:PRIMARY_EMAIL]->(p:EmailAddress {email: "user@example.org"}) - MATCH(u:User)<-[:BELONGS_TO]-(e:EmailAddress {email: "new-email@example.org"}) - RETURN e - `) - const email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) - await expect(email.toJson()).resolves.toMatchObject({ - email: 'new-email@example.org', - nonce: expect.any(String), - }) - }) - - describe('if a lone `EmailAddress` node already exists with that email', () => { - it('returns this `EmailAddress` node', async () => { - await factory.create('EmailAddress', { - verifiedAt: null, - createdAt: '2019-09-24T14:00:01.565Z', - email: 'new-email@example.org', - }) + 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: '2019-09-24T14:00:01.565Z', // this is to make sure it's the one above + createdAt: expect.any(String), }, }, 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.' }], + it('connects `EmailAddress` 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:EmailAddress {email: "new-email@example.org"}) + RETURN e + `) + const email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + await expect(email.toJson()).resolves.toMatchObject({ + email: 'new-email@example.org', + nonce: expect.any(String), + }) + }) + + describe('if a lone `EmailAddress` node already exists with that email', () => { + it('returns this `EmailAddress` node', async () => { + await factory.create('EmailAddress', { + verifiedAt: null, + 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, + createdAt: '2019-09-24T14:00:01.565Z', // this is to make sure it's the one above + }, + }, + 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.' }], + }) }) }) }) @@ -132,6 +138,7 @@ describe('VerifyEmailAddress', () => { VerifyEmailAddress(email: $email, nonce: $nonce) { email createdAt + verifiedAt } } ` @@ -154,23 +161,102 @@ describe('VerifyEmailAddress', () => { }) describe('authenticated', () => { + beforeEach(async () => { + user = await factory.create('User', { email: 'user@example.org' }) + authenticatedUser = await user.toJson() + }) + describe('if no unverified `EmailAddress` node exists', () => { - it.todo('throws UserInputError') + 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 invalid nonce', () => { - it.todo('throws UserInputError') - }) - - describe('given valid nonce for unverified `EmailAddress` node', () => { - describe('but the address does not belong to the authenticated user', () => { - it.todo('throws UserInputError') + describe('given an unverified `EmailAddress`', () => { + let emailAddress + beforeEach(async () => { + emailAddress = await factory.create('EmailAddress', { + nonce: 'abcdef', + verifiedAt: null, + createdAt: new Date().toISOString(), + email: 'to-be-verified@example.org', + }) }) - describe('and the `EmailAddress` belongs to the authenticated user', () => { - it.todo('adds `verifiedAt`') - it.todo('connects the new `EmailAddress` as PRIMARY') - it.todo('removes previous PRIMARY relationship') + 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 unverified `EmailAddress` 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 `EmailAddress` 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)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: "to-be-verified@example.org"}) + MATCH(u:User)<-[:BELONGS_TO]-(:EmailAddress {email: "user@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)-[: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) + }) + }) }) }) }) From 80ce0799206e31344448777e1f2a7387e0b9f192 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 18:23:56 +0200 Subject: [PATCH 06/30] Implement first page to change email address --- webapp/graphql/EmailAddress.js | 10 +++ webapp/locales/de.json | 6 ++ webapp/locales/en.json | 6 ++ webapp/pages/settings.vue | 4 + .../pages/settings/my-email-address/index.vue | 80 +++++++++++++++++++ .../verify-email-address-change.vue | 0 6 files changed, 106 insertions(+) create mode 100644 webapp/graphql/EmailAddress.js create mode 100644 webapp/pages/settings/my-email-address/index.vue create mode 100644 webapp/pages/settings/my-email-address/verify-email-address-change.vue diff --git a/webapp/graphql/EmailAddress.js b/webapp/graphql/EmailAddress.js new file mode 100644 index 000000000..2385440bb --- /dev/null +++ b/webapp/graphql/EmailAddress.js @@ -0,0 +1,10 @@ +import gql from 'graphql-tag' + +export const AddEmailAddressMutation = gql` + mutation($email: String!) { + AddEmailAddress(email: $email) { + email + createdAt + } + } +` diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 7b7bc3ec1..82dd00c74 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -158,6 +158,12 @@ "labelBio": "Über dich", "success": "Deine Daten wurden erfolgreich aktualisiert!" }, + "email": { + "name": "Deine E-Mail", + "labelEmail": "E-Mail Adresse ändern", + "success": "Eine neue E-Mail Addresse wurde registriert.", + "submitted": "Eine E-Mail zur Bestätigung deiner Adresse wurde an {email} gesendet." + }, "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..7d806debf 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -159,6 +159,12 @@ "labelBio": "About You", "success": "Your data was successfully updated!" }, + "email": { + "name": "Your E-Mail", + "labelEmail": "Change your E-Mail address", + "success": "A new E-Mail address has been registered.", + "submitted": "An email to verify your address has been sent to {email}." + }, "validation": { "slug": { "regex": "Allowed characters are only lowercase letters, numbers, underscores and hyphens.", 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/my-email-address/index.vue b/webapp/pages/settings/my-email-address/index.vue new file mode 100644 index 000000000..87a59136b --- /dev/null +++ b/webapp/pages/settings/my-email-address/index.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/webapp/pages/settings/my-email-address/verify-email-address-change.vue b/webapp/pages/settings/my-email-address/verify-email-address-change.vue new file mode 100644 index 000000000..e69de29bb From 0592f685f6c6575fe085d80bd7a4a55dda095769 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 19:20:18 +0200 Subject: [PATCH 07/30] Basic email change works --- webapp/graphql/EmailAddress.js | 10 ++ webapp/locales/de.json | 5 +- webapp/locales/en.json | 5 +- .../pages/settings/my-email-address/index.vue | 7 ++ .../verify-email-address-change.vue | 97 +++++++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/webapp/graphql/EmailAddress.js b/webapp/graphql/EmailAddress.js index 2385440bb..675ec6bed 100644 --- a/webapp/graphql/EmailAddress.js +++ b/webapp/graphql/EmailAddress.js @@ -8,3 +8,13 @@ export const AddEmailAddressMutation = gql` } } ` + +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 82dd00c74..dad3474a6 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -161,8 +161,11 @@ "email": { "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." + "submitted": "Eine E-Mail zur Bestätigung deiner Adresse wurde an {email} gesendet.", + "change-successful": "Deine E-Mail Adresse wurde erfolgreich geändert." }, "validation": { "slug": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 7d806debf..83a27bf59 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -162,8 +162,11 @@ "email": { "name": "Your E-Mail", "labelEmail": "Change your E-Mail address", + "labelNewEmail": "New E-Mail Address", + "labelNonce": "Enter your code", "success": "A new E-Mail address has been registered.", - "submitted": "An email to verify your address has been sent to {email}." + "submitted": "An email to verify your address has been sent to {email}.", + "change-successful": "Your E-Mail address has been changed successfully." }, "validation": { "slug": { diff --git a/webapp/pages/settings/my-email-address/index.vue b/webapp/pages/settings/my-email-address/index.vue index 87a59136b..08c894498 100644 --- a/webapp/pages/settings/my-email-address/index.vue +++ b/webapp/pages/settings/my-email-address/index.vue @@ -65,6 +65,13 @@ export default { }) this.$toast.success(this.$t('settings.email.success')) this.success = true + + setTimeout(() => { + this.$router.push({ + path: 'my-email-address/verify-email-address-change', + query: { email }, + }) + }, 3000) } catch (err) { this.$toast.error(err.message) } diff --git a/webapp/pages/settings/my-email-address/verify-email-address-change.vue b/webapp/pages/settings/my-email-address/verify-email-address-change.vue index e69de29bb..5b43c7fdf 100644 --- a/webapp/pages/settings/my-email-address/verify-email-address-change.vue +++ b/webapp/pages/settings/my-email-address/verify-email-address-change.vue @@ -0,0 +1,97 @@ + + + From 69542617ac95a4bfa9aa38114f46ca246a611527 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 23:07:41 +0200 Subject: [PATCH 08/30] Split routes in two So, to get a direct link it's better to have one route that calls a mutation as soon as it is visited. --- webapp/locales/de.json | 4 +- webapp/locales/en.json | 4 +- .../settings/my-email-address/enter-nonce.vue | 59 +++++++++++ .../pages/settings/my-email-address/index.vue | 2 +- .../verify-email-address-change.vue | 97 ------------------- .../settings/my-email-address/verify.vue | 59 +++++++++++ 6 files changed, 125 insertions(+), 100 deletions(-) create mode 100644 webapp/pages/settings/my-email-address/enter-nonce.vue delete mode 100644 webapp/pages/settings/my-email-address/verify-email-address-change.vue create mode 100644 webapp/pages/settings/my-email-address/verify.vue diff --git a/webapp/locales/de.json b/webapp/locales/de.json index dad3474a6..ad85d045f 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -165,7 +165,9 @@ "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." + "change-successful": "Deine E-Mail Adresse wurde erfolgreich geändert.", + "change-error": "Deine E-Mail Adresse konnte nicht verifiziert werden.", + "change-error-help": "Vielleicht ist der Bestätigungscode falsch oder diese E-Mail Adresse wurde nicht hinterlegt?" }, "validation": { "slug": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 83a27bf59..ce6cae5ed 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -166,7 +166,9 @@ "labelNonce": "Enter your code", "success": "A new E-Mail address has been registered.", "submitted": "An email to verify your address has been sent to {email}.", - "change-successful": "Your E-Mail address has been changed successfully." + "change-successful": "Your E-Mail address has been changed successfully.", + "change-error": "Your E-Mail could not be changed.", + "change-error-help": "Maybe the code was invalid or you did not add a new E-Mail address before?" }, "validation": { "slug": { 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..7127124d3 --- /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.vue b/webapp/pages/settings/my-email-address/index.vue index 08c894498..510a9d7e5 100644 --- a/webapp/pages/settings/my-email-address/index.vue +++ b/webapp/pages/settings/my-email-address/index.vue @@ -68,7 +68,7 @@ export default { setTimeout(() => { this.$router.push({ - path: 'my-email-address/verify-email-address-change', + path: 'my-email-address/enter-nonce', query: { email }, }) }, 3000) diff --git a/webapp/pages/settings/my-email-address/verify-email-address-change.vue b/webapp/pages/settings/my-email-address/verify-email-address-change.vue deleted file mode 100644 index 5b43c7fdf..000000000 --- a/webapp/pages/settings/my-email-address/verify-email-address-change.vue +++ /dev/null @@ -1,97 +0,0 @@ - - - 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..e2303fdd8 --- /dev/null +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -0,0 +1,59 @@ + + + From 9808e1c4f860dbd829ef0ea782a48ff033727dfd Mon Sep 17 00:00:00 2001 From: roschaefer Date: Tue, 24 Sep 2019 23:59:08 +0200 Subject: [PATCH 09/30] Validate different email address --- webapp/locales/de.json | 3 +++ webapp/locales/en.json | 3 +++ .../pages/settings/my-email-address/index.vue | 21 ++++++++++++++++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/webapp/locales/de.json b/webapp/locales/de.json index ad85d045f..04fa5d0b6 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -159,6 +159,9 @@ "success": "Deine Daten wurden erfolgreich aktualisiert!" }, "email": { + "validation": { + "same-email": "Muss sich unterscheiden von der jetzigen E-Mail Addresse" + }, "name": "Deine E-Mail", "labelEmail": "E-Mail Adresse ändern", "labelNewEmail": "Neue E-Mail Adresse", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index ce6cae5ed..cabfa1b06 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -160,6 +160,9 @@ "success": "Your data was successfully updated!" }, "email": { + "validation": { + "same-email": "Must be different from your current E-Mail address" + }, "name": "Your E-Mail", "labelEmail": "Change your E-Mail address", "labelNewEmail": "New E-Mail Address", diff --git a/webapp/pages/settings/my-email-address/index.vue b/webapp/pages/settings/my-email-address/index.vue index 510a9d7e5..a166d5521 100644 --- a/webapp/pages/settings/my-email-address/index.vue +++ b/webapp/pages/settings/my-email-address/index.vue @@ -32,9 +32,6 @@ export default { data() { return { success: false, - formSchema: { - email: { type: 'email', required: true }, - }, } }, computed: { @@ -54,6 +51,24 @@ export default { this.formData = formData }, }, + formSchema() { + const { email } = this.currentUser + const sameEmailValidationError = this.$t('settings.email.validation.same-email') + return { + email: [ + { type: 'email', required: true }, + { + validator(rule, value, callback, source, options) { + const errors = [] + if (email === value) { + errors.push(sameEmailValidationError) + } + return errors + }, + }, + ], + } + }, }, methods: { async submit() { From 89cc6da5f1cb0da8c0becf2561a501f39bdac200 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Wed, 25 Sep 2019 13:54:55 +0200 Subject: [PATCH 10/30] Don't redirect if email change was not successful --- webapp/pages/settings/my-email-address/verify.vue | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/webapp/pages/settings/my-email-address/verify.vue b/webapp/pages/settings/my-email-address/verify.vue index e2303fdd8..f4c42bcb1 100644 --- a/webapp/pages/settings/my-email-address/verify.vue +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -30,9 +30,11 @@ export default { }, }, created() { - setTimeout(() => { - this.$router.replace({ name: 'settings-my-email-address' }) - }, 3000) + if (this.success) { + setTimeout(() => { + this.$router.replace({ name: 'settings-my-email-address' }) + }, 3000) + } }, async asyncData(context) { const { From 2b490e00d7874e9b15c5a09649daafac9c47bdf0 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Wed, 25 Sep 2019 14:18:50 +0200 Subject: [PATCH 11/30] wrap email templates in standard layout to minimize duplicate code --- .../src/middleware/email/emailMiddleware.js | 13 +- .../src/middleware/email/templateBuilder.js | 77 ++ .../email/templates/emailVerification.html | 189 +++++ .../src/middleware/email/templates/index.js | 11 + .../middleware/email/templates/layout.html | 256 +++++++ .../email/templates/resetPassword.html | 613 +++++----------- .../middleware/email/templates/signup.html | 675 ++++++------------ .../email/templates/templateBuilder.js | 48 -- .../email/templates/wrongAccount.html | 613 +++++----------- 9 files changed, 1101 insertions(+), 1394 deletions(-) create mode 100644 backend/src/middleware/email/templateBuilder.js create mode 100644 backend/src/middleware/email/templates/emailVerification.html create mode 100644 backend/src/middleware/email/templates/index.js create mode 100644 backend/src/middleware/email/templates/layout.html delete mode 100644 backend/src/middleware/email/templates/templateBuilder.js diff --git a/backend/src/middleware/email/emailMiddleware.js b/backend/src/middleware/email/emailMiddleware.js index bea1bf9b3..4096fa83a 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,18 @@ 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) + // TODO: return name in response + 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..ff8be01f2 --- /dev/null +++ b/backend/src/middleware/email/templates/emailVerification.html @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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

+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 707cf741de860039e453929906c2fa9b00cd8e24 Mon Sep 17 00:00:00 2001 From: Alina Beck Date: Wed, 25 Sep 2019 16:47:42 +0100 Subject: [PATCH 12/30] write text for verification email --- .../email/templates/emailVerification.html | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/src/middleware/email/templates/emailVerification.html b/backend/src/middleware/email/templates/emailVerification.html index ff8be01f2..e185beb53 100644 --- a/backend/src/middleware/email/templates/emailVerification.html +++ b/backend/src/middleware/email/templates/emailVerification.html @@ -28,8 +28,8 @@

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:

+

Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button + kannst Du Deine neue E-Mail Adresse bestätigen:

@@ -39,8 +39,9 @@ Passwort - zurücksetzen + style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">E-Mail + Adresse + bestätigen @@ -59,10 +60,9 @@ -

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!

+

Falls Du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht + einfach ignorieren. Mlde Dich gerne bei + unserem Support Team, wenn du noch Fragen hast!

@@ -126,8 +126,8 @@

Hello {{ name }}!

-

So, you forgot your password? No problem! Just click the button below to reset - it within the next 24 hours:

+

So, you want to change your e-mail? No problem! Just click the button below to verify + your new address:

@@ -137,8 +137,8 @@ Reset - password + style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Verify + e-mail address @@ -157,7 +157,8 @@ -

If you didn't request a new password feel free to ignore this e-mail. You can +

If you don't want to change your e-mail address feel free to ignore this message. You + can also contact our support team if you have any questions!

From e116d529928306ca2cc851daa9e8fc81f352df19 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Fri, 27 Sep 2019 01:12:01 +0200 Subject: [PATCH 13/30] Use EmailAddressRequest and validate email --- backend/src/models/EmailAddressRequest.js | 12 +++++ backend/src/models/index.js | 1 + backend/src/schema/resolvers/emails.js | 18 +++++++- backend/src/schema/resolvers/emails.spec.js | 46 +++++++++++++------ .../seed/factories/emailAddressRequests.js | 10 ++++ backend/src/seed/factories/emailAddresses.js | 21 +++++---- backend/src/seed/factories/index.js | 2 + 7 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 backend/src/models/EmailAddressRequest.js create mode 100644 backend/src/seed/factories/emailAddressRequests.js diff --git a/backend/src/models/EmailAddressRequest.js b/backend/src/models/EmailAddressRequest.js new file mode 100644 index 000000000..7b37b9a39 --- /dev/null +++ b/backend/src/models/EmailAddressRequest.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..d334f460a 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'), + EmailAddressRequest: require('./EmailAddressRequest.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 index d2f76ba39..2c6296627 100644 --- a/backend/src/schema/resolvers/emails.js +++ b/backend/src/schema/resolvers/emails.js @@ -2,10 +2,18 @@ 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) => { + try { + const { neode } = context + await new Validator(neode, neode.model('EmailAddressRequest'), args) + } catch (e) { + throw new UserInputError('must be a valid email') + } + let response = await existingEmailAddress(_parent, args, context) if (response) return response @@ -19,7 +27,7 @@ export default { const result = await txc.run( ` MATCH (user:User {id: $userId}) - MERGE (user)<-[:BELONGS_TO]-(email:EmailAddress {email: $email, nonce: $nonce}) + MERGE (user)<-[:BELONGS_TO]-(email:EmailAddressRequest {email: $email, nonce: $nonce}) SET email.createdAt = toString(datetime()) RETURN email `, @@ -46,9 +54,11 @@ export default { const result = await txc.run( ` MATCH (user:User {id: $userId})-[previous:PRIMARY_EMAIL]->(:EmailAddress) - MATCH (user)<-[:BELONGS_TO]-(email:EmailAddress {email: $email, nonce: $nonce}) + MATCH (user)<-[:BELONGS_TO]-(email:EmailAddressRequest {email: $email, nonce: $nonce}) MERGE (user)-[:PRIMARY_EMAIL]->(email) + SET email:EmailAddress SET email.verifiedAt = toString(datetime()) + REMOVE email:EmailAddressRequest DELETE previous RETURN email `, @@ -59,6 +69,10 @@ export default { 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() } diff --git a/backend/src/schema/resolvers/emails.spec.js b/backend/src/schema/resolvers/emails.spec.js index 6ec66ca65..f6058e19e 100644 --- a/backend/src/schema/resolvers/emails.spec.js +++ b/backend/src/schema/resolvers/emails.spec.js @@ -68,7 +68,16 @@ describe('AddEmailAddress', () => { }) describe('email attribute is not a valid email', () => { - it.todo('throws UserInputError') + 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', () => { @@ -85,24 +94,23 @@ describe('AddEmailAddress', () => { }) }) - it('connects `EmailAddress` to the authenticated user', async () => { + it('connects `EmailAddressRequest` 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:EmailAddress {email: "new-email@example.org"}) + MATCH(u:User)<-[:BELONGS_TO]-(e:EmailAddressRequest {email: "new-email@example.org"}) RETURN e `) - const email = neode.hydrateFirst(result, 'e', neode.model('EmailAddress')) + const email = neode.hydrateFirst(result, 'e', neode.model('EmailAddressRequest')) await expect(email.toJson()).resolves.toMatchObject({ email: 'new-email@example.org', nonce: expect.any(String), }) }) - describe('if a lone `EmailAddress` node already exists with that email', () => { - it('returns this `EmailAddress` node', async () => { - await factory.create('EmailAddress', { - verifiedAt: null, + describe('if another `EmailAddressRequest` node already exists with that email', () => { + it('throws no unique constraint violation error', async () => { + await factory.create('EmailAddressRequest', { createdAt: '2019-09-24T14:00:01.565Z', email: 'new-email@example.org', }) @@ -111,7 +119,6 @@ describe('AddEmailAddress', () => { AddEmailAddress: { email: 'new-email@example.org', verifiedAt: null, - createdAt: '2019-09-24T14:00:01.565Z', // this is to make sure it's the one above }, }, errors: undefined, @@ -175,10 +182,10 @@ describe('VerifyEmailAddress', () => { }) }) - describe('given an unverified `EmailAddress`', () => { + describe('given a `EmailAddressRequest`', () => { let emailAddress beforeEach(async () => { - emailAddress = await factory.create('EmailAddress', { + emailAddress = await factory.create('EmailAddressRequest', { nonce: 'abcdef', verifiedAt: null, createdAt: new Date().toISOString(), @@ -196,7 +203,7 @@ describe('VerifyEmailAddress', () => { }) }) - describe('given valid nonce for unverified `EmailAddress` node', () => { + describe('given valid nonce for `EmailAddressRequest` node', () => { beforeEach(() => { variables = { ...variables, nonce: 'abcdef' } }) @@ -210,7 +217,7 @@ describe('VerifyEmailAddress', () => { }) }) - describe('and the `EmailAddress` belongs to the authenticated user', () => { + describe('and the `EmailAddressRequest` belongs to the authenticated user', () => { beforeEach(async () => { await emailAddress.relateTo(user, 'belongsTo') }) @@ -256,6 +263,19 @@ describe('VerifyEmailAddress', () => { 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/seed/factories/emailAddressRequests.js b/backend/src/seed/factories/emailAddressRequests.js new file mode 100644 index 000000000..242be6375 --- /dev/null +++ b/backend/src/seed/factories/emailAddressRequests.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('EmailAddressRequest', args) + }, + } +} 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..acfaad2d7 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 createEmailAddressRequests from './emailAddressRequests.js' export const seedServerHost = 'http://127.0.0.1:4001' @@ -32,6 +33,7 @@ const factories = { SocialMedia: createSocialMedia, Location: createLocation, EmailAddress: createEmailAddress, + EmailAddressRequest: createEmailAddressRequests, } export const cleanDatabase = async (options = {}) => { From f61441d3e66ca83befd2079dcb02fa8e0dcb701d Mon Sep 17 00:00:00 2001 From: roschaefer Date: Fri, 27 Sep 2019 21:43:06 +0200 Subject: [PATCH 14/30] Test my-email-address settings page --- .../settings/my-email-address/index.spec.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 webapp/pages/settings/my-email-address/index.spec.js 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..c0c767e25 --- /dev/null +++ b/webapp/pages/settings/my-email-address/index.spec.js @@ -0,0 +1,101 @@ +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' }, + }) + }) + }) + }) + }) + }) + }) +}) From 6a212fb6681ee421b4b4384867f297a233dd562e Mon Sep 17 00:00:00 2001 From: roschaefer Date: Fri, 27 Sep 2019 21:56:25 +0200 Subject: [PATCH 15/30] Test verify-nonce page --- .../my-email-address/enter-nonce.spec.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 webapp/pages/settings/my-email-address/enter-nonce.spec.js 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() + }) + }) + }) + }) + }) +}) From 69cd41d3eb6df5d2d9496f22a4d8dac0def60823 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Fri, 27 Sep 2019 23:21:18 +0200 Subject: [PATCH 16/30] Test email verification page --- .../settings/my-email-address/verify.spec.js | 164 ++++++++++++++++++ .../settings/my-email-address/verify.vue | 12 +- 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 webapp/pages/settings/my-email-address/verify.spec.js 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..9280685d1 --- /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.change-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 index f4c42bcb1..adf243355 100644 --- a/webapp/pages/settings/my-email-address/verify.vue +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -38,19 +38,29 @@ export default { }, async asyncData(context) { const { + store, query, app: { apolloProvider }, } = context const client = apolloProvider.defaultClient let success const { email = '', nonce = '' } = query + const currentUser = store.getters['auth/user'] try { - await client.mutate({ + const response = await client.mutate({ mutation: VerifyEmailAddressMutation, variables: { email, nonce }, }) + const { + data: { VerifyEmailAddress }, + } = response success = true + store.commit( + 'auth/SET_USER', + { ...currentUser, email: VerifyEmailAddress.email }, + { root: true }, + ) } catch (error) { success = false } From 3e3452c7bc9cf7fc8047a7ac87568dfcfc4956bf Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 28 Sep 2019 00:27:09 +0200 Subject: [PATCH 17/30] Better help messages, styling --- webapp/locales/de.json | 12 ++++- webapp/locales/en.json | 14 ++++-- .../settings/my-email-address/verify.vue | 47 +++++++++++++++---- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 04fa5d0b6..a5f56ae8d 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -169,8 +169,16 @@ "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.", - "change-error": "Deine E-Mail Adresse konnte nicht verifiziert werden.", - "change-error-help": "Vielleicht ist der Bestätigungscode falsch oder diese E-Mail Adresse wurde nicht hinterlegt?" + "change-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": "Wurde gar keine Änderung der E-Mail Adresse angefragt?", + "email-address-taken": "Wurde die E-Mail inzwischen einem anderen Benutzer zugewiesen?" + } + } }, "validation": { "slug": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index cabfa1b06..d6cb53b7f 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.", @@ -170,8 +170,16 @@ "success": "A new E-Mail address has been registered.", "submitted": "An email to verify your address has been sent to {email}.", "change-successful": "Your E-Mail address has been changed successfully.", - "change-error": "Your E-Mail could not be changed.", - "change-error-help": "Maybe the code was invalid or you did not add a new E-Mail address before?" + "change-error": { + "message": "Your E-Mail could not be changed.", + "explanation": "This can have different causes:", + "reason": { + "invalid-nonce": "Is the confirmation code invalid?", + "no-email-request": "You haven't requested a change of your email address at all?", + "email-address-taken": "Has the email been assigned to another user in the meantime?" + }, + "support": "If the problem persists, please contact us by e-mail at" + } }, "validation": { "slug": { diff --git a/webapp/pages/settings/my-email-address/verify.vue b/webapp/pages/settings/my-email-address/verify.vue index adf243355..9a8f09611 100644 --- a/webapp/pages/settings/my-email-address/verify.vue +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -1,18 +1,40 @@ @@ -69,3 +91,10 @@ export default { }, } + + From 573edce788a72198ea78f7397a1dbe1425a7e2ee Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 28 Sep 2019 00:34:22 +0200 Subject: [PATCH 18/30] Show at least the error message in SSR --- .../settings/my-email-address/verify.vue | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/webapp/pages/settings/my-email-address/verify.vue b/webapp/pages/settings/my-email-address/verify.vue index 9a8f09611..61c47f6d5 100644 --- a/webapp/pages/settings/my-email-address/verify.vue +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -1,19 +1,21 @@ From 76841d27f1fa43efd72f07c2357f9e0de23b6396 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 28 Sep 2019 00:39:09 +0200 Subject: [PATCH 19/30] Styling --- webapp/pages/settings/my-email-address/verify.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/pages/settings/my-email-address/verify.vue b/webapp/pages/settings/my-email-address/verify.vue index 61c47f6d5..f435d4f7d 100644 --- a/webapp/pages/settings/my-email-address/verify.vue +++ b/webapp/pages/settings/my-email-address/verify.vue @@ -6,7 +6,7 @@ - + {{ $t(`settings.email.change-successful`) }} From 5848e6af1828d52aa8a630df91be0628a73be0cc Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 28 Sep 2019 00:44:37 +0200 Subject: [PATCH 20/30] Fix a TODO by @alina-beck --- backend/src/middleware/email/emailMiddleware.js | 1 - backend/src/schema/resolvers/emails.js | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/middleware/email/emailMiddleware.js b/backend/src/middleware/email/emailMiddleware.js index 4096fa83a..f92da3368 100644 --- a/backend/src/middleware/email/emailMiddleware.js +++ b/backend/src/middleware/email/emailMiddleware.js @@ -60,7 +60,6 @@ const sendPasswordResetMail = async (resolve, root, args, context, resolveInfo) const sendEmailVerificationMail = async (resolve, root, args, context, resolveInfo) => { const response = await resolve(root, args, context, resolveInfo) - // TODO: return name in response const { email, nonce, name } = response await sendMail(emailVerificationTemplate({ email, nonce, name })) delete response.nonce diff --git a/backend/src/schema/resolvers/emails.js b/backend/src/schema/resolvers/emails.js index 2c6296627..84b5d7659 100644 --- a/backend/src/schema/resolvers/emails.js +++ b/backend/src/schema/resolvers/emails.js @@ -29,11 +29,14 @@ export default { MATCH (user:User {id: $userId}) MERGE (user)<-[:BELONGS_TO]-(email:EmailAddressRequest {email: $email, nonce: $nonce}) SET email.createdAt = toString(datetime()) - RETURN email + RETURN email, user `, { userId, email, nonce }, ) - return result.records.map(record => record.get('email').properties) + return result.records.map(record => ({ + name: record.get('user').properties.name, + ...record.get('email').properties, + })) }) try { const txResult = await writeTxResultPromise From 01e583b45ed0e393003973c9f7a002c1f0beefdf Mon Sep 17 00:00:00 2001 From: roschaefer Date: Sat, 28 Sep 2019 12:30:58 +0200 Subject: [PATCH 21/30] Translate backend error and avoid $toast --- webapp/locales/de.json | 2 +- webapp/locales/en.json | 2 +- .../pages/settings/my-email-address/index.spec.js | 15 +++++++++++++++ webapp/pages/settings/my-email-address/index.vue | 12 ++++++++++++ .../settings/my-email-address/verify.spec.js | 2 +- webapp/pages/settings/my-email-address/verify.vue | 12 ++++++------ 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/webapp/locales/de.json b/webapp/locales/de.json index a5f56ae8d..03a49d831 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -169,7 +169,7 @@ "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.", - "change-error": { + "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:", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index d6cb53b7f..4e8c01435 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -170,7 +170,7 @@ "success": "A new E-Mail address has been registered.", "submitted": "An email to verify your address has been sent to {email}.", "change-successful": "Your E-Mail address has been changed successfully.", - "change-error": { + "verification-error": { "message": "Your E-Mail could not be changed.", "explanation": "This can have different causes:", "reason": { diff --git a/webapp/pages/settings/my-email-address/index.spec.js b/webapp/pages/settings/my-email-address/index.spec.js index c0c767e25..8bf1bb986 100644 --- a/webapp/pages/settings/my-email-address/index.spec.js +++ b/webapp/pages/settings/my-email-address/index.spec.js @@ -95,6 +95,21 @@ describe('EmailSettingsIndexPage', () => { }) }) }) + + 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 index a166d5521..3ebd233d3 100644 --- a/webapp/pages/settings/my-email-address/index.vue +++ b/webapp/pages/settings/my-email-address/index.vue @@ -11,6 +11,9 @@