From d76923c47171202eb2b961bb8bda709685265970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Jul 2019 16:56:06 +0200 Subject: [PATCH 01/16] Copy all relevant files changed from branch `signup` --- .../Registration/CreateUserAccount.spec.js | 125 +++++++++++++ .../Registration/CreateUserAccount.vue | 169 ++++++++++++++++++ webapp/components/Registration/Signup.spec.js | 145 +++++++++++++++ webapp/components/Registration/Signup.vue | 141 +++++++++++++++ webapp/locales/de.json | 26 ++- webapp/locales/en.json | 27 ++- webapp/nuxt.config.js | 8 +- webapp/pages/admin.vue | 4 + webapp/pages/admin/invite.vue | 23 +++ webapp/pages/login.vue | 2 +- .../registration/create-user-account.vue | 27 +++ 11 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 webapp/components/Registration/CreateUserAccount.spec.js create mode 100644 webapp/components/Registration/CreateUserAccount.vue create mode 100644 webapp/components/Registration/Signup.spec.js create mode 100644 webapp/components/Registration/Signup.vue create mode 100644 webapp/pages/admin/invite.vue create mode 100644 webapp/pages/registration/create-user-account.vue diff --git a/webapp/components/Registration/CreateUserAccount.spec.js b/webapp/components/Registration/CreateUserAccount.spec.js new file mode 100644 index 000000000..f8364ca0d --- /dev/null +++ b/webapp/components/Registration/CreateUserAccount.spec.js @@ -0,0 +1,125 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import CreateUserAccount, { SignupVerificationMutation } from './CreateUserAccount' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('CreateUserAccount', () => { + let wrapper + let Wrapper + let mocks + let propsData + + beforeEach(() => { + mocks = { + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $t: jest.fn(), + $apollo: { + loading: false, + mutate: jest.fn(), + }, + } + propsData = {} + }) + + describe('mount', () => { + Wrapper = () => { + return mount(CreateUserAccount, { + mocks, + propsData, + localVue, + }) + } + + describe('given email and nonce', () => { + beforeEach(() => { + propsData.nonce = '666777' + propsData.email = 'sixseven@example.org' + }) + + it('renders a form to create a new user', () => { + wrapper = Wrapper() + expect(wrapper.find('.create-user-account').exists()).toBe(true) + }) + + describe('submit', () => { + let action + beforeEach(() => { + action = async () => { + wrapper = Wrapper() + wrapper.find('input#name').setValue('John Doe') + wrapper.find('input#password').setValue('hellopassword') + wrapper.find('input#confirmPassword').setValue('hellopassword') + await wrapper.find('form').trigger('submit') + } + }) + + it('calls CreateUserAccount graphql mutation', async () => { + await action() + const expected = expect.objectContaining({ mutation: SignupVerificationMutation }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('delivers data to backend', async () => { + await action() + const expected = expect.objectContaining({ + variables: { + about: '', + name: 'John Doe', + email: 'sixseven@example.org', + nonce: '666777', + password: 'hellopassword', + }, + }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + describe('in case mutation resolves', () => { + beforeEach(() => { + mocks.$apollo.mutate = jest.fn().mockResolvedValue({ + data: { + SignupVerification: { + id: 'u1', + name: 'John Doe', + slug: 'john-doe', + }, + }, + }) + }) + + it('displays success', async () => { + await action() + expect(mocks.$t).toHaveBeenCalledWith('registration.create-user-account.success') + }) + + describe('after timeout', () => { + beforeEach(jest.useFakeTimers) + + it('emits `userCreated` with user', async () => { + await action() + jest.runAllTimers() + expect(wrapper.emitted('userCreated')).toBeTruthy() + }) + }) + }) + + describe('in case mutation rejects', () => { + beforeEach(() => { + mocks.$apollo.mutate.mockRejectedValue(new Error('Invalid nonce')) + }) + + it('displays form errors', async () => { + await action() + jest.runAllTimers() + expect(wrapper.find('.errors').text()).toContain('Invalid nonce') + }) + }) + }) + }) + }) +}) diff --git a/webapp/components/Registration/CreateUserAccount.vue b/webapp/components/Registration/CreateUserAccount.vue new file mode 100644 index 000000000..f38ebe509 --- /dev/null +++ b/webapp/components/Registration/CreateUserAccount.vue @@ -0,0 +1,169 @@ + + + diff --git a/webapp/components/Registration/Signup.spec.js b/webapp/components/Registration/Signup.spec.js new file mode 100644 index 000000000..eb0d65d42 --- /dev/null +++ b/webapp/components/Registration/Signup.spec.js @@ -0,0 +1,145 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import Signup, { SignupMutation, SignupByInvitationMutation } from './Signup' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('Signup', () => { + let wrapper + let Wrapper + let mocks + let propsData + + beforeEach(() => { + mocks = { + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + $t: jest.fn(), + $apollo: { + loading: false, + mutate: jest.fn().mockResolvedValue({ data: { Signup: { email: 'mail@example.org' } } }), + }, + } + propsData = {} + }) + + describe('mount', () => { + beforeEach(jest.useFakeTimers) + + Wrapper = () => { + return mount(Signup, { + mocks, + propsData, + localVue, + }) + } + + describe('without invitation code', () => { + it('renders signup form', () => { + wrapper = Wrapper() + expect(wrapper.find('.signup').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 Signup graphql mutation', () => { + const expected = expect.objectContaining({ mutation: SignupMutation }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('delivers email to backend', () => { + const expected = expect.objectContaining({ + variables: { email: 'mail@example.org', token: null }, + }) + 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 mail for email verification was sent', () => { + const expected = ['registration.signup.form.success', { 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' }]]) + }) + }) + }) + }) + + describe('with invitation code', () => { + let action + beforeEach(() => { + propsData.token = '666777' + action = async () => { + wrapper = Wrapper() + wrapper.find('input#email').setValue('mail@example.org') + await wrapper.find('form').trigger('submit') + } + }) + + describe('submit', () => { + it('calls SignupByInvitation graphql mutation', async () => { + await action() + const expected = expect.objectContaining({ mutation: SignupByInvitationMutation }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + it('delivers invitation token to backend', async () => { + await action() + const expected = expect.objectContaining({ + variables: { email: 'mail@example.org', token: '666777' }, + }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected) + }) + + describe('in case a user account with the email already exists', () => { + beforeEach(() => { + mocks.$apollo.mutate = jest + .fn() + .mockRejectedValue( + new Error('UserInputError: User account with this email already exists.'), + ) + }) + + it.skip('explains the error', async () => { + await action() + expect(mocks.$t).toHaveBeenCalledWith('registration.signup.form.errors.email-exists') + }) + }) + + describe('in case the invitation code was incorrect', () => { + beforeEach(() => { + mocks.$apollo.mutate = jest + .fn() + .mockRejectedValue( + new Error('UserInputError: Invitation code already used or does not exist.'), + ) + }) + + it.skip('explains the error', async () => { + await action() + expect(mocks.$t).toHaveBeenCalledWith( + 'registration.signup.form.errors.invalid-invitation-token', + ) + }) + }) + }) + }) + }) +}) diff --git a/webapp/components/Registration/Signup.vue b/webapp/components/Registration/Signup.vue new file mode 100644 index 000000000..3324b51c5 --- /dev/null +++ b/webapp/components/Registration/Signup.vue @@ -0,0 +1,141 @@ + + + diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 1df329fe1..4a6b0955e 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -14,7 +14,8 @@ "moreInfo": "Was ist Human Connection?", "moreInfoURL": "https://human-connection.org", "moreInfoHint": "zur Präsentationsseite", - "hello": "Hallo" + "hello": "Hallo", + "success": "Du bist eingeloggt!" }, "password-reset": { "title": "Passwort zurücksetzen", @@ -24,6 +25,24 @@ "submitted": "Eine E-Mail mit weiteren Instruktionen wurde verschickt an {email}" } }, + "registration": { + "signup": { + "title": "Mach mit bei Human Connection!", + "form": { + "description": "Um loszulegen, gib deine E-Mail Adresse ein:", + "errors": { + "email-exists": "Es gibt schon ein Benutzerkonto mit dieser E-Mail Adresse!", + "invalid-invitation-token": "Es sieht so aus, als ob der Einladungscode schon eingelöst wurde. Jeder Code kann nur einmalig benutzt werden." + }, + "submit": "Konto erstellen", + "success": "Eine Mail mit einem Bestätigungslink für die Registrierung wurde an {email} geschickt" + } + }, + "create-user-account": { + "title": "Benutzerkonto anlegen", + "success": "Dein Benutzerkonto wurde erstellt!" + } + }, "verify-code": { "form": { "code": "Code eingeben", @@ -174,6 +193,11 @@ }, "settings": { "name": "Einstellungen" + }, + "invites": { + "name": "Benutzer einladen", + "title": "Benutzer als Admin anmelden", + "description": "Dieses Anmeldeformular ist zu sehen sobald die Anmeldung öffentlich zugänglich ist." } }, "post": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index bdf6f85fb..2919a38ca 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -14,7 +14,8 @@ "moreInfo": "What is Human Connection?", "moreInfoURL": "https://human-connection.org/en/", "moreInfoHint": "to the presentation page", - "hello": "Hello" + "hello": "Hello", + "success": "You are logged in!" }, "password-reset": { "title": "Reset your password", @@ -24,6 +25,25 @@ "submitted": "A mail with further instruction has been sent to {email}" } }, + "registration": { + "signup": { + "title": "Join Human Connection!", + "form": { + "description": "To get started, enter your email address:", + "invitation-code": "Your invitation code is: {code}", + "errors": { + "email-exists": "There is already a user account with this email address!", + "invalid-invitation-token": "It looks like as if the invitation has been used already. Invitation links can only be used once." + }, + "submit": "Create an account", + "success": "A mail with a link to complete your registration has been sent to {email}" + } + }, + "create-user-account": { + "title": "Create user account", + "success": "Your account has been created!" + } + }, "verify-code": { "form": { "code": "Enter your code", @@ -174,6 +194,11 @@ }, "settings": { "name": "Settings" + }, + "invites": { + "name": "Invite users", + "title": "Signup users as admin", + "description": "This registration form will be visible as soon as the registration is open to the public." } }, "post": { diff --git a/webapp/nuxt.config.js b/webapp/nuxt.config.js index 7383f408a..cb3a3c643 100644 --- a/webapp/nuxt.config.js +++ b/webapp/nuxt.config.js @@ -31,10 +31,10 @@ module.exports = { 'password-reset-request', 'password-reset-verify-code', 'password-reset-change-password', - 'register', - 'signup', - 'reset', - 'reset-token', + // 'registration-signup', TODO: uncomment to open public registration + 'registration-signup-by-invitation-code', + 'registration-verify-code', + 'registration-create-user-account', 'pages-slug', ], // pages to keep alive diff --git a/webapp/pages/admin.vue b/webapp/pages/admin.vue index 0cbee05d5..1cf503808 100644 --- a/webapp/pages/admin.vue +++ b/webapp/pages/admin.vue @@ -54,6 +54,10 @@ export default { name: this.$t('admin.tags.name'), path: `/admin/tags`, }, + { + name: this.$t('admin.invites.name'), + path: `/admin/invite`, + }, // TODO implement /* { name: this.$t('admin.settings.name'), diff --git a/webapp/pages/admin/invite.vue b/webapp/pages/admin/invite.vue new file mode 100644 index 000000000..36e679112 --- /dev/null +++ b/webapp/pages/admin/invite.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/pages/login.vue b/webapp/pages/login.vue index 2679b2e99..e659d8448 100644 --- a/webapp/pages/login.vue +++ b/webapp/pages/login.vue @@ -115,7 +115,7 @@ export default { async onSubmit() { try { await this.$store.dispatch('auth/login', { ...this.form }) - this.$toast.success('You are logged in!') + this.$toast.success(this.$t('login.success')) this.$router.replace(this.$route.query.path || '/') } catch (err) { this.$toast.error(err.message) diff --git a/webapp/pages/registration/create-user-account.vue b/webapp/pages/registration/create-user-account.vue new file mode 100644 index 000000000..812e4ec60 --- /dev/null +++ b/webapp/pages/registration/create-user-account.vue @@ -0,0 +1,27 @@ + + + From 29bbeaa0fae4cd4d85dc0c64cdc8b847c6a192eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Jul 2019 17:42:53 +0200 Subject: [PATCH 02/16] Fix tests by calling `wrapper.html()` once more I really don't understand why, but apparently `wrapper.html()` does some re-rendering which in our cases fixes the tests, because we reach the new sub component in the DOM tree. --- .../Registration/CreateUserAccount.spec.js | 15 +++++++++++---- .../components/Registration/CreateUserAccount.vue | 5 +++-- webapp/components/Registration/Signup.spec.js | 5 +++-- webapp/pages/registration/create-user-account.vue | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/webapp/components/Registration/CreateUserAccount.spec.js b/webapp/components/Registration/CreateUserAccount.spec.js index f8364ca0d..05a11bc33 100644 --- a/webapp/components/Registration/CreateUserAccount.spec.js +++ b/webapp/components/Registration/CreateUserAccount.spec.js @@ -56,6 +56,7 @@ describe('CreateUserAccount', () => { wrapper.find('input#password').setValue('hellopassword') wrapper.find('input#confirmPassword').setValue('hellopassword') await wrapper.find('form').trigger('submit') + await wrapper.html() } }) @@ -100,22 +101,28 @@ describe('CreateUserAccount', () => { describe('after timeout', () => { beforeEach(jest.useFakeTimers) - it('emits `userCreated` with user', async () => { + it('emits `userCreated` with { password, email }', async () => { await action() jest.runAllTimers() - expect(wrapper.emitted('userCreated')).toBeTruthy() + expect(wrapper.emitted('userCreated')).toEqual([ + [ + { + email: 'sixseven@example.org', + password: 'hellopassword', + }, + ], + ]) }) }) }) describe('in case mutation rejects', () => { beforeEach(() => { - mocks.$apollo.mutate.mockRejectedValue(new Error('Invalid nonce')) + mocks.$apollo.mutate = jest.fn().mockRejectedValue(new Error('Invalid nonce')) }) it('displays form errors', async () => { await action() - jest.runAllTimers() expect(wrapper.find('.errors').text()).toContain('Invalid nonce') }) }) diff --git a/webapp/components/Registration/CreateUserAccount.vue b/webapp/components/Registration/CreateUserAccount.vue index f38ebe509..4b91ab216 100644 --- a/webapp/components/Registration/CreateUserAccount.vue +++ b/webapp/components/Registration/CreateUserAccount.vue @@ -143,12 +143,13 @@ export default { try { await this.$apollo.mutate({ mutation: SignupVerificationMutation, - variables: { name, password, about, email, nonce } + variables: { name, password, about, email, nonce }, }) this.success = true setTimeout(() => { this.$emit('userCreated', { - email, password + email, + password, }) }, 3000) } catch (err) { diff --git a/webapp/components/Registration/Signup.spec.js b/webapp/components/Registration/Signup.spec.js index eb0d65d42..e66b39bb7 100644 --- a/webapp/components/Registration/Signup.spec.js +++ b/webapp/components/Registration/Signup.spec.js @@ -90,6 +90,7 @@ describe('Signup', () => { wrapper = Wrapper() wrapper.find('input#email').setValue('mail@example.org') await wrapper.find('form').trigger('submit') + await wrapper.html() } }) @@ -117,7 +118,7 @@ describe('Signup', () => { ) }) - it.skip('explains the error', async () => { + it('explains the error', async () => { await action() expect(mocks.$t).toHaveBeenCalledWith('registration.signup.form.errors.email-exists') }) @@ -132,7 +133,7 @@ describe('Signup', () => { ) }) - it.skip('explains the error', async () => { + it('explains the error', async () => { await action() expect(mocks.$t).toHaveBeenCalledWith( 'registration.signup.form.errors.invalid-invitation-token', diff --git a/webapp/pages/registration/create-user-account.vue b/webapp/pages/registration/create-user-account.vue index 812e4ec60..677aea5eb 100644 --- a/webapp/pages/registration/create-user-account.vue +++ b/webapp/pages/registration/create-user-account.vue @@ -13,7 +13,7 @@ export default { CreateUserAccount, }, methods: { - async handleUserCreated({email, password}) { + async handleUserCreated({ email, password }) { try { await this.$store.dispatch('auth/login', { email, password }) this.$toast.success('You are logged in!') From 813a6b5c50e9b3ea39d6816d9026c809990bd72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Jul 2019 17:55:50 +0200 Subject: [PATCH 03/16] Subpages of registration/ must be unauthenticated --- webapp/pages/registration.vue | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 webapp/pages/registration.vue diff --git a/webapp/pages/registration.vue b/webapp/pages/registration.vue new file mode 100644 index 000000000..e75ec03e1 --- /dev/null +++ b/webapp/pages/registration.vue @@ -0,0 +1,14 @@ + + + From 90aa1822b5ed9757b7047a86a518782c8c51c78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Mon, 8 Jul 2019 18:13:24 +0200 Subject: [PATCH 04/16] Disable the form button with a slot scope --- webapp/components/Password/Change.vue | 12 +- .../PasswordReset/ChangePassword.vue | 62 +++++----- .../Registration/CreateUserAccount.vue | 108 +++++++++--------- 3 files changed, 80 insertions(+), 102 deletions(-) diff --git a/webapp/components/Password/Change.vue b/webapp/components/Password/Change.vue index 63c797157..ab9a89cbc 100644 --- a/webapp/components/Password/Change.vue +++ b/webapp/components/Password/Change.vue @@ -3,10 +3,8 @@ v-model="formData" :schema="formSchema" @submit="handleSubmit" - @input="handleInput" - @input-valid="handleInputValid" > -