Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

407 change your email address #1711

Closed
wants to merge 30 commits into from
Closed

407 change your email address #1711

wants to merge 30 commits into from

Conversation

Tirokk
Copy link
Member

@Tirokk Tirokk commented Oct 5, 2020

roschaefer Authored by roschaefer
Sep 24, 2019
Merged Oct 2, 2019


🍰 Pullrequest

Issues

  • None

Todo

  • make sure this feature does not bypass the Signup for admins only
  • validate email address on the backend

roschaefer and others added 30 commits October 2, 2019 00:54
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? 🤔
So, to get a direct link it's better to have one route that calls a
mutation as soon as it is visited.
@alina-beck said we have most buttons left-aligned, so I went with that:
Human-Connection/Human-Connection#1711 (comment)

Also this uses icon `envelope` for emails. This makes sense, because we
could use icon `at` for slugs.
`BELONGS_TO` means a user owns an email address. `PRIMARY_EMAIL` means a
user authenticates with that email.

So right now, you get a proper error message if you try to change your
email back to your old email address (because you own it already).

I will make sure to delete the old email so this will be no problem
anymore. But maybe in the future we might have multiple email addresses
per user and then it makes a big difference to use `PRIMARY_EMAIL` or
`BELONGS_TO`.
In the registration resolvers, it makes sense to immediately resolve if
an email address has been found (because you can re-send the
registration email).

In this case, we use the helper method only to trigger the `UserInputError`.
This will allow you to change back to your previous email address: The
backend won't complain because of a user who owns that email address
already.
@@ -0,0 +1,4 @@
import uuid from 'uuid/v4'
export default function generateNonce() {
return uuid().substring(0, 6)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Sep 28, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,4 @@
+import uuid from 'uuid/v4'
+export default function generateNonce() {
+  return uuid().substring(0, 6)

@datenbrei you wanted to know how the nonce gets generated

})
})
})
})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,283 @@
+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', () => {
+  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', () => {
+    beforeEach(async () => {
+      user = await factory.create('User', { email: 'user@example.org' })
+      authenticatedUser = await user.toJson()
+    })
+
+    describe('email attribute is not a valid email', () => {
+      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', () => {
+      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 `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:EmailAddressRequest {email: "new-email@example.org"})
+        RETURN e
+      `)
+        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 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',
+          })
+          await expect(mutate({ mutation, variables })).resolves.toMatchObject({
+            data: {
+              AddEmailAddress: {
+                email: 'new-email@example.org',
+                verifiedAt: null,
+              },
+            },
+            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.' }],
+          })
+        })
+      })
+    })
+  })
+})
+
+describe('VerifyEmailAddress', () => {
+  const mutation = gql`
+    mutation($email: String!, $nonce: String!) {
+      VerifyEmailAddress(email: $email, nonce: $nonce) {
+        email
+        createdAt
+        verifiedAt
+      }
+    }
+  `
+
+  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', () => {
+    beforeEach(async () => {
+      user = await factory.create('User', { email: 'user@example.org' })
+      authenticatedUser = await user.toJson()
+    })
+
+    describe('if no unverified `EmailAddress` node exists', () => {
+      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 a `EmailAddressRequest`', () => {
+      let emailAddress
+      beforeEach(async () => {
+        emailAddress = await factory.create('EmailAddressRequest', {
+          nonce: 'abcdef',
+          verifiedAt: null,
+          createdAt: new Date().toISOString(),
+          email: 'to-be-verified@example.org',
+        })
+      })
+
+      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 `EmailAddressRequest` 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 `EmailAddressRequest` 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)
+          })
+
+          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.' }],
+              })
+            })
+          })
+        })
+      })
+    })
+  })
+})

Wow, this is thoroughly tested! 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


I love it!! I guess you probably didn't Test Drive this since the implementation changed a few times??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Hard to believe, but I test drove the backend 😉

For the frontend I added tests after the implementation because at the time I had hopes to find a solution with less page components and redirects. In the end I figured that solution to be the least complex though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


awesome 👏

@@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -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,

Is this necessary? I think toMatchObject does not need the full object defined, it should also pass for partial matches. Or do you want to explicitly make sure that errors are indeed undefined?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Yes, I do. I thought it might increase readability and maybe save us from false negatives.

@@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -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,

same question as above ☝️

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


same answer 😉

@@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -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,

and again ☝️

@@ -158,6 +158,27 @@
"labelBio": "Über dich",
"success": "Deine Daten wurden erfolgreich aktualisiert!"
},
"email": {
"validation": {
"same-email": "Das ist deine aktuelle E-Mail Addresse"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -157,6 +157,28 @@
       "labelBio": "Über dich",
       "success": "Deine Daten wurden erfolgreich aktualisiert!"
     },
+    "email": {
+      "validation": {
+        "same-email": "Muss sich unterscheiden von der jetzigen E-Mail Addresse"

Mir würde besser gefallen: Das ist deine aktuelle E-Mail Adresse

@@ -159,6 +159,27 @@
"labelBio": "About You",
"success": "Your data was successfully updated!"
},
"email": {
"validation": {
"same-email": "This is your current email address"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -158,6 +158,28 @@
       "labelBio": "About You",
       "success": "Your data was successfully updated!"
     },
+    "email": {
+      "validation": {
+        "same-email": "Must be different from your current E-Mail address"

Different language, same idea: This is your current e-mail address 😉

What you wrote sounds good in English but not very friendly in German – to my ears, at least. And if we decide to change it I think we should change it in both languages. I'll leave it up to you! :)

"no-email-request": "Are you certain that you requested a change of your email address?"
},
"support": "If the problem persists, please contact us by email at"
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -158,6 +158,28 @@
       "labelBio": "About You",
       "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",
+      "labelNonce": "Enter your code",
+      "success": "A new E-Mail address has been registered.",
+      "submitted": "An email to verify your address has been sent to <b>{email}</b>.",
+      "change-successful": "Your E-Mail address has been changed successfully.",
+      "verification-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"

I think last time we decided to go with the spelling e-mail in English, or am I mistaken @Tirokk? Would be great to have it all the same – and there are some E-Mails and emails in this file. 😜

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 1, 2019


I think we didn't decide... @Tirokk asked it to be changed in your PR cause it's what he prefers, and since no one else cared enough to point it out, it's what we went with. I prefer the non-hyphenated version personally. While e-mail is still more commonly used than email, it's on the downturn, so if we wanna keep up with the times, maybe we should be cool and use email 😝
https://writingexplained.org/e-mail-or-email

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


So how should I change it? email now?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


In German it seems to E-Mail and nothing else: https://www.duden.de/sprachwissen/sprachratgeber/Schreibung-von-E-Mail

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


I say just leave it, like I said, it's not something I would ask to be changed.
Sorry, I just couldn't help myself when @alina-beck said we decided... e-mail is perfectly ok, there seem to be stricter rules in German than English and as long as it's not incorrect, we can follow the stricter rules to have some continuity between the two.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Oct 2, 2019


I really don't mind if we go with email or e-mail in English! (Personally I've only been using email so far...) But I think it would be great if we could agree on one and then stick to it in all user-facing communication.

It's not a big issue and most people won't notice, I guess, but some (language 🤓 like me) might and it's just overall smoother and cleaner. 😉

Since I don't have a strong opinion I just went with what @Tirokk suggested last time. But what do you say @mattwr18? Would email be better? Let's decide! 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


I wouldn't say it would be better, I don't think it really matters one way or the other, and I completely understand for user facing communication it's great to be consistent. It's a little funny to me cause all of our keys in the locale files we spell it email not e-mail, but those are not user facing.
I'm happy to use e-mail for the reasons I stated above ☝️
Now, we can probably freely say we decided 🤣

<ds-form v-else v-model="form" :schema="formSchema" @submit="submit">
<template slot-scope="{ errors }">
<ds-card :header="$t('settings.email.name')">
<ds-input
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,114 @@
+<template>
+  <ds-card centered v-if="success">
+    <transition name="ds-transition-fade">
+      <sweetalert-icon icon="info" />
+    </transition>
+    <ds-text v-html="submitMessage" />
+  </ds-card>
+  <ds-form v-else v-model="form" :schema="formSchema" @submit="submit">
+    <template slot-scope="{ errors }">
+      <ds-card :header="$t('settings.email.name')">
+        <ds-input id="email" model="email" icon="at" :label="$t('settings.email.labelEmail')" />

in the login e-mail input we use the envelope icon which I would prefer here as well 🙂

}
},
},
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,114 @@
+<template>
+  <ds-card centered v-if="success">
+    <transition name="ds-transition-fade">
+      <sweetalert-icon icon="info" />
+    </transition>
+    <ds-text v-html="submitMessage" />
+  </ds-card>
+  <ds-form v-else v-model="form" :schema="formSchema" @submit="submit">
+    <template slot-scope="{ errors }">
+      <ds-card :header="$t('settings.email.name')">
+        <ds-input id="email" model="email" icon="at" :label="$t('settings.email.labelEmail')" />
+
+        <template slot="footer">
+          <ds-space class="backendErrors" v-if="backendErrors">
+            <ds-text align="center" bold color="danger">{{ backendErrors.message }}</ds-text>
+          </ds-space>
+          <ds-button class="submit-button" icon="check" :disabled="errors" type="submit" primary>
+            {{ $t('actions.save') }}
+          </ds-button>
+        </template>
+      </ds-card>
+    </template>
+  </ds-form>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import { AddEmailAddressMutation } from '~/graphql/EmailAddress.js'
+import { SweetalertIcon } from 'vue-sweetalert-icons'
+
+export default {
+  components: {
+    SweetalertIcon,
+  },
+  data() {
+    return {
+      backendErrors: null,
+      success: false,
+    }
+  },
+  computed: {
+    submitMessage() {
+      const { email } = this.formData
+      return this.$t('settings.email.submitted', { email })
+    },
+    ...mapGetters({
+      currentUser: 'auth/user',
+    }),
+    form: {
+      get: function() {
+        const { email } = this.currentUser
+        return { email }
+      },
+      set: function(formData) {
+        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() {
+      const { email } = this.formData
+      try {
+        await this.$apollo.mutate({
+          mutation: AddEmailAddressMutation,
+          variables: { email },
+        })
+        this.$toast.success(this.$t('settings.email.success'))
+        this.success = true
+
+        setTimeout(() => {
+          this.$router.push({
+            path: 'my-email-address/enter-nonce',
+            query: { email },
+          })
+        }, 3000)
+      } catch (err) {
+        if (err.message.includes('exists')) {
+          // We cannot use form validation errors here, the backend does not
+          // have a query to filter for email addresses. This is a privacy
+          // consideration. We could implement a dedicated query to check that
+          // but I think it's too much effort for this feature.
+          this.backendErrors = { message: this.$t('registration.signup.form.errors.email-exists') }
+          return
+        }
+        this.$toast.error(err.message)
+      }
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.submit-button {
+  float: right;

why? all the other buttons (e.g. add social media, delete data, change password) are left aligned

path: 'my-email-address/enter-nonce',
query: { email },
})
}, 3000)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,114 @@
+<template>
+  <ds-card centered v-if="success">
+    <transition name="ds-transition-fade">
+      <sweetalert-icon icon="info" />
+    </transition>
+    <ds-text v-html="submitMessage" />
+  </ds-card>
+  <ds-form v-else v-model="form" :schema="formSchema" @submit="submit">
+    <template slot-scope="{ errors }">
+      <ds-card :header="$t('settings.email.name')">
+        <ds-input id="email" model="email" icon="at" :label="$t('settings.email.labelEmail')" />
+
+        <template slot="footer">
+          <ds-space class="backendErrors" v-if="backendErrors">
+            <ds-text align="center" bold color="danger">{{ backendErrors.message }}</ds-text>
+          </ds-space>
+          <ds-button class="submit-button" icon="check" :disabled="errors" type="submit" primary>
+            {{ $t('actions.save') }}
+          </ds-button>
+        </template>
+      </ds-card>
+    </template>
+  </ds-form>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import { AddEmailAddressMutation } from '~/graphql/EmailAddress.js'
+import { SweetalertIcon } from 'vue-sweetalert-icons'
+
+export default {
+  components: {
+    SweetalertIcon,
+  },
+  data() {
+    return {
+      backendErrors: null,
+      success: false,
+    }
+  },
+  computed: {
+    submitMessage() {
+      const { email } = this.formData
+      return this.$t('settings.email.submitted', { email })
+    },
+    ...mapGetters({
+      currentUser: 'auth/user',
+    }),
+    form: {
+      get: function() {
+        const { email } = this.currentUser
+        return { email }
+      },
+      set: function(formData) {
+        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() {
+      const { email } = this.formData
+      try {
+        await this.$apollo.mutate({
+          mutation: AddEmailAddressMutation,
+          variables: { email },
+        })
+        this.$toast.success(this.$t('settings.email.success'))
+        this.success = true
+
+        setTimeout(() => {
+          this.$router.push({
+            path: 'my-email-address/enter-nonce',
+            query: { email },
+          })
+        }, 3000)

I don't like that we are mixing different types of alerts – toasts, the error in the footer, the success block in combination with this timeout... they're all over the place! And in this case not really self-contained when the submit() method has to decide how long the alert is displayed. 😕

Do you think we could agree on just one version, make a proper component for it and then use it everywhere? 😄 Maybe not in this PR, though...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Yes, totally agree. I would prefer form validation errors. I try my best to implement re-usable async form validators, e.g. the one that checks for unique slugs.

However in this case we have no backend query to check if a particular already exists. Sure - I could probably implement a backend query for frontend validation. But I thought maybe we don't want to do that? 🤔

So right now, we have to call the mutation and create the user account. If it fails, we have to check the backend error message. It would be great to get the form validation error into the <ds-form> component somehow, but I believe that's not possible right now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


I also prefer form validators as it's closer to where the user's attention is, but then, as far as I know, we'd have toastrs for success and form validators for failures?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


We have toasters because we're just too lazy to write the template and the translations for error messages.

<ds-input
id="email"
model="email"
icon="envelope"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Sep 30, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,59 @@
+<template>
+  <ds-form v-model="form" :schema="formSchema" @submit="submit">
+    <template slot-scope="{ errors }">
+      <ds-card :header="$t('settings.email.name')">
+        <ds-input
+          id="email"
+          model="email"
+          icon="at"

envelope, please! 😉

let response
try {
const { neode } = context
await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,92 @@
+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) => {
+      let response
+      try {
+        const { neode } = context
+        await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)

I love this!! Validators rock!!

} = context
const { email } = args
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,92 @@
+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) => {
+      let response
+      try {
+        const { neode } = context
+        await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)
+      } catch (e) {
+        throw new UserInputError('must be a valid email')
+      }
+
+      // check email does not belong to anybody
+      await existingEmailAddress(_parent, args, context)
+
+      const nonce = generateNonce()
+      const {
+        user: { id: userId },
+      } = context
+      const { email } = args
+      const session = context.driver.session()
+      const writeTxResultPromise = session.writeTransaction(async txc => {

fancy stuff here! this is the first time in the code base we are leveraging this, I believe..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


could you summarize the benefits of using writeTransaction and writeTxResultPromise?
or point me to some docs that explain it?

Actually, I found this // It is possible to execute write transactions that will benefit from automatic retries // on both single instance ('bolt' URI scheme) and Causal Cluster ('bolt+routing' URI scheme) from https://neo4j.com/docs/api/javascript-driver/current/
Are automatic retries the main reason you decided to use this here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Yes, I have no idea how to check those benefits. E.g. how to simulate them. But the docs clearly say: Do this. So I did.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


it seems like it would be a benefit to have automatic retries, and I'm in favor of following best practices. :)

MATCH (user:User {id: $userId})-[:PRIMARY_EMAIL]->(previous:EmailAddress)
MATCH (user)<-[:BELONGS_TO]-(email:UnverifiedEmailAddress {email: $email, nonce: $nonce})
MERGE (user)-[:PRIMARY_EMAIL]->(email)
SET email:EmailAddress
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,92 @@
+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) => {
+      let response
+      try {
+        const { neode } = context
+        await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)
+      } catch (e) {
+        throw new UserInputError('must be a valid email')
+      }
+
+      // check email does not belong to anybody
+      await existingEmailAddress(_parent, args, context)
+
+      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:UnverifiedEmailAddress {email: $email, nonce: $nonce})
+            SET email.createdAt = toString(datetime())
+            RETURN email, user
+          `,
+          { userId, email, nonce },
+        )
+        return result.records.map(record => ({
+          name: record.get('user').properties.name,
+          ...record.get('email').properties,
+        }))
+      })
+      try {
+        const txResult = await writeTxResultPromise
+        response = txResult[0]
+      } finally {
+        session.close()
+      }
+      return response
+    },
+    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})-[:PRIMARY_EMAIL]->(previous:EmailAddress)
+            MATCH (user)<-[:BELONGS_TO]-(email:UnverifiedEmailAddress {email: $email, nonce: $nonce})
+            MERGE (user)-[:PRIMARY_EMAIL]->(email)
+            SET email:EmailAddress

interesting 🤔

const { nonce, email } = args
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async txc => {
const result = await txc.run(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,92 @@
+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) => {
+      let response
+      try {
+        const { neode } = context
+        await new Validator(neode, neode.model('UnverifiedEmailAddress'), args)
+      } catch (e) {
+        throw new UserInputError('must be a valid email')
+      }
+
+      // check email does not belong to anybody
+      await existingEmailAddress(_parent, args, context)
+
+      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:UnverifiedEmailAddress {email: $email, nonce: $nonce})
+            SET email.createdAt = toString(datetime())
+            RETURN email, user
+          `,
+          { userId, email, nonce },
+        )
+        return result.records.map(record => ({
+          name: record.get('user').properties.name,
+          ...record.get('email').properties,
+        }))
+      })
+      try {
+        const txResult = await writeTxResultPromise
+        response = txResult[0]
+      } finally {
+        session.close()
+      }
+      return response
+    },
+    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(

not a fan of this naming convention... it's as if we don't know what the result of this transaction will be
we have been using transactionRes, which is maybe slightly better, but actually still makes it seem like it's a mystery what it returns...
In this case it's returning the emailNode, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


const result would be a result set. It's a local variable to store that stuff we need for the return value 😆

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


I get that, just think we don't really gain anything by being vague, and it could make our code easier to understand if it's more expressive rather than less.

MATCH(u:User)<-[:BELONGS_TO]-(e:UnverifiedEmailAddress {email: "new-email@example.org"})
RETURN e
`)
const email = neode.hydrateFirst(result, 'e', neode.model('UnverifiedEmailAddress'))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,298 @@
+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', () => {
+  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', () => {
+    beforeEach(async () => {
+      user = await factory.create('User', { id: '567', email: 'user@example.org' })
+      authenticatedUser = await user.toJson()
+    })
+
+    describe('email attribute is not a valid email', () => {
+      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', () => {
+      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 `UnverifiedEmailAddress` 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:UnverifiedEmailAddress {email: "new-email@example.org"})
+        RETURN e
+      `)
+        const email = neode.hydrateFirst(result, 'e', neode.model('UnverifiedEmailAddress'))

I'll have to read up on this method... hydrateFirst sounds like some advice you give someone before going out drinking 😆

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


😆 it's the neode way of parsing a transaction record into a neode node. It needs to know the model name and the variable in the cypher statement.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


great! thanks for that... I'll still have a look at the docs, and ideally use it myself... I guessed that is what the 'e' was, but I would have just used email personally... I don't know what the 4 characters less buys you, but I know programmers are different.

<ds-space class="backendErrors" v-if="backendErrors">
<ds-text align="center" bold color="danger">{{ backendErrors.message }}</ds-text>
</ds-space>
<ds-button icon="check" :disabled="errors" type="submit" primary>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alina-beck Authored by alina-beck
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,113 @@
+<template>
+  <ds-card centered v-if="success">
+    <transition name="ds-transition-fade">
+      <sweetalert-icon icon="info" />
+    </transition>
+    <ds-text v-html="submitMessage" />
+  </ds-card>
+  <ds-form v-else v-model="form" :schema="formSchema" @submit="submit">
+    <template slot-scope="{ errors }">
+      <ds-card :header="$t('settings.email.name')">
+        <ds-input
+          id="email"
+          model="email"
+          icon="envelope"
+          :label="$t('settings.email.labelEmail')"
+        />
+
+        <template slot="footer">
+          <ds-space class="backendErrors" v-if="backendErrors">
+            <ds-text align="center" bold color="danger">{{ backendErrors.message }}</ds-text>
+          </ds-space>
+          <ds-button class="submit-button" icon="check" :disabled="errors" type="submit" primary>

the class is now obsolete, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Ok, you got me. I hoped to find not enough suggestions to touch this PR again, but this suggestion together with @mattwr18's suggestions above is too much to just click "Merge"...

@@ -0,0 +1,26 @@
import { UserInputError } from 'apollo-server'
export default async function alreadyExistingMail(_parent, args, context) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -0,0 +1,26 @@
+import { UserInputError } from 'apollo-server'
+export default async function alreadyExistingMail(_parent, args, context) {

was this name alreadyExistingMail on purpose?
Shouldn't it be alreadyExistingEmail?
or even the same as the file name? existingEmailAddress??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Not on purpose. Next time I touch this file I will go with existingEmailAddress. This makes sense especially because we have multiple nodes for similar things now.

@@ -67,9 +42,9 @@ export default {
},
SignupByInvitation: async (_parent, args, context) => {
const { token } = args
const nonce = uuid().substring(0, 6)
const nonce = generateNonce()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -67,9 +42,9 @@ export default {
     },
     SignupByInvitation: async (_parent, args, context) => {
       const { token } = args
-      const nonce = uuid().substring(0, 6)
+      const nonce = generateNonce()

nice refactor

@@ -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.' }],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -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.' }],

nice improvement

"reason": {
"invalid-nonce": "Is the confirmation code invalid?",
"no-email-request": "Are you certain that you requested a change of your email address?"
},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -159,6 +159,28 @@
       "labelBio": "About You",
       "success": "Your data was successfully updated!"
     },
+    "email": {
+      "validation": {
+        "same-email": "This is your current email address"
+      },
+      "name": "Your email",
+      "labelEmail": "Change your email address",
+      "labelNewEmail": "New email Address",
+      "labelNonce": "Enter your code",
+      "success": "A new email address has been registered.",
+      "submitted": "An email to verify your address has been sent to <b>{email}</b>.",
+      "change-successful": "Your email address has been changed successfully.",
+      "verification-error": {
+        "message": "Your email 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?"

I might be confused by this... like how would my email address be assigned to someone else?
maybe it could make me think of my partner, but it might make me think I've been hacked 😮
I wonder if it wouldn't be better to just have them get in contact if they think they should be able to verify the email address, and we could explore what happened??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

roschaefer Authored by roschaefer
Oct 2, 2019


Well, this should hardly happen. But maybe people have some kind of shared email address and they thought it's a super good idea to use that email for an account?

But you're right, it will probably be confusing. Maybe we wait for the first user feedback on this? See, if people get freaked out by this? 😉

"explanation": "This can have different causes:",
"reason": {
"invalid-nonce": "Is the confirmation code invalid?",
"no-email-request": "Are you certain that you requested a change of your email address?"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mattwr18 Authored by mattwr18
Oct 2, 2019


Outdated (history rewrite) - original diff


@@ -159,6 +159,28 @@
       "labelBio": "About You",
       "success": "Your data was successfully updated!"
     },
+    "email": {
+      "validation": {
+        "same-email": "This is your current email address"
+      },
+      "name": "Your email",
+      "labelEmail": "Change your email address",
+      "labelNewEmail": "New email Address",
+      "labelNonce": "Enter your code",
+      "success": "A new email address has been registered.",
+      "submitted": "An email to verify your address has been sent to <b>{email}</b>.",
+      "change-successful": "Your email address has been changed successfully.",
+      "verification-error": {
+        "message": "Your email 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?",

if you want to form these all as questions, I might say Are you certain you requested a change of your email address?

Copy link
Contributor

@Mogge Mogge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments, and suggestions that should not block this from being merged in... I'll leave it up to you to follow any of them or not.
I would also like to test this out on nitro-staging as well.

@Mogge Mogge closed this Oct 8, 2020
@ulfgebhardt ulfgebhardt deleted the pr1711head branch January 7, 2021 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants