Merge pull request #1044 from Human-Connection/refactor_email_address

Refactoring: Split User and Email
This commit is contained in:
Robert Schäfer 2019-07-16 21:28:42 +02:00 committed by GitHub
commit 7de73f861e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 62 additions and 46 deletions

View File

@ -8,5 +8,6 @@ module.exports = {
relationship: 'BELONGS_TO', relationship: 'BELONGS_TO',
target: 'User', target: 'User',
direction: 'out', direction: 'out',
eager: true,
}, },
} }

View File

@ -4,7 +4,6 @@ module.exports = {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
actorId: { type: 'string', allow: [null] }, actorId: { type: 'string', allow: [null] },
name: { type: 'string', min: 3 }, name: { type: 'string', min: 3 },
email: { type: 'string', lowercase: true, email: true },
slug: 'string', slug: 'string',
encryptedPassword: 'string', encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] }, avatar: { type: 'string', allow: [null] },

View File

@ -5,7 +5,7 @@ export async function createPasswordReset(options) {
const { driver, code, email, issuedAt = new Date() } = options const { driver, code, email, issuedAt = new Date() } = options
const session = driver.session() const session = driver.session()
const cypher = ` const cypher = `
MATCH (u:User) WHERE u.email = $email MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL}) CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr) MERGE (u)-[:REQUESTED]->(pr)
RETURN u RETURN u
@ -35,7 +35,7 @@ export default {
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = ` const cypher = `
MATCH (pr:PasswordReset {code: $code}) MATCH (pr:PasswordReset {code: $code})
MATCH (u:User {email: $email})-[:REQUESTED]->(pr) MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime() SET pr.usedAt = datetime()
SET u.encryptedPassword = $encryptedNewPassword SET u.encryptedPassword = $encryptedNewPassword

View File

@ -12,8 +12,8 @@ const instance = neode()
*/ */
const checkEmailDoesNotExist = async ({ email }) => { const checkEmailDoesNotExist = async ({ email }) => {
email = email.toLowerCase() email = email.toLowerCase()
const users = await instance.all('User', { email }) const emails = await instance.all('EmailAddress', { email })
if (users.length > 0) throw new UserInputError('User account with this email already exists.') if (emails.length > 0) throw new UserInputError('User account with this email already exists.')
} }
export default { export default {

View File

@ -166,11 +166,12 @@ describe('SignupByInvitation', () => {
await expect(action()).rejects.toThrow('"email" must be a valid email') await expect(action()).rejects.toThrow('"email" must be a valid email')
}) })
it('creates no EmailAddress node', async done => { it('creates no additional EmailAddress node', async done => {
try { try {
await action() await action()
} catch (e) { } catch (e) {
const emailAddresses = await instance.all('EmailAddress') let emailAddresses = await instance.all('EmailAddress')
emailAddresses = await emailAddresses.toJson
expect(emailAddresses).toHaveLength(0) expect(emailAddresses).toHaveLength(0)
done() done()
} }
@ -191,16 +192,16 @@ describe('SignupByInvitation', () => {
describe('creates a EmailAddress node', () => { describe('creates a EmailAddress node', () => {
it('with a `createdAt` attribute', async () => { it('with a `createdAt` attribute', async () => {
await action() await action()
const emailAddresses = await instance.all('EmailAddress') let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
const emailAddress = await emailAddresses.first().toJson() emailAddress = await emailAddress.toJson()
expect(emailAddress.createdAt).toBeTruthy() expect(emailAddress.createdAt).toBeTruthy()
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number)) expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
}) })
it('with a cryptographic `nonce`', async () => { it('with a cryptographic `nonce`', async () => {
await action() await action()
const emailAddresses = await instance.all('EmailAddress') let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
const emailAddress = await emailAddresses.first().toJson() emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String)) expect(emailAddress.nonce).toEqual(expect.any(String))
}) })
@ -220,6 +221,7 @@ describe('SignupByInvitation', () => {
it('rejects because codes can be used only once', async done => { it('rejects because codes can be used only once', async done => {
await action() await action()
try { try {
variables.email = 'yetanotheremail@example.org'
await action() await action()
} catch (e) { } catch (e) {
expect(e.message).toMatch(/Invitation code already used/) expect(e.message).toMatch(/Invitation code already used/)
@ -282,8 +284,8 @@ describe('Signup', () => {
it('creates a Signup with a cryptographic `nonce`', async () => { it('creates a Signup with a cryptographic `nonce`', async () => {
await action() await action()
const emailAddresses = await instance.all('EmailAddress') let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
const emailAddress = await emailAddresses.first().toJson() emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String)) expect(emailAddress.nonce).toEqual(expect.any(String))
}) })
}) })

View File

@ -2,6 +2,9 @@ import encode from '../../jwt/encode'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server' import { AuthenticationError } from 'apollo-server'
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import { neode } from '../../bootstrap/neo4j'
const instance = neode()
export default { export default {
Query: { Query: {
@ -21,8 +24,8 @@ export default {
// } // }
const session = driver.session() const session = driver.session()
const result = await session.run( const result = await session.run(
'MATCH (user:User {email: $userEmail}) ' + 'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' +
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1', 'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1',
{ {
userEmail: email, userEmail: email,
}, },
@ -46,41 +49,24 @@ export default {
} }
}, },
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => { changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
const session = driver.session() let currentUser = await instance.find('User', user.id)
let result = await session.run(
`MATCH (user:User {email: $userEmail})
RETURN user {.id, .email, .encryptedPassword}`,
{
userEmail: user.email,
},
)
const [currentUser] = result.records.map(function(record) { const encryptedPassword = currentUser.get('encryptedPassword')
return record.get('user') if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) {
})
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
throw new AuthenticationError('Old password is not correct') throw new AuthenticationError('Old password is not correct')
} }
if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) { if (await bcrypt.compareSync(newPassword, encryptedPassword)) {
throw new AuthenticationError('Old password and new password should be different') throw new AuthenticationError('Old password and new password should be different')
} else {
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
session.run(
`MATCH (user:User {email: $userEmail})
SET user.encryptedPassword = $newEncryptedPassword
RETURN user
`,
{
userEmail: user.email,
newEncryptedPassword,
},
)
session.close()
return encode(currentUser)
} }
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
await currentUser.update({
encryptedPassword: newEncryptedPassword,
updatedAt: new Date().toISOString(),
})
return encode(await currentUser.toJson())
}, },
}, },
} }

View File

@ -65,6 +65,13 @@ export const hasOne = obj => {
export default { export default {
Query: { Query: {
User: async (object, args, context, resolveInfo) => { User: async (object, args, context, resolveInfo) => {
const { email } = args
if (email) {
const e = await instance.first('EmailAddress', { email })
let user = e.get('belongsTo')
user = await user.toJson()
return [user.node]
}
return neo4jgraphql(object, args, context, resolveInfo, false) return neo4jgraphql(object, args, context, resolveInfo, false)
}, },
}, },
@ -104,6 +111,14 @@ export default {
}, },
}, },
User: { User: {
email: async (parent, params, context, resolveInfo) => {
if (typeof parent.email !== 'undefined') return parent.email
const { id } = parent
const statement = `MATCH(u:User {id: {id}})-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e`
const result = await instance.cypher(statement, { id })
let [{ email }] = result.records.map(r => r.get('e').properties)
return email
},
...undefinedToNull([ ...undefinedToNull([
'actorId', 'actorId',
'avatar', 'avatar',

View File

@ -2,7 +2,7 @@ type User {
id: ID! id: ID!
actorId: String actorId: String
name: String name: String
email: String! email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
slug: String! slug: String!
avatar: String avatar: String
coverImg: String coverImg: String

View File

@ -21,7 +21,11 @@ export default function create() {
...args, ...args,
} }
args = await encryptPassword(args) args = await encryptPassword(args)
return neodeInstance.create('User', args) const user = await neodeInstance.create('User', args)
const email = await neodeInstance.create('EmailAddress', { email: args.email })
await user.relateTo(email, 'primaryEmail')
await email.relateTo(user, 'belongsTo')
return user
}, },
} }
} }

View File

@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`,
u.updatedAt = user.updatedAt.`$date`, u.updatedAt = user.updatedAt.`$date`,
u.deleted = user.deletedAt IS NOT NULL, u.deleted = user.deletedAt IS NOT NULL,
u.disabled = false u.disabled = false
MERGE (e:EmailAddress {
email: user.email,
createdAt: toString(datetime()),
verifiedAt: toString(datetime())
})
MERGE (e)-[:BELONGS_TO]->(u)
MERGE (u)<-[:PRIMARY_EMAIL]-(e)
WITH u, user, user.badgeIds AS badgeIds WITH u, user, user.badgeIds AS badgeIds
UNWIND badgeIds AS badgeId UNWIND badgeIds AS badgeId
MATCH (b:Badge {id: badgeId}) MATCH (b:Badge {id: badgeId})

View File

@ -34,6 +34,8 @@ CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE; CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE; CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE; CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE;
' | cypher-shell ' | cypher-shell
echo ' echo '