diff --git a/backend/.env.template b/backend/.env.template index e905d1eb6..0c80529a1 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -5,6 +5,11 @@ GRAPHQL_PORT=4000 GRAPHQL_URI=http://localhost:4000 CLIENT_URI=http://localhost:3000 MOCKS=false +SMTP_HOST= +SMTP_PORT= +SMTP_IGNORE_TLS=true +SMTP_USERNAME= +SMTP_PASSWORD= JWT_SECRET="b/&&7b78BF&fv/Vd" MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ" diff --git a/backend/README.md b/backend/README.md index 3cce123ac..cd56e231f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -44,6 +44,9 @@ or start the backend in production environment with: yarn run start ``` +For e-mail delivery, please configure at least `SMTP_HOST` and `SMTP_PORT` in +your `.env` configuration file. + Your backend is up and running at [http://localhost:4000/](http://localhost:4000/) This will start the GraphQL service \(by default on localhost:4000\) where you can issue GraphQL requests or access GraphQL Playground in the browser. diff --git a/backend/package.json b/backend/package.json index 50d4aa9fc..85a51fa14 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,7 +47,7 @@ "apollo-client": "~2.6.3", "apollo-link-context": "~1.0.18", "apollo-link-http": "~1.5.15", - "apollo-server": "~2.6.3", + "apollo-server": "~2.6.4", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.3", "cors": "~2.8.5", @@ -61,7 +61,7 @@ "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.2", - "graphql-shield": "~5.6.1", + "graphql-shield": "~5.7.1", "graphql-tag": "~2.10.1", "graphql-yoga": "~1.18.0", "helmet": "~3.18.0", @@ -72,6 +72,7 @@ "neo4j-driver": "~1.7.4", "neo4j-graphql-js": "git+https://github.com/Human-Connection/neo4j-graphql-js.git#temporary_fixes", "node-fetch": "~2.6.0", + "nodemailer": "^6.2.1", "npm-run-all": "~4.1.5", "request": "~2.88.0", "sanitize-html": "~1.20.1", diff --git a/backend/src/config/index.js b/backend/src/config/index.js index aed6f7c1c..320b636e9 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -2,23 +2,33 @@ import dotenv from 'dotenv' dotenv.config() -export const requiredConfigs = { - MAPBOX_TOKEN: process.env.MAPBOX_TOKEN, - JWT_SECRET: process.env.JWT_SECRET, - PRIVATE_KEY_PASSPHRASE: process.env.PRIVATE_KEY_PASSPHRASE, -} +const { + MAPBOX_TOKEN, + JWT_SECRET, + PRIVATE_KEY_PASSPHRASE, + SMTP_IGNORE_TLS = true, + SMTP_HOST, + SMTP_PORT, + SMTP_USERNAME, + SMTP_PASSWORD, + NEO4J_URI = 'bolt://localhost:7687', + NEO4J_USERNAME = 'neo4j', + NEO4J_PASSWORD = 'neo4j', + GRAPHQL_PORT = 4000, + CLIENT_URI = 'http://localhost:3000', + GRAPHQL_URI = 'http://localhost:4000', +} = process.env -export const neo4jConfigs = { - NEO4J_URI: process.env.NEO4J_URI || 'bolt://localhost:7687', - NEO4J_USERNAME: process.env.NEO4J_USERNAME || 'neo4j', - NEO4J_PASSWORD: process.env.NEO4J_PASSWORD || 'neo4j', -} - -export const serverConfigs = { - GRAPHQL_PORT: process.env.GRAPHQL_PORT || 4000, - CLIENT_URI: process.env.CLIENT_URI || 'http://localhost:3000', - GRAPHQL_URI: process.env.GRAPHQL_URI || 'http://localhost:4000', +export const requiredConfigs = { MAPBOX_TOKEN, JWT_SECRET, PRIVATE_KEY_PASSPHRASE } +export const smtpConfigs = { + SMTP_HOST, + SMTP_PORT, + SMTP_IGNORE_TLS, + SMTP_USERNAME, + SMTP_PASSWORD, } +export const neo4jConfigs = { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD } +export const serverConfigs = { GRAPHQL_PORT, CLIENT_URI, GRAPHQL_URI } export const developmentConfigs = { DEBUG: process.env.NODE_ENV !== 'production' && process.env.DEBUG === 'true', @@ -29,6 +39,7 @@ export const developmentConfigs = { export default { ...requiredConfigs, + ...smtpConfigs, ...neo4jConfigs, ...serverConfigs, ...developmentConfigs, diff --git a/backend/src/middleware/fixImageUrlsMiddleware.js b/backend/src/middleware/fixImageUrlsMiddleware.js deleted file mode 100644 index 3bfa8537a..000000000 --- a/backend/src/middleware/fixImageUrlsMiddleware.js +++ /dev/null @@ -1,51 +0,0 @@ -const legacyUrls = [ - 'https://api-alpha.human-connection.org', - 'https://staging-api.human-connection.org', - 'http://localhost:3000', -] - -export const fixUrl = url => { - legacyUrls.forEach(legacyUrl => { - url = url.replace(legacyUrl, '') - }) - if (!url.startsWith('/')) { - url = `/${url}` - } - return url -} - -const checkUrl = thing => { - return ( - thing && - typeof thing === 'string' && - legacyUrls.find(legacyUrl => { - return thing.indexOf(legacyUrl) === 0 - }) - ) -} - -export const fixImageURLs = (result, recursive) => { - if (checkUrl(result)) { - result = fixUrl(result) - } else if (result && Array.isArray(result)) { - result.forEach((res, index) => { - result[index] = fixImageURLs(result[index], true) - }) - } else if (result && typeof result === 'object') { - Object.keys(result).forEach(key => { - result[key] = fixImageURLs(result[key], true) - }) - } - return result -} - -export default { - Mutation: async (resolve, root, args, context, info) => { - const result = await resolve(root, args, context, info) - return fixImageURLs(result) - }, - Query: async (resolve, root, args, context, info) => { - let result = await resolve(root, args, context, info) - return fixImageURLs(result) - }, -} diff --git a/backend/src/middleware/fixImageUrlsMiddleware.spec.js b/backend/src/middleware/fixImageUrlsMiddleware.spec.js deleted file mode 100644 index 0da66811a..000000000 --- a/backend/src/middleware/fixImageUrlsMiddleware.spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { fixImageURLs } from './fixImageUrlsMiddleware' - -describe('fixImageURLs', () => { - describe('edge case: image url is exact match of legacy url', () => { - it('replaces it with `/`', () => { - const url = 'https://api-alpha.human-connection.org' - expect(fixImageURLs(url)).toEqual('/') - }) - }) - - describe('image url of legacy alpha', () => { - it('removes domain', () => { - const url = - 'https://api-alpha.human-connection.org/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png' - expect(fixImageURLs(url)).toEqual( - '/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png', - ) - }) - }) - - describe('image url of legacy staging', () => { - it('removes domain', () => { - const url = - 'https://staging-api.human-connection.org/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg' - expect(fixImageURLs(url)).toEqual( - '/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg', - ) - }) - }) - - describe('object', () => { - it('returns untouched', () => { - const object = { some: 'thing' } - expect(fixImageURLs(object)).toEqual(object) - }) - }) - - describe('some string', () => { - it('returns untouched', () => {}) - const string = "Yeah I'm a String" - expect(fixImageURLs(string)).toEqual(string) - }) -}) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 75314abc0..9b85bd340 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -3,7 +3,6 @@ import activityPub from './activityPubMiddleware' import password from './passwordMiddleware' import softDelete from './softDeleteMiddleware' import sluggify from './sluggifyMiddleware' -import fixImageUrls from './fixImageUrlsMiddleware' import excerpt from './excerptMiddleware' import dateTime from './dateTimeMiddleware' import xss from './xssMiddleware' @@ -25,7 +24,6 @@ export default schema => { excerpt: excerpt, notifications: notifications, xss: xss, - fixImageUrls: fixImageUrls, softDelete: softDelete, user: user, includedFields: includedFields, @@ -42,7 +40,6 @@ export default schema => { 'excerpt', 'notifications', 'xss', - 'fixImageUrls', 'softDelete', 'user', 'includedFields', diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 10b777748..dbcde849c 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -147,6 +147,8 @@ const permissions = shield( CreateComment: isAuthenticated, DeleteComment: isAuthor, DeleteUser: isDeletingOwnAccount, + requestPasswordReset: allow, + resetPassword: allow, }, User: { email: isMyOwn, diff --git a/backend/src/schema/resolvers/passwordReset.js b/backend/src/schema/resolvers/passwordReset.js new file mode 100644 index 000000000..13789662b --- /dev/null +++ b/backend/src/schema/resolvers/passwordReset.js @@ -0,0 +1,72 @@ +import uuid from 'uuid/v4' +import bcrypt from 'bcryptjs' +import CONFIG from '../../config' +import nodemailer from 'nodemailer' +import { resetPasswordMail, wrongAccountMail } from './passwordReset/emailTemplates' + +const transporter = () => { + const configs = { + host: CONFIG.SMTP_HOST, + port: CONFIG.SMTP_PORT, + ignoreTLS: CONFIG.SMTP_IGNORE_TLS, + secure: false, // true for 465, false for other ports + } + const { SMTP_USERNAME: user, SMTP_PASSWORD: pass } = CONFIG + if (user && pass) { + configs.auth = { user, pass } + } + return nodemailer.createTransport(configs) +} + +export async function createPasswordReset(options) { + const { driver, code, email, issuedAt = new Date() } = options + const session = driver.session() + const cypher = ` + MATCH (u:User) WHERE u.email = $email + CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL}) + MERGE (u)-[:REQUESTED]->(pr) + RETURN u + ` + const transactionRes = await session.run(cypher, { + issuedAt: issuedAt.toISOString(), + code, + email, + }) + const users = transactionRes.records.map(record => record.get('u')) + session.close() + return users +} + +export default { + Mutation: { + requestPasswordReset: async (_, { email }, { driver }) => { + const code = uuid().substring(0, 6) + const [user] = await createPasswordReset({ driver, code, email }) + if (CONFIG.SMTP_HOST && CONFIG.SMTP_PORT) { + const name = (user && user.name) || '' + const mailTemplate = user ? resetPasswordMail : wrongAccountMail + await transporter().sendMail(mailTemplate({ email, code, name })) + } + return true + }, + resetPassword: async (_, { email, code, newPassword }, { driver }) => { + const session = driver.session() + const stillValid = new Date() + stillValid.setDate(stillValid.getDate() - 1) + const newHashedPassword = await bcrypt.hashSync(newPassword, 10) + const cypher = ` + MATCH (pr:PasswordReset {code: $code}) + MATCH (u:User {email: $email})-[:REQUESTED]->(pr) + WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL + SET pr.usedAt = datetime() + SET u.password = $newHashedPassword + RETURN pr + ` + let transactionRes = await session.run(cypher, { stillValid, email, code, newHashedPassword }) + const [reset] = transactionRes.records.map(record => record.get('pr')) + const result = !!(reset && reset.properties.usedAt) + session.close() + return result + }, + }, +} diff --git a/backend/src/schema/resolvers/passwordReset.spec.js b/backend/src/schema/resolvers/passwordReset.spec.js new file mode 100644 index 000000000..545945f51 --- /dev/null +++ b/backend/src/schema/resolvers/passwordReset.spec.js @@ -0,0 +1,180 @@ +import { GraphQLClient } from 'graphql-request' +import Factory from '../../seed/factories' +import { host } from '../../jest/helpers' +import { getDriver } from '../../bootstrap/neo4j' +import { createPasswordReset } from './passwordReset' + +const factory = Factory() +let client +const driver = getDriver() + +const getAllPasswordResets = async () => { + const session = driver.session() + let transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r') + const resets = transactionRes.records.map(record => record.get('r')) + session.close() + return resets +} + +describe('passwordReset', () => { + beforeEach(async () => { + client = new GraphQLClient(host) + await factory.create('User', { + email: 'user@example.org', + role: 'user', + password: '1234', + }) + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('requestPasswordReset', () => { + const mutation = `mutation($email: String!) { requestPasswordReset(email: $email) }` + + describe('with invalid email', () => { + const variables = { email: 'non-existent@example.org' } + + it('resolves anyways', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual({ + requestPasswordReset: true, + }) + }) + + it('creates no node', async () => { + await client.request(mutation, variables) + const resets = await getAllPasswordResets() + expect(resets).toHaveLength(0) + }) + }) + + describe('with a valid email', () => { + const variables = { email: 'user@example.org' } + + it('resolves', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual({ + requestPasswordReset: true, + }) + }) + + it('creates node with label `PasswordReset`', async () => { + await client.request(mutation, variables) + const resets = await getAllPasswordResets() + expect(resets).toHaveLength(1) + }) + + it('creates a reset code', async () => { + await client.request(mutation, variables) + const resets = await getAllPasswordResets() + const [reset] = resets + const { code } = reset.properties + expect(code).toHaveLength(6) + }) + }) + }) + + describe('resetPassword', () => { + const setup = async (options = {}) => { + const { email = 'user@example.org', issuedAt = new Date(), code = 'abcdef' } = options + + const session = driver.session() + await createPasswordReset({ driver, email, issuedAt, code }) + session.close() + } + + const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }` + let email = 'user@example.org' + let code = 'abcdef' + let newPassword = 'supersecret' + let variables + + describe('invalid email', () => { + it('resolves to false', async () => { + await setup() + variables = { newPassword, email: 'non-existent@example.org', code } + await expect(client.request(mutation, variables)).resolves.toEqual({ resetPassword: false }) + }) + }) + + describe('valid email', () => { + describe('but invalid code', () => { + it('resolves to false', async () => { + await setup() + variables = { newPassword, email, code: 'slkdjf' } + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: false, + }) + }) + }) + + describe('and valid code', () => { + beforeEach(() => { + variables = { + newPassword, + email: 'user@example.org', + code: 'abcdef', + } + }) + + describe('and code not expired', () => { + beforeEach(async () => { + await setup() + }) + + it('resolves to true', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: true, + }) + }) + + it('updates PasswordReset `usedAt` property', async () => { + await client.request(mutation, variables) + const requests = await getAllPasswordResets() + const [request] = requests + const { usedAt } = request.properties + expect(usedAt).not.toBeFalsy() + }) + + it('updates password of the user', async () => { + await client.request(mutation, variables) + const checkLoginMutation = ` + mutation($email: String!, $password: String!) { + login(email: $email, password: $password) + } + ` + const expected = expect.objectContaining({ login: expect.any(String) }) + await expect( + client.request(checkLoginMutation, { + email: 'user@example.org', + password: 'supersecret', + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but expired code', () => { + beforeEach(async () => { + const issuedAt = new Date() + issuedAt.setDate(issuedAt.getDate() - 1) + await setup({ issuedAt }) + }) + + it('resolves to false', async () => { + await expect(client.request(mutation, variables)).resolves.toEqual({ + resetPassword: false, + }) + }) + + it('does not update PasswordReset `usedAt` property', async () => { + await client.request(mutation, variables) + const requests = await getAllPasswordResets() + const [request] = requests + const { usedAt } = request.properties + expect(usedAt).toBeUndefined() + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/resolvers/passwordReset/emailTemplates.js b/backend/src/schema/resolvers/passwordReset/emailTemplates.js new file mode 100644 index 000000000..8508adccc --- /dev/null +++ b/backend/src/schema/resolvers/passwordReset/emailTemplates.js @@ -0,0 +1,85 @@ +import CONFIG from '../../../config' + +export const from = '"Human Connection" ' + +export const resetPasswordMail = options => { + const { + name, + email, + code, + subject = 'Use this link to reset your password. The link is only valid for 24 hours.', + supportUrl = 'https://human-connection.org/en/contact/', + } = options + const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI) + actionUrl.searchParams.set('code', code) + actionUrl.searchParams.set('email', email) + + return { + to: email, + subject, + text: ` +Hi ${name}! + +You recently requested to reset your password for your Human Connection account. +Use the link below to reset it. This password reset is only valid for the next +24 hours. + +${actionUrl} + +If you did not request a password reset, please ignore this email or contact +support if you have questions: + +${supportUrl} + +Thanks, +The Human Connection Team + +If you're having trouble with the link above, you can manually copy and +paste the following code into your browser window: + +${code} + +Human Connection gemeinnützige GmbH +Bahnhofstr. 11 +73235 Weilheim / Teck +Deutschland + `, + } +} + +export const wrongAccountMail = options => { + const { + email, + subject = `We received a request to reset your password with this email address (${email})`, + supportUrl = 'https://human-connection.org/en/contact/', + } = options + const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI) + return { + to: email, + subject, + text: ` +We received a request to reset the password to access Human Connection with your +email address, but we were unable to find an account associated with this +address. + +If you use Human Connection and were expecting this email, consider trying to +request a password reset using the email address associated with your account. +Try a different email: + +${actionUrl} + +If you do not use Human Connection or did not request a password reset, please +ignore this email. Feel free to contact support if you have further questions: + +${supportUrl} + +Thanks, +The Human Connection Team + +Human Connection gemeinnützige GmbH +Bahnhofstr. 11 +73235 Weilheim / Teck +Deutschland + `, + } +} diff --git a/backend/src/schema/resolvers/user_management.js b/backend/src/schema/resolvers/user_management.js index eb07a07b3..e33314f7e 100644 --- a/backend/src/schema/resolvers/user_management.js +++ b/backend/src/schema/resolvers/user_management.js @@ -59,7 +59,7 @@ export default { changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => { const session = driver.session() let result = await session.run( - `MATCH (user:User {email: $userEmail}) + `MATCH (user:User {email: $userEmail}) RETURN user {.id, .email, .password}`, { userEmail: user.email, diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 2a8be9e09..1ef83bac3 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -25,6 +25,8 @@ type Mutation { login(email: String!, password: String!): String! signup(email: String!, password: String!): Boolean! changePassword(oldPassword: String!, newPassword: String!): String! + requestPasswordReset(email: String!): Boolean! + resetPassword(email: String!, code: String!, newPassword: String!): Boolean! report(id: ID!, description: String): Report disable(id: ID!): ID enable(id: ID!): ID diff --git a/backend/yarn.lock b/backend/yarn.lock index c3e72f4a6..dee5b8d20 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1110,10 +1110,10 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0" integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA== -"@types/yup@0.26.16": - version "0.26.16" - resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.16.tgz#75c428236207c48d9f8062dd1495cda8c5485a15" - integrity sha512-E2RNc7DSeQ+2EIJ1H3+yFjYu6YiyQBUJ7yNpIxomrYJ3oFizLZ5yDS3T1JTUNBC2OCRkgnhLS0smob5UuCHfNA== +"@types/yup@0.26.17": + version "0.26.17" + resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.26.17.tgz#5cb7cfc211d8e985b21d88289542591c92cad9dc" + integrity sha512-MN7VHlPsZQ2MTBxLE2Gl+Qfg2WyKsoz+vIr8xN0OSZ4AvJDrrKBlxc8b59UXCCIG9tPn9XhxTXh3j/htHbzC2Q== "@types/zen-observable@^0.5.3": version "0.5.4" @@ -1342,18 +1342,6 @@ apollo-engine-reporting-protobuf@0.3.1: dependencies: protobufjs "^6.8.6" -apollo-engine-reporting@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.3.1.tgz#f2c2c63f865871a57c15cdbb2a3bcd4b4af28115" - integrity sha512-e0Xp+0yite8DH/xm9fnJt42CxfWAcY6waiq3icCMAgO9T7saXzVOPpl84SkuA+hIJUBtfaKrTnC+7Jxi/I7OrQ== - dependencies: - apollo-engine-reporting-protobuf "0.3.1" - apollo-graphql "^0.3.0" - apollo-server-core "2.6.3" - apollo-server-env "2.4.0" - async-retry "^1.2.1" - graphql-extensions "0.7.2" - apollo-engine-reporting@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.3.2.tgz#b2569f79eb1a7a7380f49340db61465f449284fe" @@ -1383,14 +1371,6 @@ apollo-errors@^1.9.0: assert "^1.4.1" extendable-error "^0.1.5" -apollo-graphql@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.3.1.tgz#d13b80cc0cae3fe7066b81b80914c6f983fac8d7" - integrity sha512-tbhtzNAAhNI34v4XY9OlZGnH7U0sX4BP1cJrUfSiNzQnZRg1UbQYZ06riHSOHpi5RSndFcA9LDM5C1ZKKOUeBg== - dependencies: - apollo-env "0.5.1" - lodash.sortby "^4.7.0" - apollo-graphql@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.3.2.tgz#8881a87f1d5fcf80837b34dba90737e664eabe9a" @@ -1442,32 +1422,6 @@ apollo-server-caching@0.4.0: dependencies: lru-cache "^5.0.0" -apollo-server-core@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.3.tgz#786c8251c82cf29acb5cae9635a321f0644332ae" - integrity sha512-tfC0QO1NbJW3ShkB5pRCnUaYEkW2AwnswaTeedkfv//EO3yiC/9LeouCK5F22T8stQG+vGjvCqf0C8ldI/XsIA== - dependencies: - "@apollographql/apollo-tools" "^0.3.6" - "@apollographql/graphql-playground-html" "1.6.20" - "@types/ws" "^6.0.0" - apollo-cache-control "0.7.2" - apollo-datasource "0.5.0" - apollo-engine-reporting "1.3.1" - apollo-server-caching "0.4.0" - apollo-server-env "2.4.0" - apollo-server-errors "2.3.0" - apollo-server-plugin-base "0.5.2" - apollo-tracing "0.7.2" - fast-json-stable-stringify "^2.0.0" - graphql-extensions "0.7.2" - graphql-subscriptions "^1.0.0" - graphql-tag "^2.9.2" - graphql-tools "^4.0.0" - graphql-upload "^8.0.2" - sha.js "^2.4.11" - subscriptions-transport-ws "^0.9.11" - ws "^6.0.0" - apollo-server-core@2.6.4: version "2.6.4" resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.6.4.tgz#0372e3a28f221b9db83bdfbb0fd0b2960cd29bab" @@ -1516,10 +1470,10 @@ apollo-server-errors@2.3.0: resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.3.0.tgz#700622b66a16dffcad3b017e4796749814edc061" integrity sha512-rUvzwMo2ZQgzzPh2kcJyfbRSfVKRMhfIlhY7BzUfM4x6ZT0aijlgsf714Ll3Mbf5Fxii32kD0A/DmKsTecpccw== -apollo-server-express@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.3.tgz#62034c978f84207615c0430fb37ab006f71146fe" - integrity sha512-8ca+VpKArgNzFar0D3DesWnn0g9YDtFLhO56TQprHh2Spxu9WxTnYNjsYs2MCCNf+iV/uy7vTvEknErvnIcZaQ== +apollo-server-express@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.6.4.tgz#fc1d661be73fc1880aa53a56e1abe3733d08eada" + integrity sha512-U6hiZxty/rait39V5d+QeueNHlwfl68WbYtsutDUVxnq2Jws2ZDrvIkaWWN6HQ77+nBy5gGVxycvWIyoHHfi+g== dependencies: "@apollographql/graphql-playground-html" "1.6.20" "@types/accepts" "^1.3.5" @@ -1527,7 +1481,7 @@ apollo-server-express@2.6.3: "@types/cors" "^2.8.4" "@types/express" "4.17.0" accepts "^1.3.5" - apollo-server-core "2.6.3" + apollo-server-core "2.6.4" body-parser "^1.18.3" cors "^2.8.4" graphql-subscriptions "^1.0.0" @@ -1555,11 +1509,6 @@ apollo-server-module-graphiql@^1.3.4, apollo-server-module-graphiql@^1.4.0: resolved "https://registry.yarnpkg.com/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.4.0.tgz#c559efa285578820709f1769bb85d3b3eed3d8ec" integrity sha512-GmkOcb5he2x5gat+TuiTvabnBf1m4jzdecal3XbXBh/Jg+kx4hcvO3TTDFQ9CuTprtzdcVyA11iqG7iOMOt7vA== -apollo-server-plugin-base@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.2.tgz#f97ba983f1e825fec49cba8ff6a23d00e1901819" - integrity sha512-j81CpadRLhxikBYHMh91X4aTxfzFnmmebEiIR9rruS6dywWCxV2aLW87l9ocD1MiueNam0ysdwZkX4F3D4csNw== - apollo-server-plugin-base@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.5.3.tgz#234c6330c412a2e83ff49305a0c2f991fb40a266" @@ -1572,13 +1521,13 @@ apollo-server-testing@~2.6.4: dependencies: apollo-server-core "2.6.4" -apollo-server@~2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.3.tgz#71235325449c6d3881a5143975ca44c07a07d2d7" - integrity sha512-pTIXE5xEMAikKLTIBIqLNvimMETiZbzmiqDb6BGzIUicAz4Rxa1/+bDi1ZeJWrZQjE/TfBLd2Si3qam7dZGrjw== +apollo-server@~2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.6.4.tgz#34b3a50135e20b8df8c194a14e4636eb9c2898b2" + integrity sha512-f0TZOc969XNNlSm8sVsU34D8caQfPNwS0oqmWUxb8xXl88HlFzB+HBmOU6ZEKdpMCksTNDbqYo0jXiGJ0rL/0g== dependencies: - apollo-server-core "2.6.3" - apollo-server-express "2.6.3" + apollo-server-core "2.6.4" + apollo-server-express "2.6.4" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" @@ -3840,12 +3789,12 @@ graphql-request@~1.8.2: dependencies: cross-fetch "2.2.2" -graphql-shield@~5.6.1: - version "5.6.1" - resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.6.1.tgz#f4c9fb5ed329f823a738ad974b300d4a982691ca" - integrity sha512-Zrxrvx1Ep/nDdfQh/wN5PrH9JE4OEFdUmLzuyZSIGIAQWyXDk8FAl0cuNulnqI+zqrDzZ9TUj/zO3oV4hNKqCA== +graphql-shield@~5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-5.7.1.tgz#04095fb8148a463997f7c509d4aeb2a6abf79f98" + integrity sha512-UZ0K1uAqRAoGA1U2DsUu4vIZX2Vents4Xim99GFEUBTgvSDkejiE+k/Dywqfu76lJFEE8qu3vG5fhJN3SmnKbA== dependencies: - "@types/yup" "0.26.16" + "@types/yup" "0.26.17" lightercollective "^0.3.0" object-hash "^1.3.1" yup "^0.27.0" @@ -5762,6 +5711,11 @@ node-releases@^1.1.19: dependencies: semver "^5.3.0" +nodemailer@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.2.1.tgz#20d773925eb8f7a06166a0b62c751dc8290429f3" + integrity sha512-TagB7iuIi9uyNgHExo8lUDq3VK5/B0BpbkcjIgNvxbtVrjNqq0DwAOTuzALPVkK76kMhTSzIgHqg8X1uklVs6g== + nodemon@~1.19.1: version "1.19.1" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.19.1.tgz#576f0aad0f863aabf8c48517f6192ff987cd5071" diff --git a/deployment/human-connection/templates/secrets.template.yaml b/deployment/human-connection/templates/secrets.template.yaml index 8f18dbf46..9f59b948a 100644 --- a/deployment/human-connection/templates/secrets.template.yaml +++ b/deployment/human-connection/templates/secrets.template.yaml @@ -5,6 +5,11 @@ data: MONGODB_PASSWORD: "TU9OR09EQl9QQVNTV09SRA==" PRIVATE_KEY_PASSPHRASE: "YTdkc2Y3OHNhZGc4N2FkODdzZmFnc2FkZzc4" MAPBOX_TOKEN: "cGsuZXlKMUlqb2lhSFZ0WVc0dFkyOXVibVZqZEdsdmJpSXNJbUVpT2lKamFqbDBjbkJ1Ykdvd2VUVmxNM1Z3WjJsek5UTnVkM1p0SW4wLktaOEtLOWw3MG9talhiRWtrYkhHc1EK" + SMTP_HOST: + SMTP_PORT: 587 + SMTP_USERNAME: + SMTP_PASSWORD: + SMTP_IGNORE_TLS: metadata: name: human-connection namespace: human-connection diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/badges.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/badges.cql index 62cd4a2cc..027cea019 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/badges.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/badges.cql @@ -45,7 +45,7 @@ MERGE(b:Badge {id: badge._id["$oid"]}) ON CREATE SET b.key = badge.key, b.type = badge.type, -b.icon = badge.image.path, +b.icon = replace(badge.image.path, 'https://api-alpha.human-connection.org', ''), b.status = badge.status, b.createdAt = badge.createdAt.`$date`, b.updatedAt = badge.updatedAt.`$date` diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions.cql index 98d8f24e9..472354763 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/contributions.cql @@ -28,7 +28,7 @@ [?] unique: true, // Unique value is not enforced in Nitro? [-] index: true }, -[ ] type: { +[ ] type: { // db.getCollection('contributions').distinct('type') -> 'DELETED', 'cando', 'post' [ ] type: String, [ ] required: true, [-] index: true @@ -50,7 +50,7 @@ [?] required: true // Not required in Nitro }, [ ] hasMore: { type: Boolean }, -[?] teaserImg: { type: String }, // Path is incorrect in Nitro +[X] teaserImg: { type: String }, [ ] language: { [ ] type: String, [ ] required: true, @@ -131,7 +131,7 @@ MERGE (p:Post {id: post._id["$oid"]}) ON CREATE SET p.title = post.title, p.slug = post.slug, -p.image = post.teaserImg, +p.image = replace(post.teaserImg, 'https://api-alpha.human-connection.org', ''), p.content = post.content, p.contentExcerpt = post.contentExcerpt, p.visibility = toLower(post.visibility), diff --git a/deployment/legacy-migration/maintenance-worker/migration/neo4j/users.cql b/deployment/legacy-migration/maintenance-worker/migration/neo4j/users.cql index aec5499fc..4d7c9aa9f 100644 --- a/deployment/legacy-migration/maintenance-worker/migration/neo4j/users.cql +++ b/deployment/legacy-migration/maintenance-worker/migration/neo4j/users.cql @@ -49,8 +49,8 @@ } }, [ ] timezone: { type: String }, -[?] avatar: { type: String }, // Path is incorrect in Nitro -[?] coverImg: { type: String }, // Path is incorrect in Nitro, was not modeled in latest Nitro - do we want this? +[X] avatar: { type: String }, +[X] coverImg: { type: String }, [ ] doiToken: { type: String }, [ ] confirmedAt: { type: Date }, [?] badgeIds: [], // Verify this is working properly @@ -102,8 +102,8 @@ u.name = user.name, u.slug = user.slug, u.email = user.email, u.password = user.password, -u.avatar = user.avatar, -u.coverImg = user.coverImg, +u.avatar = replace(user.avatar, 'https://api-alpha.human-connection.org', ''), +u.coverImg = replace(user.coverImg, 'https://api-alpha.human-connection.org', ''), u.wasInvited = user.wasInvited, u.wasSeeded = user.wasSeeded, u.role = toLower(user.role), diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a71418229..016984d3b 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,6 +1,12 @@ version: "3.4" services: + mailserver: + image: djfarrelly/maildev + ports: + - 1080:80 + networks: + - hc-network webapp: build: context: webapp @@ -20,6 +26,10 @@ services: - backend_node_modules:/nitro-backend/node_modules - uploads:/nitro-backend/public/uploads command: yarn run dev + environment: + - SMTP_HOST=mailserver + - SMTP_PORT=25 + - SMTP_IGNORE_TLS=true neo4j: environment: - NEO4J_AUTH=none diff --git a/webapp/components/DeleteData/DeleteData.vue b/webapp/components/DeleteData/DeleteData.vue index cac548fa4..14b6bc9c3 100644 --- a/webapp/components/DeleteData/DeleteData.vue +++ b/webapp/components/DeleteData/DeleteData.vue @@ -83,6 +83,7 @@ export default { deleteContributions: false, deleteComments: false, deleteEnabled: false, + enableDeletionValue: null, } }, computed: { diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js index 030ad20da..e345531d0 100644 --- a/webapp/components/FilterMenu/FilterMenu.spec.js +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -1,10 +1,12 @@ import { mount, createLocalVue } from '@vue/test-utils' import FilterMenu from './FilterMenu.vue' import Styleguide from '@human-connection/styleguide' +import VTooltip from 'v-tooltip' const localVue = createLocalVue() localVue.use(Styleguide) +localVue.use(VTooltip) describe('FilterMenu.vue', () => { let wrapper diff --git a/webapp/components/Password/Change.vue b/webapp/components/Password/Change.vue index 95da2a7be..63c797157 100644 --- a/webapp/components/Password/Change.vue +++ b/webapp/components/Password/Change.vue @@ -11,18 +11,21 @@ id="oldPassword" model="oldPassword" type="password" + autocomplete="off" :label="$t('settings.security.change-password.label-old-password')" /> diff --git a/webapp/components/PasswordReset/ChangePassword.spec.js b/webapp/components/PasswordReset/ChangePassword.spec.js new file mode 100644 index 000000000..88caa6c6d --- /dev/null +++ b/webapp/components/PasswordReset/ChangePassword.spec.js @@ -0,0 +1,83 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import ChangePassword from './ChangePassword' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('ChangePassword ', () => { + let wrapper + let Wrapper + let mocks + let propsData + + beforeEach(() => { + propsData = {} + mocks = { + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $t: jest.fn(), + $apollo: { + loading: false, + mutate: jest.fn().mockResolvedValue({ data: { resetPassword: true } }), + }, + } + }) + + describe('mount', () => { + beforeEach(jest.useFakeTimers) + + Wrapper = () => { + return mount(ChangePassword, { + mocks, + propsData, + localVue, + }) + } + + describe('given email and verification code', () => { + beforeEach(() => { + propsData.email = 'mail@example.org' + propsData.code = '123456' + }) + + describe('submitting new password', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.find('input#newPassword').setValue('supersecret') + wrapper.find('input#confirmPassword').setValue('supersecret') + wrapper.find('form').trigger('submit') + }) + + it('calls resetPassword graphql mutation', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalled() + }) + + it('delivers new password to backend', () => { + const expected = expect.objectContaining({ + variables: { code: '123456', email: 'mail@example.org', newPassword: 'supersecret' }, + }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + describe('password reset successful', () => { + it('displays success message', () => { + const expected = 'verify-code.form.change-password.success' + expect(mocks.$t).toHaveBeenCalledWith(expected) + }) + + describe('after animation', () => { + beforeEach(jest.runAllTimers) + + it('emits `change-password-sucess`', () => { + expect(wrapper.emitted('passwordResetResponse')).toEqual([['success']]) + }) + }) + }) + }) + }) + }) +}) diff --git a/webapp/components/PasswordReset/ChangePassword.vue b/webapp/components/PasswordReset/ChangePassword.vue new file mode 100644 index 000000000..5a12f9938 --- /dev/null +++ b/webapp/components/PasswordReset/ChangePassword.vue @@ -0,0 +1,140 @@ + + + diff --git a/webapp/components/PasswordReset/Request.spec.js b/webapp/components/PasswordReset/Request.spec.js new file mode 100644 index 000000000..c9128a70e --- /dev/null +++ b/webapp/components/PasswordReset/Request.spec.js @@ -0,0 +1,77 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import Request from './Request' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('Request', () => { + let wrapper + let Wrapper + let mocks + + beforeEach(() => { + mocks = { + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $t: jest.fn(), + $apollo: { + loading: false, + mutate: jest.fn().mockResolvedValue({ data: { reqestPasswordReset: true } }), + }, + } + }) + + describe('mount', () => { + beforeEach(jest.useFakeTimers) + + Wrapper = () => { + return mount(Request, { + mocks, + localVue, + }) + } + + it('renders a password reset form', () => { + wrapper = Wrapper() + expect(wrapper.find('.password-reset').exists()).toBe(true) + }) + + describe('submit', () => { + beforeEach(async () => { + wrapper = Wrapper() + wrapper.find('input#email').setValue('mail@example.org') + await wrapper.find('form').trigger('submit') + }) + + it('calls requestPasswordReset graphql mutation', () => { + expect(mocks.$apollo.mutate).toHaveBeenCalled() + }) + + it('delivers email to backend', () => { + const expected = expect.objectContaining({ variables: { email: 'mail@example.org' } }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('hides form to avoid re-submission', () => { + expect(wrapper.find('form').exists()).not.toBeTruthy() + }) + + it('displays a message that a password email was requested', () => { + const expected = ['password-reset.form.submitted', { email: 'mail@example.org' }] + expect(mocks.$t).toHaveBeenCalledWith(...expected) + }) + + describe('after animation', () => { + beforeEach(jest.runAllTimers) + + it('emits `handleSubmitted`', () => { + expect(wrapper.emitted('handleSubmitted')).toEqual([[{ email: 'mail@example.org' }]]) + }) + }) + }) + }) +}) diff --git a/webapp/components/PasswordReset/Request.vue b/webapp/components/PasswordReset/Request.vue new file mode 100644 index 000000000..8ca2da89b --- /dev/null +++ b/webapp/components/PasswordReset/Request.vue @@ -0,0 +1,107 @@ + + + diff --git a/webapp/components/PasswordReset/VerifyCode.spec.js b/webapp/components/PasswordReset/VerifyCode.spec.js new file mode 100644 index 000000000..22cdfd885 --- /dev/null +++ b/webapp/components/PasswordReset/VerifyCode.spec.js @@ -0,0 +1,53 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import VerifyCode from './VerifyCode' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('VerifyCode ', () => { + let wrapper + let Wrapper + let mocks + let propsData + + beforeEach(() => { + mocks = { + $t: jest.fn(), + } + propsData = { + email: 'mail@example.org', + } + }) + + describe('mount', () => { + beforeEach(jest.useFakeTimers) + + Wrapper = () => { + return mount(VerifyCode, { + mocks, + localVue, + propsData, + }) + } + + it('renders a verify code form', () => { + wrapper = Wrapper() + expect(wrapper.find('.verify-code').exists()).toBe(true) + }) + + describe('after verification code given', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.find('input#code').setValue('123456') + wrapper.find('form').trigger('submit') + }) + + it('emits `verifyCode`', () => { + const expected = [[{ code: '123456', email: 'mail@example.org' }]] + expect(wrapper.emitted('verification')).toEqual(expected) + }) + }) + }) +}) diff --git a/webapp/components/PasswordReset/VerifyCode.vue b/webapp/components/PasswordReset/VerifyCode.vue new file mode 100644 index 000000000..de1495e36 --- /dev/null +++ b/webapp/components/PasswordReset/VerifyCode.vue @@ -0,0 +1,67 @@ + + + diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 5110590ab..ebffe91b0 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -8,11 +8,32 @@ "logout": "Ausloggen", "email": "Deine E-Mail", "password": "Dein Passwort", + "forgotPassword": "Passwort vergessen?", "moreInfo": "Was ist Human Connection?", "moreInfoURL": "https://human-connection.org", "moreInfoHint": "zur Präsentationsseite", "hello": "Hallo" }, + "password-reset": { + "title": "Passwort zurücksetzen", + "form": { + "description": "Eine Mail zum Zurücksetzen des Passworts wird an die angegebene E-Mail Adresse geschickt.", + "submit": "Email anfordern", + "submitted": "Eine E-Mail mit weiteren Instruktionen wurde verschickt an {email}" + } + }, + "verify-code": { + "form": { + "code": "Code eingeben", + "description": "Öffne dein E-Mail Postfach und gib den Code ein, den wir geschickt haben.", + "next": "Weiter", + "change-password":{ + "success": "Änderung des Passworts war erfolgreich!", + "error": "Passwort Änderung fehlgeschlagen. Möglicherweise falscher Sicherheitscode?", + "help": "Falls Probleme auftreten, schreib uns gerne eine Mail an:" + } + } + }, "editor": { "placeholder": "Schreib etwas Inspirierendes..." }, @@ -197,7 +218,11 @@ "name": "Name", "loadMore": "mehr laden", "loading": "wird geladen", - "reportContent": "Melden" + "reportContent": "Melden", + "validations": { + "email": "muss eine gültige E-Mail Adresse sein", + "verification-code": "muss genau 6 Buchstaben lang sein" + } }, "actions": { "loading": "lade", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 076b76da1..b4ac01fbb 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -8,11 +8,32 @@ "logout": "Logout", "email": "Your Email", "password": "Your Password", + "forgotPassword": "Forgot Password?", "moreInfo": "What is Human Connection?", "moreInfoURL": "https://human-connection.org/en/", "moreInfoHint": "to the presentation page", "hello": "Hello" }, + "password-reset": { + "title": "Reset your password", + "form": { + "description": "A password reset email will be sent to the given email address.", + "submit": "Request email", + "submitted": "A mail with further instruction has been sent to {email}" + } + }, + "verify-code": { + "form": { + "code": "Enter your code", + "description": "Open your inbox and enter the code that we've sent to you.", + "next": "Continue", + "change-password": { + "success": "Changing your password was successful!", + "error": "Changing your password failed. Maybe the security code was not correct?", + "help": "In case of problems, feel free to ask for help by sending us a mail to:" + } + } + }, "editor": { "placeholder": "Leave your inspirational thoughts..." }, @@ -198,7 +219,11 @@ "name": "Name", "loadMore": "load more", "loading": "loading", - "reportContent": "Report" + "reportContent": "Report", + "validations": { + "email": "must be a valid email address", + "verification-code": "must be 6 characters long" + } }, "actions": { "loading": "loading", diff --git a/webapp/nuxt.config.js b/webapp/nuxt.config.js index 49f2f5d0a..7383f408a 100644 --- a/webapp/nuxt.config.js +++ b/webapp/nuxt.config.js @@ -25,7 +25,18 @@ module.exports = { env: { // pages which do NOT require a login - publicPages: ['login', 'logout', 'register', 'signup', 'reset', 'reset-token', 'pages-slug'], + publicPages: [ + 'login', + 'logout', + 'password-reset-request', + 'password-reset-verify-code', + 'password-reset-change-password', + 'register', + 'signup', + 'reset', + 'reset-token', + 'pages-slug', + ], // pages to keep alive keepAlivePages: ['index'], // active locales diff --git a/webapp/package.json b/webapp/package.json index aeafeb3ff..7f78b83ee 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -29,6 +29,7 @@ "!**/?(*.)+(spec|test).js?(x)" ], "coverageReporters": [ + "text", "lcov" ], "transform": { @@ -94,7 +95,7 @@ "eslint-config-standard": "~12.0.0", "eslint-loader": "~2.1.2", "eslint-plugin-import": "~2.17.3", - "eslint-plugin-jest": "~22.6.4", + "eslint-plugin-jest": "~22.7.0", "eslint-plugin-node": "~9.1.0", "eslint-plugin-prettier": "~3.1.0", "eslint-plugin-promise": "~4.1.1", @@ -110,4 +111,4 @@ "vue-jest": "~3.0.4", "vue-svg-loader": "~0.12.0" } -} +} \ No newline at end of file diff --git a/webapp/pages/login.vue b/webapp/pages/login.vue index 7520eaa5b..2679b2e99 100644 --- a/webapp/pages/login.vue +++ b/webapp/pages/login.vue @@ -45,6 +45,11 @@ name="password" type="password" /> + + + {{ $t('login.forgotPassword') }} + + + + + + + + + + + + + + diff --git a/webapp/pages/password-reset/change-password.vue b/webapp/pages/password-reset/change-password.vue new file mode 100644 index 000000000..12ccc192a --- /dev/null +++ b/webapp/pages/password-reset/change-password.vue @@ -0,0 +1,28 @@ + + + diff --git a/webapp/pages/password-reset/request.vue b/webapp/pages/password-reset/request.vue new file mode 100644 index 000000000..b7e5fe21a --- /dev/null +++ b/webapp/pages/password-reset/request.vue @@ -0,0 +1,18 @@ + + + diff --git a/webapp/pages/password-reset/verify-code.vue b/webapp/pages/password-reset/verify-code.vue new file mode 100644 index 000000000..c814ea9ba --- /dev/null +++ b/webapp/pages/password-reset/verify-code.vue @@ -0,0 +1,22 @@ + + + diff --git a/webapp/yarn.lock b/webapp/yarn.lock index c26035f8e..ba2cf19bc 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4300,10 +4300,10 @@ eslint-plugin-import@~2.17.3: read-pkg-up "^2.0.0" resolve "^1.11.0" -eslint-plugin-jest@~22.6.4: - version "22.6.4" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.6.4.tgz#2895b047dd82f90f43a58a25cf136220a21c9104" - integrity sha512-36OqnZR/uMCDxXGmTsqU4RwllR0IiB/XF8GW3ODmhsjiITKuI0GpgultWFt193ipN3HARkaIcKowpE6HBvRHNg== +eslint-plugin-jest@~22.7.0: + version "22.7.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.7.0.tgz#a1d325bccb024b04f5354c56fe790baba54a454c" + integrity sha512-0U9nBd9V6+GKpM/KvRDcmMuPsewSsdM7NxCozgJkVAh8IrwHmQ0aw44/eYuVkhT8Fcdhsz0zYiyPtKg147eXMQ== eslint-plugin-node@~9.1.0: version "9.1.0"