diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e07cc8d3..d5b02cc76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -399,7 +399,7 @@ jobs: report_name: Coverage Frontend type: lcov result_path: ./coverage/lcov.info - min_coverage: 87 + min_coverage: 86 token: ${{ github.token }} ############################################################################## @@ -491,7 +491,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 39 + min_coverage: 37 token: ${{ github.token }} ############################################################################## diff --git a/backend/.env.dist b/backend/.env.dist index 1b485b8e4..ed9b26102 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -2,8 +2,6 @@ PORT=4000 JWT_SECRET=secret123 JWT_EXPIRES_IN=10m GRAPHIQL=false -LOGIN_API_URL=http://login-server:1201/ -COMMUNITY_API_URL=http://nginx/api/ GDT_API_URL=https://gdt.gradido.net DB_HOST=localhost DB_PORT=3306 diff --git a/backend/src/auth/INALIENABLE_RIGHTS.ts b/backend/src/auth/INALIENABLE_RIGHTS.ts index eb367d643..58a81ce52 100644 --- a/backend/src/auth/INALIENABLE_RIGHTS.ts +++ b/backend/src/auth/INALIENABLE_RIGHTS.ts @@ -4,10 +4,8 @@ export const INALIENABLE_RIGHTS = [ RIGHTS.LOGIN, RIGHTS.GET_COMMUNITY_INFO, RIGHTS.COMMUNITIES, - RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE, RIGHTS.CREATE_USER, RIGHTS.SEND_RESET_PASSWORD_EMAIL, - RIGHTS.RESET_PASSWORD, + RIGHTS.SET_PASSWORD, RIGHTS.CHECK_USERNAME, - RIGHTS.CHECK_EMAIL, ] diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index fa750239e..6a2c05025 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -12,14 +12,12 @@ export enum RIGHTS { SUBSCRIBE_NEWSLETTER = 'SUBSCRIBE_NEWSLETTER', TRANSACTION_LIST = 'TRANSACTION_LIST', SEND_COINS = 'SEND_COINS', - LOGIN_VIA_EMAIL_VERIFICATION_CODE = 'LOGIN_VIA_EMAIL_VERIFICATION_CODE', LOGOUT = 'LOGOUT', CREATE_USER = 'CREATE_USER', SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL', - RESET_PASSWORD = 'RESET_PASSWORD', + SET_PASSWORD = 'SET_PASSWORD', UPDATE_USER_INFOS = 'UPDATE_USER_INFOS', CHECK_USERNAME = 'CHECK_USERNAME', - CHECK_EMAIL = 'CHECK_EMAIL', HAS_ELOPAGE = 'HAS_ELOPAGE', // Admin SEARCH_USERS = 'SEARCH_USERS', diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index eab1b4608..a12524a78 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -8,8 +8,6 @@ const server = { JWT_SECRET: process.env.JWT_SECRET || 'secret123', JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '10m', GRAPHIQL: process.env.GRAPHIQL === 'true' || false, - LOGIN_API_URL: process.env.LOGIN_API_URL || 'http://login-server:1201/', - COMMUNITY_API_URL: process.env.COMMUNITY_API_URL || 'http://nginx/api/', GDT_API_URL: process.env.GDT_API_URL || 'https://gdt.gradido.net', PRODUCTION: process.env.NODE_ENV === 'production' || false, } @@ -53,6 +51,7 @@ const email = { EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT || '587', EMAIL_LINK_VERIFICATION: process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/vue/checkEmail/$1', + EMAIL_LINK_SETPASSWORD: process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/vue/reset/$1', } const webhook = { diff --git a/backend/src/graphql/arg/ChangePasswordArgs.ts b/backend/src/graphql/arg/ChangePasswordArgs.ts deleted file mode 100644 index 4c2efae60..000000000 --- a/backend/src/graphql/arg/ChangePasswordArgs.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ArgsType, Field } from 'type-graphql' - -@ArgsType() -export default class ChangePasswordArgs { - @Field(() => Number) - sessionId: number - - @Field(() => String) - email: string - - @Field(() => String) - password: string -} diff --git a/backend/src/graphql/model/CheckEmailResponse.ts b/backend/src/graphql/model/CheckEmailResponse.ts deleted file mode 100644 index 948739722..000000000 --- a/backend/src/graphql/model/CheckEmailResponse.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { ObjectType, Field } from 'type-graphql' - -@ObjectType() -export class CheckEmailResponse { - constructor(json: any) { - this.sessionId = json.session_id - this.email = json.user.email - this.language = json.user.language - this.firstName = json.user.first_name - this.lastName = json.user.last_name - } - - @Field(() => Number) - sessionId: number - - @Field(() => String) - email: string - - @Field(() => String) - firstName: string - - @Field(() => String) - lastName: string - - @Field(() => String) - language: string -} diff --git a/backend/src/graphql/model/LoginViaVerificationCode.ts b/backend/src/graphql/model/LoginViaVerificationCode.ts deleted file mode 100644 index 61286ca0e..000000000 --- a/backend/src/graphql/model/LoginViaVerificationCode.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { ObjectType, Field } from 'type-graphql' - -@ObjectType() -export class LoginViaVerificationCode { - constructor(json: any) { - this.sessionId = json.session_id - this.email = json.user.email - } - - @Field(() => Number) - sessionId: number - - @Field(() => String) - email: string -} diff --git a/backend/src/graphql/model/SendPasswordResetEmailResponse.ts b/backend/src/graphql/model/SendPasswordResetEmailResponse.ts deleted file mode 100644 index d387efede..000000000 --- a/backend/src/graphql/model/SendPasswordResetEmailResponse.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { ObjectType, Field } from 'type-graphql' - -@ObjectType() -export class SendPasswordResetEmailResponse { - constructor(json: any) { - this.state = json.state - this.msg = json.msg - } - - @Field(() => String) - state: string - - @Field(() => String) - msg?: string -} diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index ce403ac0e..bebc32e1e 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -3,24 +3,16 @@ import fs from 'fs' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' -import { getConnection, getCustomRepository } from 'typeorm' +import { getConnection, getCustomRepository, getRepository } from 'typeorm' import CONFIG from '../../config' -import { LoginViaVerificationCode } from '../model/LoginViaVerificationCode' -import { SendPasswordResetEmailResponse } from '../model/SendPasswordResetEmailResponse' import { User } from '../model/User' import { User as DbUser } from '@entity/User' import { encode } from '../../auth/JWT' -import ChangePasswordArgs from '../arg/ChangePasswordArgs' import CheckUsernameArgs from '../arg/CheckUsernameArgs' import CreateUserArgs from '../arg/CreateUserArgs' import UnsecureLoginArgs from '../arg/UnsecureLoginArgs' import UpdateUserInfosArgs from '../arg/UpdateUserInfosArgs' -import { apiPost, apiGet } from '../../apis/HttpRequest' -import { - klicktippRegistrationMiddleware, - klicktippNewsletterStateMiddleware, -} from '../../middleware/klicktippMiddleware' -import { CheckEmailResponse } from '../model/CheckEmailResponse' +import { klicktippNewsletterStateMiddleware } from '../../middleware/klicktippMiddleware' import { UserSettingRepository } from '../../typeorm/repository/UserSettingRepository' import { LoginUserRepository } from '../../typeorm/repository/LoginUser' import { Setting } from '../enum/Setting' @@ -30,10 +22,14 @@ import { LoginUserBackup } from '@entity/LoginUserBackup' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { sendEMail } from '../../util/sendEMail' import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys' +import { signIn } from '../../apis/KlicktippController' import { RIGHTS } from '../../auth/RIGHTS' import { ServerUserRepository } from '../../typeorm/repository/ServerUser' import { ROLE_ADMIN } from '../../auth/ROLES' +const EMAIL_OPT_IN_RESET_PASSWORD = 2 +const EMAIL_OPT_IN_REGISTER = 1 + // eslint-disable-next-line @typescript-eslint/no-var-requires const sodium = require('sodium-native') // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -58,50 +54,8 @@ const PassphraseGenerate = (): string[] => { result.push(WORDS[sodium.randombytes_random() % 2048]) } return result - /* - return [ - 'behind', - 'salmon', - 'fluid', - 'orphan', - 'frost', - 'elder', - 'amateur', - 'always', - 'panel', - 'palm', - 'leopard', - 'essay', - 'punch', - 'title', - 'fun', - 'annual', - 'page', - 'hundred', - 'journey', - 'select', - 'figure', - 'tunnel', - 'casual', - 'bar', - ] - */ } -/* -Test results: -INSERT INTO `login_users` (`id`, `email`, `first_name`, `last_name`, `username`, `description`, `password`, `pubkey`, `privkey`, `email_hash`, `created`, `email_checked`, `passphrase_shown`, `language`, `disabled`, `group_id`, `publisher_id`) VALUES -// old -(1, 'peter@lustig.de', 'peter', 'lustig', '', '', 4747956395458240931, 0x8c75edd507f470e5378f927489374694d68f3d155523f1c4402c36affd35a7ed, 0xb0e310655726b088631ccfd31ad6470ee50115c161dde8559572fa90657270ff13dc1200b2d3ea90dfbe92f3a4475ee4d9cee4989e39736a0870c33284bc73a8ae690e6da89f241a121eb3b500c22885, 0x9f700e6f6ec351a140b674c0edd4479509697b023bd8bee8826915ef6c2af036, '2021-11-03 20:05:04', 0, 0, 'de', 0, 1, 0); -// new -(2, 'peter@lustig.de', 'peter', 'lustig', '', '', 4747956395458240931, 0x8c75edd507f470e5378f927489374694d68f3d155523f1c4402c36affd35a7ed, 0xb0e310655726b088631ccfd31ad6470ee50115c161dde8559572fa90657270ff13dc1200b2d3ea90dfbe92f3a4475ee4d9cee4989e39736a0870c33284bc73a8ae690e6da89f241a121eb3b500c22885, 0x9f700e6f6ec351a140b674c0edd4479509697b023bd8bee8826915ef6c2af036, '2021-11-03 20:22:15', 0, 0, 'de', 0, 1, 0); -INSERT INTO `login_user_backups` (`id`, `user_id`, `passphrase`, `mnemonic_type`) VALUES -// old -(1, 1, 'behind salmon fluid orphan frost elder amateur always panel palm leopard essay punch title fun annual page hundred journey select figure tunnel casual bar ', 2); -// new -(2, 2, 'behind salmon fluid orphan frost elder amateur always panel palm leopard essay punch title fun annual page hundred journey select figure tunnel casual bar ', 2); -*/ - const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => { if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) { throw new Error('passphrase empty or to short') @@ -240,13 +194,21 @@ export class UserResolver { @Ctx() context: any, ): Promise { email = email.trim().toLowerCase() - // const result = await apiPost(CONFIG.LOGIN_API_URL + 'unsecureLogin', { email, password }) - // UnsecureLogin const loginUserRepository = getCustomRepository(LoginUserRepository) const loginUser = await loginUserRepository.findByEmail(email).catch(() => { throw new Error('No user with this credentials') }) - if (!loginUser.emailChecked) throw new Error('user email not validated') + if (!loginUser.emailChecked) { + throw new Error('User email not validated') + } + if (loginUser.password === BigInt(0)) { + // TODO we want to catch this on the frontend and ask the user to check his emails or resend code + throw new Error('User has no password set yet') + } + if (!loginUser.pubKey || !loginUser.privKey) { + // TODO we want to catch this on the frontend and ask the user to check his emails or resend code + throw new Error('User has no private or publicKey') + } const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash const loginUserPassword = BigInt(loginUser.password.toString()) if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) { @@ -320,22 +282,6 @@ export class UserResolver { return user } - @Authorized([RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE]) - @Query(() => LoginViaVerificationCode) - async loginViaEmailVerificationCode( - @Arg('optin') optin: string, - ): Promise { - // I cannot use number as type here. - // The value received is not the same as sent by the query - const result = await apiGet( - CONFIG.LOGIN_API_URL + 'loginViaEmailVerificationCode?emailVerificationCode=' + optin, - ) - if (!result.success) { - throw new Error(result.data) - } - return new LoginViaVerificationCode(result.data) - } - @Authorized([RIGHTS.LOGOUT]) @Query(() => String) async logout(): Promise { @@ -350,7 +296,7 @@ export class UserResolver { @Authorized([RIGHTS.CREATE_USER]) @Mutation(() => String) async createUser( - @Args() { email, firstName, lastName, password, language, publisherId }: CreateUserArgs, + @Args() { email, firstName, lastName, language, publisherId }: CreateUserArgs, ): Promise { // TODO: wrong default value (should be null), how does graphql work here? Is it an required field? // default int publisher_id = 0; @@ -360,13 +306,6 @@ export class UserResolver { language = DEFAULT_LANGUAGE } - // Validate Password - if (!isPassword(password)) { - throw new Error( - 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', - ) - } - // Validate username // TODO: never true const username = '' @@ -384,10 +323,10 @@ export class UserResolver { } const passphrase = PassphraseGenerate() - const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key - const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash + // const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key + // const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash + // const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) const emailHash = getEmailHash(email) - const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) // Table: login_users const loginUser = new LoginUser() @@ -396,13 +335,13 @@ export class UserResolver { loginUser.lastName = lastName loginUser.username = username loginUser.description = '' - loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash + // loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash loginUser.emailHash = emailHash loginUser.language = language loginUser.groupId = 1 loginUser.publisherId = publisherId - loginUser.pubKey = keyPair[0] - loginUser.privKey = encryptedPrivkey + // loginUser.pubKey = keyPair[0] + // loginUser.privKey = encryptedPrivkey const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() @@ -428,11 +367,13 @@ export class UserResolver { // Table: state_users const dbUser = new DbUser() - dbUser.pubkey = keyPair[0] dbUser.email = email dbUser.firstName = firstName dbUser.lastName = lastName dbUser.username = username + // TODO this field has no null allowed unlike the loginServer table + dbUser.pubkey = Buffer.alloc(32, 0) // default to 0000... + // dbUser.pubkey = keyPair[0] await queryRunner.manager.save(dbUser).catch((er) => { // eslint-disable-next-line no-console @@ -441,10 +382,11 @@ export class UserResolver { }) // Store EmailOptIn in DB + // TODO: this has duplicate code with sendResetPasswordEmail const emailOptIn = new LoginEmailOptIn() emailOptIn.userId = loginUserId emailOptIn.verificationCode = random(64) - emailOptIn.emailOptInTypeId = 2 + emailOptIn.emailOptInTypeId = EMAIL_OPT_IN_REGISTER await queryRunner.manager.save(emailOptIn).catch((error) => { // eslint-disable-next-line no-console @@ -489,38 +431,172 @@ export class UserResolver { } @Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL]) - @Query(() => SendPasswordResetEmailResponse) - async sendResetPasswordEmail( - @Arg('email') email: string, - ): Promise { - const payload = { - email, - email_text: 7, - email_verification_code_type: 'resetPassword', + @Query(() => Boolean) + async sendResetPasswordEmail(@Arg('email') email: string): Promise { + // TODO: this has duplicate code with createUser + // TODO: Moriz: I think we do not need this variable. + let emailAlreadySend = false + + const loginUserRepository = await getCustomRepository(LoginUserRepository) + const loginUser = await loginUserRepository.findOneOrFail({ email }) + + const loginEmailOptInRepository = await getRepository(LoginEmailOptIn) + let optInCode = await loginEmailOptInRepository.findOne({ + userId: loginUser.id, + emailOptInTypeId: EMAIL_OPT_IN_RESET_PASSWORD, + }) + if (optInCode) { + emailAlreadySend = true + } else { + optInCode = new LoginEmailOptIn() + optInCode.verificationCode = random(64) + optInCode.userId = loginUser.id + optInCode.emailOptInTypeId = EMAIL_OPT_IN_RESET_PASSWORD + await loginEmailOptInRepository.save(optInCode) } - const response = await apiPost(CONFIG.LOGIN_API_URL + 'sendEmail', payload) - if (!response.success) { - throw new Error(response.data) + + const link = CONFIG.EMAIL_LINK_SETPASSWORD.replace( + /\$1/g, + optInCode.verificationCode.toString(), + ) + + if (emailAlreadySend) { + const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() + if (timeElapsed <= 10 * 60 * 1000) { + throw new Error('email already sent less than 10 minutes before') + } } - return new SendPasswordResetEmailResponse(response.data) + + const emailSent = await sendEMail({ + from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, + to: `${loginUser.firstName} ${loginUser.lastName} <${email}>`, + subject: 'Gradido: Reset Password', + text: `Hallo ${loginUser.firstName} ${loginUser.lastName}, + + Du oder jemand anderes hat für dieses Konto ein Zurücksetzen des Passworts angefordert. + Wenn du es warst, klicke bitte auf den Link: ${link} + oder kopiere den obigen Link in Dein Browserfenster. + + Mit freundlichen Grüßen, + dein Gradido-Team`, + }) + + // In case EMails are disabled log the activation link for the user + if (!emailSent) { + // eslint-disable-next-line no-console + console.log(`Reset password link: ${link}`) + } + + return true } - @Authorized([RIGHTS.RESET_PASSWORD]) - @Mutation(() => String) - async resetPassword( - @Args() - { sessionId, email, password }: ChangePasswordArgs, - ): Promise { - const payload = { - session_id: sessionId, - email, - password, + @Authorized([RIGHTS.SET_PASSWORD]) + @Mutation(() => Boolean) + async setPassword( + @Arg('code') code: string, + @Arg('password') password: string, + ): Promise { + // Validate Password + if (!isPassword(password)) { + throw new Error( + 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', + ) } - const result = await apiPost(CONFIG.LOGIN_API_URL + 'resetPassword', payload) - if (!result.success) { - throw new Error(result.data) + + // Load code + const loginEmailOptInRepository = await getRepository(LoginEmailOptIn) + const optInCode = await loginEmailOptInRepository + .findOneOrFail({ verificationCode: code }) + .catch(() => { + throw new Error('Could not login with emailVerificationCode') + }) + + // Code is only valid for 10minutes + const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() + if (timeElapsed > 10 * 60 * 1000) { + throw new Error('Code is older than 10 minutes') } - return 'success' + + // load loginUser + const loginUserRepository = await getCustomRepository(LoginUserRepository) + const loginUser = await loginUserRepository + .findOneOrFail({ id: optInCode.userId }) + .catch(() => { + throw new Error('Could not find corresponding Login User') + }) + + // load user + const dbUserRepository = await getCustomRepository(UserRepository) + const dbUser = await dbUserRepository.findOneOrFail({ email: loginUser.email }).catch(() => { + throw new Error('Could not find corresponding User') + }) + + const loginUserBackupRepository = await getRepository(LoginUserBackup) + const loginUserBackup = await loginUserBackupRepository + .findOneOrFail({ userId: loginUser.id }) + .catch(() => { + throw new Error('Could not find corresponding BackupUser') + }) + + const passphrase = loginUserBackup.passphrase.slice(0, -1).split(' ') + if (passphrase.length < PHRASE_WORD_COUNT) { + // TODO if this can happen we cannot recover from that + throw new Error('Could not load a correct passphrase') + } + + // Activate EMail + loginUser.emailChecked = true + + // Update Password + const passwordHash = SecretKeyCryptographyCreateKey(loginUser.email, password) // return short and long hash + const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key + const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) + loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash + loginUser.pubKey = keyPair[0] + loginUser.privKey = encryptedPrivkey + dbUser.pubkey = keyPair[0] + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('READ UNCOMMITTED') + + try { + // Save loginUser + await queryRunner.manager.save(loginUser).catch((error) => { + throw new Error('error saving loginUser: ' + error) + }) + + // Save user + await queryRunner.manager.save(dbUser).catch((error) => { + throw new Error('error saving user: ' + error) + }) + + // Delete Code + await queryRunner.manager.remove(optInCode).catch((error) => { + throw new Error('error deleting code: ' + error) + }) + + await queryRunner.commitTransaction() + } catch (e) { + await queryRunner.rollbackTransaction() + throw e + } finally { + await queryRunner.release() + } + + // Sign into Klicktipp + // TODO do we always signUp the user? How to handle things with old users? + if (optInCode.emailOptInTypeId === EMAIL_OPT_IN_REGISTER) { + try { + await signIn(loginUser.email, loginUser.language, loginUser.firstName, loginUser.lastName) + } catch { + // TODO is this a problem? + // eslint-disable-next-line no-console + console.log('Could not subscribe to klicktipp') + } + } + + return true } @Authorized([RIGHTS.UPDATE_USER_INFOS]) @@ -656,19 +732,6 @@ export class UserResolver { return true } - @Authorized([RIGHTS.CHECK_EMAIL]) - @Query(() => CheckEmailResponse) - @UseMiddleware(klicktippRegistrationMiddleware) - async checkEmail(@Arg('optin') optin: string): Promise { - const result = await apiGet( - CONFIG.LOGIN_API_URL + 'loginViaEmailVerificationCode?emailVerificationCode=' + optin, - ) - if (!result.success) { - throw new Error(result.data) - } - return new CheckEmailResponse(result.data) - } - @Authorized([RIGHTS.HAS_ELOPAGE]) @Query(() => Boolean) async hasElopage(@Ctx() context: any): Promise { diff --git a/backend/src/middleware/klicktippMiddleware.ts b/backend/src/middleware/klicktippMiddleware.ts index e81087097..69a74480d 100644 --- a/backend/src/middleware/klicktippMiddleware.ts +++ b/backend/src/middleware/klicktippMiddleware.ts @@ -1,20 +1,20 @@ import { MiddlewareFn } from 'type-graphql' -import { signIn, getKlickTippUser } from '../apis/KlicktippController' +import { /* signIn, */ getKlickTippUser } from '../apis/KlicktippController' import { KlickTipp } from '../graphql/model/KlickTipp' import CONFIG from '../config/index' -export const klicktippRegistrationMiddleware: MiddlewareFn = async ( - // Only for demo - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - { root, args, context, info }, - next, -) => { - // Do Something here before resolver is called - const result = await next() - // Do Something here after resolver is completed - await signIn(result.email, result.language, result.firstName, result.lastName) - return result -} +// export const klicktippRegistrationMiddleware: MiddlewareFn = async ( +// // Only for demo +// /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +// { root, args, context, info }, +// next, +// ) => { +// // Do Something here before resolver is called +// const result = await next() +// // Do Something here after resolver is completed +// await signIn(result.email, result.language, result.firstName, result.lastName) +// return result +// } export const klicktippNewsletterStateMiddleware: MiddlewareFn = async ( /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ diff --git a/frontend/src/components/Inputs/InputPassword.vue b/frontend/src/components/Inputs/InputPassword.vue index 15ce116c9..b457e7673 100644 --- a/frontend/src/components/Inputs/InputPassword.vue +++ b/frontend/src/components/Inputs/InputPassword.vue @@ -23,6 +23,7 @@ variant="outline-light" @click="toggleShowPassword" class="border-left-0 rounded-right" + tabindex="-1" > diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index d1d3d583c..0c6ea51aa 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -12,9 +12,9 @@ export const unsubscribeNewsletter = gql` } ` -export const resetPassword = gql` - mutation($sessionId: Float!, $email: String!, $password: String!) { - resetPassword(sessionId: $sessionId, email: $email, password: $password) +export const setPassword = gql` + mutation($code: String!, $password: String!) { + setPassword(code: $code, password: $password) } ` @@ -42,12 +42,11 @@ export const updateUserInfos = gql` } ` -export const registerUser = gql` +export const createUser = gql` mutation( $firstName: String! $lastName: String! $email: String! - $password: String! $language: String! $publisherId: Int ) { @@ -55,7 +54,6 @@ export const registerUser = gql` email: $email firstName: $firstName lastName: $lastName - password: $password language: $language publisherId: $publisherId ) diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 8b55f4098..d867e14ad 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -46,15 +46,6 @@ export const logout = gql` } ` -export const loginViaEmailVerificationCode = gql` - query($optin: String!) { - loginViaEmailVerificationCode(optin: $optin) { - sessionId - email - } - } -` - export const transactionsQuery = gql` query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) { transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) { @@ -88,9 +79,7 @@ export const transactionsQuery = gql` export const sendResetPasswordEmail = gql` query($email: String!) { - sendResetPasswordEmail(email: $email) { - state - } + sendResetPasswordEmail(email: $email) } ` @@ -118,15 +107,6 @@ export const listGDTEntriesQuery = gql` } ` -export const checkEmailQuery = gql` - query($optin: String!) { - checkEmail(optin: $optin) { - email - sessionId - } - } -` - export const communityInfo = gql` query { getCommunityInfo { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 1e8bfb621..f17975c1f 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -145,12 +145,17 @@ "password": { "change-password": "Passwort ändern", "forgot_pwd": "Passwort vergessen?", + "not-authenticated": "Leider konnten wir dich nicht authentifizieren. Bitte wende dich an den Support.", + "resend_subtitle": "Dein Aktivierungslink ist abgelaufen, Du kannst hier ein neuen anfordern.", "reset": "Passwort zurücksetzen", "reset-password": { - "not-authenticated": "Leider konnten wir dich nicht authentifizieren. Bitte wende dich an den Support.", "text": "Jetzt kannst du ein neues Passwort speichern, mit dem du dich zukünftig in der Gradido-App anmelden kannst." }, "send_now": "Jetzt senden", + "set": "Passwort festlegen", + "set-password": { + "text": "Jetzt kannst du ein neues Passwort speichern, mit dem du dich zukünftig in der Gradido-App anmelden kannst." + }, "subtitle": "Wenn du dein Passwort vergessen hast, kannst du es hier zurücksetzen." } }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f473ff798..b14ef5a35 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -145,12 +145,17 @@ "password": { "change-password": "Change password", "forgot_pwd": "Forgot password?", + "not-authenticated": "Unfortunately we could not authenticate you. Please contact the support.", + "resend_subtitle": "Your activation link is expired, here you can order a new one.", "reset": "Reset password", "reset-password": { - "not-authenticated": "Unfortunately we could not authenticate you. Please contact the support.", "text": "Now you can save a new password to login to the Gradido-App in the future." }, "send_now": "Send now", + "set": "Set password", + "set-password": { + "text": "Now you can save a new password to login to the Gradido-App in the future." + }, "subtitle": "If you have forgotten your password, you can reset it here." } }, diff --git a/frontend/src/routes/router.test.js b/frontend/src/routes/router.test.js index cd26b6f6b..a85c7a291 100644 --- a/frontend/src/routes/router.test.js +++ b/frontend/src/routes/router.test.js @@ -49,8 +49,8 @@ describe('router', () => { expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' }) }) - it('has fifteen routes defined', () => { - expect(routes).toHaveLength(15) + it('has sixteen routes defined', () => { + expect(routes).toHaveLength(16) }) describe('overview', () => { @@ -167,7 +167,7 @@ describe('router', () => { describe('checkEmail', () => { it('loads the "CheckEmail" component', async () => { const component = await routes.find((r) => r.path === '/checkEmail/:optin').component() - expect(component.default.name).toBe('CheckEmail') + expect(component.default.name).toBe('ResetPassword') }) }) diff --git a/frontend/src/routes/routes.js b/frontend/src/routes/routes.js index f6975d09d..418422997 100755 --- a/frontend/src/routes/routes.js +++ b/frontend/src/routes/routes.js @@ -50,7 +50,7 @@ const routes = [ path: '/thx/:comingFrom', component: () => import('../views/Pages/thx.vue'), beforeEnter: (to, from, next) => { - const validFrom = ['password', 'reset', 'register', 'login'] + const validFrom = ['password', 'reset', 'register', 'login', 'Login'] if (!validFrom.includes(from.path.split('/')[1])) { next({ path: '/login' }) } else { @@ -62,6 +62,10 @@ const routes = [ path: '/password', component: () => import('../views/Pages/ForgotPassword.vue'), }, + { + path: '/password/:comingFrom', + component: () => import('../views/Pages/ForgotPassword.vue'), + }, { path: '/register-community', component: () => import('../views/Pages/RegisterCommunity.vue'), @@ -76,7 +80,7 @@ const routes = [ }, { path: '/checkEmail/:optin', - component: () => import('../views/Pages/CheckEmail.vue'), + component: () => import('../views/Pages/ResetPassword.vue'), }, { path: '*', component: NotFound }, ] diff --git a/frontend/src/views/Pages/CheckEmail.spec.js b/frontend/src/views/Pages/CheckEmail.spec.js deleted file mode 100644 index 2513d4b3f..000000000 --- a/frontend/src/views/Pages/CheckEmail.spec.js +++ /dev/null @@ -1,105 +0,0 @@ -import { mount, RouterLinkStub } from '@vue/test-utils' -import CheckEmail from './CheckEmail' - -const localVue = global.localVue - -const apolloQueryMock = jest.fn().mockRejectedValue({ message: 'error' }) - -const toasterMock = jest.fn() -const routerPushMock = jest.fn() - -describe('CheckEmail', () => { - let wrapper - - const mocks = { - $i18n: { - locale: 'en', - }, - $t: jest.fn((t) => t), - $route: { - params: { - optin: '123', - }, - }, - $toasted: { - error: toasterMock, - }, - $router: { - push: routerPushMock, - }, - $loading: { - show: jest.fn(() => { - return { hide: jest.fn() } - }), - }, - $apollo: { - query: apolloQueryMock, - }, - } - - const stubs = { - RouterLink: RouterLinkStub, - } - - const Wrapper = () => { - return mount(CheckEmail, { localVue, mocks, stubs }) - } - - describe('mount', () => { - beforeEach(() => { - wrapper = Wrapper() - }) - - it('calls the checkEmail when created', async () => { - expect(apolloQueryMock).toBeCalledWith( - expect.objectContaining({ variables: { optin: '123' } }), - ) - }) - - describe('No valid optin', () => { - it('toasts an error when no valid optin is given', () => { - expect(toasterMock).toHaveBeenCalledWith('error') - }) - - it('has a message suggesting to contact the support', () => { - expect(wrapper.find('div.header').text()).toContain('checkEmail.title') - expect(wrapper.find('div.header').text()).toContain('checkEmail.errorText') - }) - }) - - describe('is authenticated', () => { - beforeEach(() => { - apolloQueryMock.mockResolvedValue({ - data: { - checkEmail: { - sessionId: 1, - email: 'user@example.org', - language: 'de', - }, - }, - }) - }) - - it.skip('Has sessionId from API call', async () => { - await wrapper.vm.$nextTick() - expect(wrapper.vm.sessionId).toBe(1) - }) - - describe('Register header', () => { - it('has a welcome message', async () => { - expect(wrapper.find('div.header').text()).toContain('checkEmail.title') - }) - }) - - describe('links', () => { - it('has a link "Back"', async () => { - expect(wrapper.findAllComponents(RouterLinkStub).at(0).text()).toEqual('back') - }) - - it('links to /login when clicking "Back"', async () => { - expect(wrapper.findAllComponents(RouterLinkStub).at(0).props().to).toBe('/Login') - }) - }) - }) - }) -}) diff --git a/frontend/src/views/Pages/CheckEmail.vue b/frontend/src/views/Pages/CheckEmail.vue deleted file mode 100644 index 1a23f6b09..000000000 --- a/frontend/src/views/Pages/CheckEmail.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - diff --git a/frontend/src/views/Pages/ForgotPassword.spec.js b/frontend/src/views/Pages/ForgotPassword.spec.js index 91247d8a6..47c9fad56 100644 --- a/frontend/src/views/Pages/ForgotPassword.spec.js +++ b/frontend/src/views/Pages/ForgotPassword.spec.js @@ -8,30 +8,41 @@ const localVue = global.localVue const mockRouterPush = jest.fn() +const stubs = { + RouterLink: RouterLinkStub, +} + +const createMockObject = (comingFrom) => { + return { + localVue, + mocks: { + $t: jest.fn((t) => t), + $router: { + push: mockRouterPush, + }, + $apollo: { + query: mockAPIcall, + }, + $route: { + params: { + comingFrom, + }, + }, + }, + stubs, + } +} + describe('ForgotPassword', () => { let wrapper - const mocks = { - $t: jest.fn((t) => t), - $router: { - push: mockRouterPush, - }, - $apollo: { - query: mockAPIcall, - }, - } - - const stubs = { - RouterLink: RouterLinkStub, - } - - const Wrapper = () => { - return mount(ForgotPassword, { localVue, mocks, stubs }) + const Wrapper = (functionN) => { + return mount(ForgotPassword, functionN) } describe('mount', () => { beforeEach(() => { - wrapper = Wrapper() + wrapper = Wrapper(createMockObject()) }) it('renders the component', () => { @@ -144,5 +155,15 @@ describe('ForgotPassword', () => { }) }) }) + + describe('comingFrom login', () => { + beforeEach(() => { + wrapper = Wrapper(createMockObject('reset')) + }) + + it('has another subtitle', () => { + expect(wrapper.find('p.text-lead').text()).toEqual('settings.password.resend_subtitle') + }) + }) }) }) diff --git a/frontend/src/views/Pages/ForgotPassword.vue b/frontend/src/views/Pages/ForgotPassword.vue index 444e94495..c8b31246f 100644 --- a/frontend/src/views/Pages/ForgotPassword.vue +++ b/frontend/src/views/Pages/ForgotPassword.vue @@ -5,8 +5,8 @@
-

{{ $t('settings.password.reset') }}

-

{{ $t('settings.password.subtitle') }}

+

{{ $t(displaySetup.headline) }}

+

{{ $t(displaySetup.subtitle) }}

@@ -22,7 +22,7 @@
- {{ $t('settings.password.send_now') }} + {{ $t(displaySetup.button) }}
@@ -41,6 +41,21 @@ import { sendResetPasswordEmail } from '../../graphql/queries' import InputEmail from '../../components/Inputs/InputEmail' +const textFields = { + reset: { + headline: 'settings.password.reset', + subtitle: 'settings.password.resend_subtitle', + button: 'settings.password.send_now', + cancel: 'back', + }, + login: { + headline: 'settings.password.reset', + subtitle: 'settings.password.subtitle', + button: 'settings.password.send_now', + cancel: 'back', + }, +} + export default { name: 'password', components: { @@ -52,6 +67,7 @@ export default { form: { email: '', }, + displaySetup: {}, } }, methods: { @@ -71,6 +87,13 @@ export default { }) }, }, + created() { + if (this.$route.params.comingFrom) { + this.displaySetup = textFields[this.$route.params.comingFrom] + } else { + this.displaySetup = textFields.login + } + }, } diff --git a/frontend/src/views/Pages/Login.spec.js b/frontend/src/views/Pages/Login.spec.js index 53bb9446f..a16a8ad54 100644 --- a/frontend/src/views/Pages/Login.spec.js +++ b/frontend/src/views/Pages/Login.spec.js @@ -238,7 +238,7 @@ describe('Login', () => { describe('login fails', () => { beforeEach(() => { apolloQueryMock.mockRejectedValue({ - message: 'Ouch!', + message: '..No user with this credentials', }) }) diff --git a/frontend/src/views/Pages/Login.vue b/frontend/src/views/Pages/Login.vue index 45e700099..0ce209551 100755 --- a/frontend/src/views/Pages/Login.vue +++ b/frontend/src/views/Pages/Login.vue @@ -105,11 +105,11 @@ export default { loader.hide() }) .catch((error) => { - if (!error.message.includes('user email not validated')) { + if (error.message.includes('No user with this credentials')) { this.$toasted.error(this.$t('error.no-account')) } else { // : this.$t('error.no-email-verify') - this.$router.push('/thx/login') + this.$router.push('/reset/login') } loader.hide() }) diff --git a/frontend/src/views/Pages/Register.spec.js b/frontend/src/views/Pages/Register.spec.js index d6814bd49..b75c881c6 100644 --- a/frontend/src/views/Pages/Register.spec.js +++ b/frontend/src/views/Pages/Register.spec.js @@ -151,16 +151,10 @@ describe('Register', () => { expect(wrapper.find('#Email-input-field').exists()).toBeTruthy() }) - it('has password input fields', () => { - expect(wrapper.find('input[name="form.password"]').exists()).toBeTruthy() - }) - - it('has password repeat input fields', () => { - expect(wrapper.find('input[name="form.passwordRepeat"]').exists()).toBeTruthy() - }) it('has Language selected field', () => { expect(wrapper.find('.selectedLanguage').exists()).toBeTruthy() }) + it('selects Language value en', async () => { wrapper.find('.selectedLanguage').findAll('option').at(1).setSelected() expect(wrapper.find('.selectedLanguage').element.value).toBe('en') @@ -223,8 +217,6 @@ describe('Register', () => { wrapper.find('#registerFirstname').setValue('Max') wrapper.find('#registerLastname').setValue('Mustermann') wrapper.find('#Email-input-field').setValue('max.mustermann@gradido.net') - wrapper.find('input[name="form.password"]').setValue('Aa123456_') - wrapper.find('input[name="form.passwordRepeat"]').setValue('Aa123456_') wrapper.find('.language-switch-select').findAll('option').at(1).setSelected() wrapper.find('#publisherid').setValue('12345') }) @@ -280,7 +272,6 @@ describe('Register', () => { email: 'max.mustermann@gradido.net', firstName: 'Max', lastName: 'Mustermann', - password: 'Aa123456_', language: 'en', publisherId: 12345, }, diff --git a/frontend/src/views/Pages/Register.vue b/frontend/src/views/Pages/Register.vue index 8669781a4..070d80bb9 100755 --- a/frontend/src/views/Pages/Register.vue +++ b/frontend/src/views/Pages/Register.vue @@ -85,10 +85,6 @@
- @@ -194,14 +190,13 @@