diff --git a/backend/.env.dist b/backend/.env.dist index 3c93f1576..7994daacf 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -38,8 +38,8 @@ EMAIL_SENDER=info@gradido.net EMAIL_PASSWORD=xxx EMAIL_SMTP_URL=gmail.com EMAIL_SMTP_PORT=587 -EMAIL_LINK_VERIFICATION=http://localhost/checkEmail/{code} -EMAIL_LINK_SETPASSWORD=http://localhost/reset/{code} +EMAIL_LINK_VERIFICATION=http://localhost/checkEmail/{optin}{code} +EMAIL_LINK_SETPASSWORD=http://localhost/reset/{optin} EMAIL_CODE_VALID_TIME=10 # Webhook diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 754bfbf08..e502272d3 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -62,9 +62,9 @@ const email = { EMAIL_SMTP_URL: process.env.EMAIL_SMTP_URL || 'gmail.com', EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT || '587', EMAIL_LINK_VERIFICATION: - process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{code}', + process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{optin}{code}', EMAIL_LINK_SETPASSWORD: - process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{code}', + process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{optin}', EMAIL_CODE_VALID_TIME: process.env.EMAIL_CODE_VALID_TIME ? parseInt(process.env.EMAIL_CODE_VALID_TIME) || 10 : 10, diff --git a/backend/src/graphql/arg/CreateUserArgs.ts b/backend/src/graphql/arg/CreateUserArgs.ts index 0d63e76bb..af915b91a 100644 --- a/backend/src/graphql/arg/CreateUserArgs.ts +++ b/backend/src/graphql/arg/CreateUserArgs.ts @@ -16,4 +16,7 @@ export default class CreateUserArgs { @Field(() => Int, { nullable: true }) publisherId: number + + @Field(() => String, { nullable: true }) + redeemCode?: string | null } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index f873fd694..53f39668e 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -124,7 +124,10 @@ describe('UserResolver', () => { describe('account activation email', () => { it('sends an account activation email', () => { - const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(/{code}/g, emailOptIn) + const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( + /{optin}/g, + emailOptIn, + ).replace(/{code}/g, '') expect(sendAccountActivationEmail).toBeCalledWith({ link: activationLink, firstName: 'Peter', diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index a56b67945..b24aa1b58 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -7,6 +7,7 @@ import { getConnection, getCustomRepository, QueryRunner } from '@dbTools/typeor import CONFIG from '@/config' import { User } from '@model/User' import { User as DbUser } from '@entity/User' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { encode } from '@/auth/JWT' import CreateUserArgs from '@arg/CreateUserArgs' import UnsecureLoginArgs from '@arg/UnsecureLoginArgs' @@ -305,7 +306,8 @@ export class UserResolver { @Authorized([RIGHTS.CREATE_USER]) @Mutation(() => User) async createUser( - @Args() { email, firstName, lastName, language, publisherId }: CreateUserArgs, + @Args() + { email, firstName, lastName, language, publisherId, redeemCode = null }: CreateUserArgs, ): Promise { // TODO: wrong default value (should be null), how does graphql work here? Is it an required field? // default int publisher_id = 0; @@ -338,6 +340,12 @@ export class UserResolver { dbUser.language = language dbUser.publisherId = publisherId dbUser.passphrase = passphrase.join(' ') + if (redeemCode) { + const transactionLink = await dbTransactionLink.findOne({ code: redeemCode }) + if (transactionLink) { + dbUser.referrerId = transactionLink.userId + } + } // TODO this field has no null allowed unlike the loginServer table // dbUser.pubKey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000... // dbUser.pubkey = keyPair[0] @@ -360,9 +368,9 @@ export class UserResolver { const emailOptIn = await createEmailOptIn(dbUser.id, queryRunner) const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( - /{code}/g, + /{optin}/g, emailOptIn.verificationCode.toString(), - ) + ).replace(/{code}/g, redeemCode ? '/' + redeemCode : '') // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountActivationEmail({ @@ -379,6 +387,7 @@ export class UserResolver { console.log(`Account confirmation link: ${activationLink}`) } */ + await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() @@ -404,7 +413,7 @@ export class UserResolver { const emailOptIn = await createEmailOptIn(user.id, queryRunner) const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( - /{code}/g, + /{optin}/g, emailOptIn.verificationCode.toString(), ) @@ -443,7 +452,7 @@ export class UserResolver { const optInCode = await getOptInCode(user.id) const link = CONFIG.EMAIL_LINK_SETPASSWORD.replace( - /{code}/g, + /{optin}/g, optInCode.verificationCode.toString(), ) diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index f68d983c0..298d56bdb 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -45,6 +45,7 @@ export const createUser = gql` $email: String! $language: String! $publisherId: Int + $redeemCode: String ) { createUser( email: $email @@ -52,6 +53,7 @@ export const createUser = gql` lastName: $lastName language: $language publisherId: $publisherId + redeemCode: $redeemCode ) { id } diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index d3a177866..95c811f8d 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -45,6 +45,7 @@ export const createUser = gql` $email: String! $language: String! $publisherId: Int + $redeemCode: String ) { createUser( email: $email @@ -52,6 +53,7 @@ export const createUser = gql` lastName: $lastName language: $language publisherId: $publisherId + redeemCode: $redeemCode ) { id } diff --git a/frontend/src/pages/ResetPassword.spec.js b/frontend/src/pages/ResetPassword.spec.js index f5d672c99..c43f71932 100644 --- a/frontend/src/pages/ResetPassword.spec.js +++ b/frontend/src/pages/ResetPassword.spec.js @@ -54,25 +54,27 @@ describe('ResetPassword', () => { describe('mount', () => { beforeEach(() => { + jest.clearAllMocks() wrapper = Wrapper() }) - describe('No valid optin', () => { - it.skip('does not render the Reset Password form when not authenticated', () => { - expect(wrapper.find('form').exists()).toBeFalsy() + describe('no valid optin', () => { + beforeEach(() => { + jest.clearAllMocks() + apolloQueryMock.mockRejectedValue({ message: 'Your time is up!' }) + wrapper = Wrapper() }) - it.skip('toasts an error when no valid optin is given', () => { - expect(toastErrorSpy).toHaveBeenCalledWith('error') + it('toasts an error when no valid optin is given', () => { + expect(toastErrorSpy).toHaveBeenCalledWith('Your time is up!') }) - it.skip('has a message suggesting to contact the support', () => { - expect(wrapper.find('div.header').text()).toContain('settings.password.reset') - expect(wrapper.find('div.header').text()).toContain('settings.password.not-authenticated') + it('redirects to /forgot-password/resetPassword', () => { + expect(routerPushMock).toBeCalledWith('/forgot-password/resetPassword') }) }) - describe('is authenticated', () => { + describe('valid optin', () => { it('renders the Reset Password form when authenticated', () => { expect(wrapper.find('div.resetpwd-form').exists()).toBeTruthy() }) @@ -148,7 +150,6 @@ describe('ResetPassword', () => { describe('server response with error code > 10min', () => { beforeEach(async () => { - jest.clearAllMocks() apolloMutationMock.mockRejectedValue({ message: '...Code is older than 10 minutes' }) await wrapper.find('form').trigger('submit') await flushPromises() @@ -163,7 +164,7 @@ describe('ResetPassword', () => { }) }) - describe('server response with error code > 10min', () => { + describe('server response with error', () => { beforeEach(async () => { jest.clearAllMocks() apolloMutationMock.mockRejectedValueOnce({ message: 'Error' }) @@ -178,6 +179,7 @@ describe('ResetPassword', () => { describe('server response with success on /checkEmail', () => { beforeEach(async () => { + jest.clearAllMocks() mocks.$route.path.mock = 'checkEmail' apolloMutationMock.mockResolvedValue({ data: { @@ -204,6 +206,28 @@ describe('ResetPassword', () => { it('redirects to "/thx/checkEmail"', () => { expect(routerPushMock).toHaveBeenCalledWith('/thx/checkEmail') }) + + describe('with param code', () => { + beforeEach(async () => { + mocks.$route.params.code = 'the-most-secret-code-ever' + apolloMutationMock.mockResolvedValue({ + data: { + resetPassword: 'success', + }, + }) + wrapper = Wrapper() + await wrapper.findAll('input').at(0).setValue('Aa123456_') + await wrapper.findAll('input').at(1).setValue('Aa123456_') + await wrapper.find('form').trigger('submit') + await flushPromises() + }) + + it('redirects to "/thx/checkEmail/the-most-secret-code-ever"', () => { + expect(routerPushMock).toHaveBeenCalledWith( + '/thx/checkEmail/the-most-secret-code-ever', + ) + }) + }) }) describe('server response with success on /reset-password', () => { diff --git a/frontend/src/pages/ResetPassword.vue b/frontend/src/pages/ResetPassword.vue index 7532953ed..7771be5f6 100644 --- a/frontend/src/pages/ResetPassword.vue +++ b/frontend/src/pages/ResetPassword.vue @@ -6,11 +6,11 @@ -

{{ $t(displaySetup.authenticated) }}

+

{{ $t(displaySetup.title) }}

- {{ $t(displaySetup.notAuthenticated) }} + {{ $t(displaySetup.text) }}
@@ -53,14 +53,14 @@ import { queryOptIn } from '@/graphql/queries' const textFields = { reset: { - authenticated: 'settings.password.change-password', - notAuthenticated: 'settings.password.reset-password.text', + title: 'settings.password.change-password', + text: 'settings.password.reset-password.text', button: 'settings.password.change-password', linkTo: '/login', }, checkEmail: { - authenticated: 'settings.password.set', - notAuthenticated: 'settings.password.set-password.text', + title: 'settings.password.set', + text: 'settings.password.set-password.text', button: 'settings.password.set', linkTo: '/login', }, @@ -97,7 +97,11 @@ export default { .then(() => { this.form.password = '' if (this.$route.path.includes('checkEmail')) { - this.$router.push('/thx/checkEmail') + if (this.$route.params.code) { + this.$router.push('/thx/checkEmail/' + this.$route.params.code) + } else { + this.$router.push('/thx/checkEmail') + } } else { this.$router.push('/thx/resetPassword') } diff --git a/frontend/src/pages/thx.vue b/frontend/src/pages/thx.vue index 4a86212c9..736e0c70e 100644 --- a/frontend/src/pages/thx.vue +++ b/frontend/src/pages/thx.vue @@ -9,7 +9,11 @@

{{ $t(displaySetup.subtitle) }}


- + + + {{ $t(displaySetup.button) }} + + {{ $t(displaySetup.button) }} diff --git a/frontend/src/routes/router.test.js b/frontend/src/routes/router.test.js index 85f765c69..925b3ffca 100644 --- a/frontend/src/routes/router.test.js +++ b/frontend/src/routes/router.test.js @@ -112,7 +112,7 @@ describe('router', () => { }) describe('thx', () => { - const thx = routes.find((r) => r.path === '/thx/:comingFrom') + const thx = routes.find((r) => r.path === '/thx/:comingFrom/:code?') it('loads the "Thx" page', async () => { const component = await thx.component() @@ -177,7 +177,9 @@ describe('router', () => { describe('checkEmail', () => { it('loads the "CheckEmail" page', async () => { - const component = await routes.find((r) => r.path === '/checkEmail/:optin').component() + const component = await routes + .find((r) => r.path === '/checkEmail/:optin/:code?') + .component() expect(component.default.name).toBe('ResetPassword') }) }) diff --git a/frontend/src/routes/routes.js b/frontend/src/routes/routes.js index 5e0b09c5e..a6586c201 100755 --- a/frontend/src/routes/routes.js +++ b/frontend/src/routes/routes.js @@ -47,7 +47,7 @@ const routes = [ component: () => import('@/pages/Register.vue'), }, { - path: '/thx/:comingFrom', + path: '/thx/:comingFrom/:code?', component: () => import('@/pages/thx.vue'), beforeEnter: (to, from, next) => { const validFrom = ['forgot-password', 'reset-password', 'register', 'login', 'checkEmail'] @@ -79,7 +79,7 @@ const routes = [ component: () => import('@/pages/ResetPassword.vue'), }, { - path: '/checkEmail/:optin', + path: '/checkEmail/:optin/:code?', component: () => import('@/pages/ResetPassword.vue'), }, {