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 @@ + + + + + + {{ $t('registration.create-user-account.success') }} + + + + + + + + + + + + + + {{ errors.message }} + + + + {{ $t('actions.save') }} + + + + + + + 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 @@ + + + + + {{ $t('registration.signup.title') }} + + + + + + {{ $t('registration.signup.form.description') }} + + + + + {{ $t('registration.signup.form.submit') }} + + + + + + + + + + + {{ error.message }} + + + + + + + + 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 @@ + + + + + {{ $t('admin.invites.title') }} + + + {{ $t('admin.invites.description') }} + + + + + + + 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 @@ + + + + +