Merge branch 'master' into login_fix_creation_validation

This commit is contained in:
einhornimmond 2021-12-07 13:59:50 +01:00 committed by GitHub
commit cff1e13f16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 429 additions and 606 deletions

View File

@ -399,7 +399,7 @@ jobs:
report_name: Coverage Frontend report_name: Coverage Frontend
type: lcov type: lcov
result_path: ./coverage/lcov.info result_path: ./coverage/lcov.info
min_coverage: 87 min_coverage: 86
token: ${{ github.token }} token: ${{ github.token }}
############################################################################## ##############################################################################
@ -491,7 +491,7 @@ jobs:
report_name: Coverage Backend report_name: Coverage Backend
type: lcov type: lcov
result_path: ./backend/coverage/lcov.info result_path: ./backend/coverage/lcov.info
min_coverage: 39 min_coverage: 37
token: ${{ github.token }} token: ${{ github.token }}
############################################################################## ##############################################################################

View File

@ -2,8 +2,6 @@ PORT=4000
JWT_SECRET=secret123 JWT_SECRET=secret123
JWT_EXPIRES_IN=10m JWT_EXPIRES_IN=10m
GRAPHIQL=false GRAPHIQL=false
LOGIN_API_URL=http://login-server:1201/
COMMUNITY_API_URL=http://nginx/api/
GDT_API_URL=https://gdt.gradido.net GDT_API_URL=https://gdt.gradido.net
DB_HOST=localhost DB_HOST=localhost
DB_PORT=3306 DB_PORT=3306

View File

@ -4,10 +4,8 @@ export const INALIENABLE_RIGHTS = [
RIGHTS.LOGIN, RIGHTS.LOGIN,
RIGHTS.GET_COMMUNITY_INFO, RIGHTS.GET_COMMUNITY_INFO,
RIGHTS.COMMUNITIES, RIGHTS.COMMUNITIES,
RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE,
RIGHTS.CREATE_USER, RIGHTS.CREATE_USER,
RIGHTS.SEND_RESET_PASSWORD_EMAIL, RIGHTS.SEND_RESET_PASSWORD_EMAIL,
RIGHTS.RESET_PASSWORD, RIGHTS.SET_PASSWORD,
RIGHTS.CHECK_USERNAME, RIGHTS.CHECK_USERNAME,
RIGHTS.CHECK_EMAIL,
] ]

View File

@ -12,14 +12,12 @@ export enum RIGHTS {
SUBSCRIBE_NEWSLETTER = 'SUBSCRIBE_NEWSLETTER', SUBSCRIBE_NEWSLETTER = 'SUBSCRIBE_NEWSLETTER',
TRANSACTION_LIST = 'TRANSACTION_LIST', TRANSACTION_LIST = 'TRANSACTION_LIST',
SEND_COINS = 'SEND_COINS', SEND_COINS = 'SEND_COINS',
LOGIN_VIA_EMAIL_VERIFICATION_CODE = 'LOGIN_VIA_EMAIL_VERIFICATION_CODE',
LOGOUT = 'LOGOUT', LOGOUT = 'LOGOUT',
CREATE_USER = 'CREATE_USER', CREATE_USER = 'CREATE_USER',
SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL', SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL',
RESET_PASSWORD = 'RESET_PASSWORD', SET_PASSWORD = 'SET_PASSWORD',
UPDATE_USER_INFOS = 'UPDATE_USER_INFOS', UPDATE_USER_INFOS = 'UPDATE_USER_INFOS',
CHECK_USERNAME = 'CHECK_USERNAME', CHECK_USERNAME = 'CHECK_USERNAME',
CHECK_EMAIL = 'CHECK_EMAIL',
HAS_ELOPAGE = 'HAS_ELOPAGE', HAS_ELOPAGE = 'HAS_ELOPAGE',
// Admin // Admin
SEARCH_USERS = 'SEARCH_USERS', SEARCH_USERS = 'SEARCH_USERS',

View File

@ -8,8 +8,6 @@ const server = {
JWT_SECRET: process.env.JWT_SECRET || 'secret123', JWT_SECRET: process.env.JWT_SECRET || 'secret123',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '10m', JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '10m',
GRAPHIQL: process.env.GRAPHIQL === 'true' || false, 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', GDT_API_URL: process.env.GDT_API_URL || 'https://gdt.gradido.net',
PRODUCTION: process.env.NODE_ENV === 'production' || false, PRODUCTION: process.env.NODE_ENV === 'production' || false,
} }
@ -53,6 +51,7 @@ const email = {
EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT || '587', EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT || '587',
EMAIL_LINK_VERIFICATION: EMAIL_LINK_VERIFICATION:
process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/vue/checkEmail/$1', 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 = { const webhook = {

View File

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

View File

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

View File

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

View File

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

View File

@ -3,24 +3,16 @@
import fs from 'fs' import fs from 'fs'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' 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 CONFIG from '../../config'
import { LoginViaVerificationCode } from '../model/LoginViaVerificationCode'
import { SendPasswordResetEmailResponse } from '../model/SendPasswordResetEmailResponse'
import { User } from '../model/User' import { User } from '../model/User'
import { User as DbUser } from '@entity/User' import { User as DbUser } from '@entity/User'
import { encode } from '../../auth/JWT' import { encode } from '../../auth/JWT'
import ChangePasswordArgs from '../arg/ChangePasswordArgs'
import CheckUsernameArgs from '../arg/CheckUsernameArgs' import CheckUsernameArgs from '../arg/CheckUsernameArgs'
import CreateUserArgs from '../arg/CreateUserArgs' import CreateUserArgs from '../arg/CreateUserArgs'
import UnsecureLoginArgs from '../arg/UnsecureLoginArgs' import UnsecureLoginArgs from '../arg/UnsecureLoginArgs'
import UpdateUserInfosArgs from '../arg/UpdateUserInfosArgs' import UpdateUserInfosArgs from '../arg/UpdateUserInfosArgs'
import { apiPost, apiGet } from '../../apis/HttpRequest' import { klicktippNewsletterStateMiddleware } from '../../middleware/klicktippMiddleware'
import {
klicktippRegistrationMiddleware,
klicktippNewsletterStateMiddleware,
} from '../../middleware/klicktippMiddleware'
import { CheckEmailResponse } from '../model/CheckEmailResponse'
import { UserSettingRepository } from '../../typeorm/repository/UserSettingRepository' import { UserSettingRepository } from '../../typeorm/repository/UserSettingRepository'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser' import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { Setting } from '../enum/Setting' import { Setting } from '../enum/Setting'
@ -30,10 +22,14 @@ import { LoginUserBackup } from '@entity/LoginUserBackup'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendEMail } from '../../util/sendEMail' import { sendEMail } from '../../util/sendEMail'
import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys' import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys'
import { signIn } from '../../apis/KlicktippController'
import { RIGHTS } from '../../auth/RIGHTS' import { RIGHTS } from '../../auth/RIGHTS'
import { ServerUserRepository } from '../../typeorm/repository/ServerUser' import { ServerUserRepository } from '../../typeorm/repository/ServerUser'
import { ROLE_ADMIN } from '../../auth/ROLES' 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 // eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native') const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
@ -58,50 +54,8 @@ const PassphraseGenerate = (): string[] => {
result.push(WORDS[sodium.randombytes_random() % 2048]) result.push(WORDS[sodium.randombytes_random() % 2048])
} }
return result 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[] => { const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) { if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) {
throw new Error('passphrase empty or to short') throw new Error('passphrase empty or to short')
@ -240,13 +194,21 @@ export class UserResolver {
@Ctx() context: any, @Ctx() context: any,
): Promise<User> { ): Promise<User> {
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
// const result = await apiPost(CONFIG.LOGIN_API_URL + 'unsecureLogin', { email, password })
// UnsecureLogin
const loginUserRepository = getCustomRepository(LoginUserRepository) const loginUserRepository = getCustomRepository(LoginUserRepository)
const loginUser = await loginUserRepository.findByEmail(email).catch(() => { const loginUser = await loginUserRepository.findByEmail(email).catch(() => {
throw new Error('No user with this credentials') 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 passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
const loginUserPassword = BigInt(loginUser.password.toString()) const loginUserPassword = BigInt(loginUser.password.toString())
if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) { if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
@ -320,22 +282,6 @@ export class UserResolver {
return user return user
} }
@Authorized([RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE])
@Query(() => LoginViaVerificationCode)
async loginViaEmailVerificationCode(
@Arg('optin') optin: string,
): Promise<LoginViaVerificationCode> {
// 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]) @Authorized([RIGHTS.LOGOUT])
@Query(() => String) @Query(() => String)
async logout(): Promise<boolean> { async logout(): Promise<boolean> {
@ -350,7 +296,7 @@ export class UserResolver {
@Authorized([RIGHTS.CREATE_USER]) @Authorized([RIGHTS.CREATE_USER])
@Mutation(() => String) @Mutation(() => String)
async createUser( async createUser(
@Args() { email, firstName, lastName, password, language, publisherId }: CreateUserArgs, @Args() { email, firstName, lastName, language, publisherId }: CreateUserArgs,
): Promise<string> { ): Promise<string> {
// TODO: wrong default value (should be null), how does graphql work here? Is it an required field? // TODO: wrong default value (should be null), how does graphql work here? Is it an required field?
// default int publisher_id = 0; // default int publisher_id = 0;
@ -360,13 +306,6 @@ export class UserResolver {
language = DEFAULT_LANGUAGE 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 // Validate username
// TODO: never true // TODO: never true
const username = '' const username = ''
@ -384,10 +323,10 @@ export class UserResolver {
} }
const passphrase = PassphraseGenerate() const passphrase = PassphraseGenerate()
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key // const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash // const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email) const emailHash = getEmailHash(email)
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
// Table: login_users // Table: login_users
const loginUser = new LoginUser() const loginUser = new LoginUser()
@ -396,13 +335,13 @@ export class UserResolver {
loginUser.lastName = lastName loginUser.lastName = lastName
loginUser.username = username loginUser.username = username
loginUser.description = '' loginUser.description = ''
loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash // loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash
loginUser.emailHash = emailHash loginUser.emailHash = emailHash
loginUser.language = language loginUser.language = language
loginUser.groupId = 1 loginUser.groupId = 1
loginUser.publisherId = publisherId loginUser.publisherId = publisherId
loginUser.pubKey = keyPair[0] // loginUser.pubKey = keyPair[0]
loginUser.privKey = encryptedPrivkey // loginUser.privKey = encryptedPrivkey
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
@ -428,11 +367,13 @@ export class UserResolver {
// Table: state_users // Table: state_users
const dbUser = new DbUser() const dbUser = new DbUser()
dbUser.pubkey = keyPair[0]
dbUser.email = email dbUser.email = email
dbUser.firstName = firstName dbUser.firstName = firstName
dbUser.lastName = lastName dbUser.lastName = lastName
dbUser.username = username 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) => { await queryRunner.manager.save(dbUser).catch((er) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -441,10 +382,11 @@ export class UserResolver {
}) })
// Store EmailOptIn in DB // Store EmailOptIn in DB
// TODO: this has duplicate code with sendResetPasswordEmail
const emailOptIn = new LoginEmailOptIn() const emailOptIn = new LoginEmailOptIn()
emailOptIn.userId = loginUserId emailOptIn.userId = loginUserId
emailOptIn.verificationCode = random(64) emailOptIn.verificationCode = random(64)
emailOptIn.emailOptInTypeId = 2 emailOptIn.emailOptInTypeId = EMAIL_OPT_IN_REGISTER
await queryRunner.manager.save(emailOptIn).catch((error) => { await queryRunner.manager.save(emailOptIn).catch((error) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -489,38 +431,172 @@ export class UserResolver {
} }
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL]) @Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
@Query(() => SendPasswordResetEmailResponse) @Query(() => Boolean)
async sendResetPasswordEmail( async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> {
@Arg('email') email: string, // TODO: this has duplicate code with createUser
): Promise<SendPasswordResetEmailResponse> { // TODO: Moriz: I think we do not need this variable.
const payload = { let emailAlreadySend = false
email,
email_text: 7, const loginUserRepository = await getCustomRepository(LoginUserRepository)
email_verification_code_type: 'resetPassword', 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) { const link = CONFIG.EMAIL_LINK_SETPASSWORD.replace(
throw new Error(response.data) /\$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]) @Authorized([RIGHTS.SET_PASSWORD])
@Mutation(() => String) @Mutation(() => Boolean)
async resetPassword( async setPassword(
@Args() @Arg('code') code: string,
{ sessionId, email, password }: ChangePasswordArgs, @Arg('password') password: string,
): Promise<string> { ): Promise<boolean> {
const payload = { // Validate Password
session_id: sessionId, if (!isPassword(password)) {
email, throw new Error(
password, '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) { // Load code
throw new Error(result.data) 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]) @Authorized([RIGHTS.UPDATE_USER_INFOS])
@ -656,19 +732,6 @@ export class UserResolver {
return true return true
} }
@Authorized([RIGHTS.CHECK_EMAIL])
@Query(() => CheckEmailResponse)
@UseMiddleware(klicktippRegistrationMiddleware)
async checkEmail(@Arg('optin') optin: string): Promise<CheckEmailResponse> {
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]) @Authorized([RIGHTS.HAS_ELOPAGE])
@Query(() => Boolean) @Query(() => Boolean)
async hasElopage(@Ctx() context: any): Promise<boolean> { async hasElopage(@Ctx() context: any): Promise<boolean> {

View File

@ -1,20 +1,20 @@
import { MiddlewareFn } from 'type-graphql' import { MiddlewareFn } from 'type-graphql'
import { signIn, getKlickTippUser } from '../apis/KlicktippController' import { /* signIn, */ getKlickTippUser } from '../apis/KlicktippController'
import { KlickTipp } from '../graphql/model/KlickTipp' import { KlickTipp } from '../graphql/model/KlickTipp'
import CONFIG from '../config/index' import CONFIG from '../config/index'
export const klicktippRegistrationMiddleware: MiddlewareFn = async ( // export const klicktippRegistrationMiddleware: MiddlewareFn = async (
// Only for demo // // Only for demo
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ // /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
{ root, args, context, info }, // { root, args, context, info },
next, // next,
) => { // ) => {
// Do Something here before resolver is called // // Do Something here before resolver is called
const result = await next() // const result = await next()
// Do Something here after resolver is completed // // Do Something here after resolver is completed
await signIn(result.email, result.language, result.firstName, result.lastName) // await signIn(result.email, result.language, result.firstName, result.lastName)
return result // return result
} // }
export const klicktippNewsletterStateMiddleware: MiddlewareFn = async ( export const klicktippNewsletterStateMiddleware: MiddlewareFn = async (
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ /* eslint-disable-next-line @typescript-eslint/no-unused-vars */

View File

@ -23,6 +23,7 @@
variant="outline-light" variant="outline-light"
@click="toggleShowPassword" @click="toggleShowPassword"
class="border-left-0 rounded-right" class="border-left-0 rounded-right"
tabindex="-1"
> >
<b-icon :icon="showPassword ? 'eye' : 'eye-slash'" /> <b-icon :icon="showPassword ? 'eye' : 'eye-slash'" />
</b-button> </b-button>

View File

@ -12,9 +12,9 @@ export const unsubscribeNewsletter = gql`
} }
` `
export const resetPassword = gql` export const setPassword = gql`
mutation($sessionId: Float!, $email: String!, $password: String!) { mutation($code: String!, $password: String!) {
resetPassword(sessionId: $sessionId, email: $email, password: $password) setPassword(code: $code, password: $password)
} }
` `
@ -42,12 +42,11 @@ export const updateUserInfos = gql`
} }
` `
export const registerUser = gql` export const createUser = gql`
mutation( mutation(
$firstName: String! $firstName: String!
$lastName: String! $lastName: String!
$email: String! $email: String!
$password: String!
$language: String! $language: String!
$publisherId: Int $publisherId: Int
) { ) {
@ -55,7 +54,6 @@ export const registerUser = gql`
email: $email email: $email
firstName: $firstName firstName: $firstName
lastName: $lastName lastName: $lastName
password: $password
language: $language language: $language
publisherId: $publisherId publisherId: $publisherId
) )

View File

@ -46,15 +46,6 @@ export const logout = gql`
} }
` `
export const loginViaEmailVerificationCode = gql`
query($optin: String!) {
loginViaEmailVerificationCode(optin: $optin) {
sessionId
email
}
}
`
export const transactionsQuery = gql` export const transactionsQuery = gql`
query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) { query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) { transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
@ -88,9 +79,7 @@ export const transactionsQuery = gql`
export const sendResetPasswordEmail = gql` export const sendResetPasswordEmail = gql`
query($email: String!) { query($email: String!) {
sendResetPasswordEmail(email: $email) { sendResetPasswordEmail(email: $email)
state
}
} }
` `
@ -118,15 +107,6 @@ export const listGDTEntriesQuery = gql`
} }
` `
export const checkEmailQuery = gql`
query($optin: String!) {
checkEmail(optin: $optin) {
email
sessionId
}
}
`
export const communityInfo = gql` export const communityInfo = gql`
query { query {
getCommunityInfo { getCommunityInfo {

View File

@ -145,12 +145,17 @@
"password": { "password": {
"change-password": "Passwort ändern", "change-password": "Passwort ändern",
"forgot_pwd": "Passwort vergessen?", "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": "Passwort zurücksetzen",
"reset-password": { "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." "text": "Jetzt kannst du ein neues Passwort speichern, mit dem du dich zukünftig in der Gradido-App anmelden kannst."
}, },
"send_now": "Jetzt senden", "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." "subtitle": "Wenn du dein Passwort vergessen hast, kannst du es hier zurücksetzen."
} }
}, },

View File

@ -145,12 +145,17 @@
"password": { "password": {
"change-password": "Change password", "change-password": "Change password",
"forgot_pwd": "Forgot 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": "Reset password",
"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." "text": "Now you can save a new password to login to the Gradido-App in the future."
}, },
"send_now": "Send now", "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." "subtitle": "If you have forgotten your password, you can reset it here."
} }
}, },

View File

@ -49,8 +49,8 @@ describe('router', () => {
expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' }) expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' })
}) })
it('has fifteen routes defined', () => { it('has sixteen routes defined', () => {
expect(routes).toHaveLength(15) expect(routes).toHaveLength(16)
}) })
describe('overview', () => { describe('overview', () => {
@ -167,7 +167,7 @@ describe('router', () => {
describe('checkEmail', () => { describe('checkEmail', () => {
it('loads the "CheckEmail" component', async () => { it('loads the "CheckEmail" component', async () => {
const component = await routes.find((r) => r.path === '/checkEmail/:optin').component() const component = await routes.find((r) => r.path === '/checkEmail/:optin').component()
expect(component.default.name).toBe('CheckEmail') expect(component.default.name).toBe('ResetPassword')
}) })
}) })

View File

@ -50,7 +50,7 @@ const routes = [
path: '/thx/:comingFrom', path: '/thx/:comingFrom',
component: () => import('../views/Pages/thx.vue'), component: () => import('../views/Pages/thx.vue'),
beforeEnter: (to, from, next) => { beforeEnter: (to, from, next) => {
const validFrom = ['password', 'reset', 'register', 'login'] const validFrom = ['password', 'reset', 'register', 'login', 'Login']
if (!validFrom.includes(from.path.split('/')[1])) { if (!validFrom.includes(from.path.split('/')[1])) {
next({ path: '/login' }) next({ path: '/login' })
} else { } else {
@ -62,6 +62,10 @@ const routes = [
path: '/password', path: '/password',
component: () => import('../views/Pages/ForgotPassword.vue'), component: () => import('../views/Pages/ForgotPassword.vue'),
}, },
{
path: '/password/:comingFrom',
component: () => import('../views/Pages/ForgotPassword.vue'),
},
{ {
path: '/register-community', path: '/register-community',
component: () => import('../views/Pages/RegisterCommunity.vue'), component: () => import('../views/Pages/RegisterCommunity.vue'),
@ -76,7 +80,7 @@ const routes = [
}, },
{ {
path: '/checkEmail/:optin', path: '/checkEmail/:optin',
component: () => import('../views/Pages/CheckEmail.vue'), component: () => import('../views/Pages/ResetPassword.vue'),
}, },
{ path: '*', component: NotFound }, { path: '*', component: NotFound },
] ]

View File

@ -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')
})
})
})
})
})

View File

@ -1,72 +0,0 @@
<template>
<div class="checkemail-form">
<b-container>
<div class="header p-4" ref="header">
<div class="header-body text-center mb-7">
<b-row class="justify-content-center">
<b-col xl="5" lg="6" md="8" class="px-2">
<h1>{{ $t('site.checkEmail.title') }}</h1>
<div class="pb-4" v-if="!pending">
<span v-if="!authenticated">
{{ $t('site.checkEmail.errorText') }}
</span>
</div>
</b-col>
</b-row>
</div>
</div>
</b-container>
<b-container class="mt--8 p-1">
<b-row>
<b-col class="text-center py-lg-4">
<router-link to="/Login" class="mt-3">{{ $t('back') }}</router-link>
</b-col>
</b-row>
</b-container>
</div>
</template>
<script>
import { checkEmailQuery } from '../../graphql/queries'
export default {
name: 'CheckEmail',
data() {
return {
authenticated: false,
sessionId: null,
email: null,
pending: true,
}
},
methods: {
async authenticate() {
const loader = this.$loading.show({
container: this.$refs.header,
})
const optin = this.$route.params.optin
this.$apollo
.query({
query: checkEmailQuery,
variables: {
optin: optin,
},
})
.then((result) => {
this.authenticated = true
this.sessionId = result.data.checkEmail.sessionId
this.email = result.data.checkEmail.email
this.$router.push('/thx/checkEmail')
})
.catch((error) => {
this.$toasted.error(error.message)
})
loader.hide()
this.pending = false
},
},
mounted() {
this.authenticate()
},
}
</script>
<style></style>

View File

@ -8,30 +8,41 @@ const localVue = global.localVue
const mockRouterPush = jest.fn() 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', () => { describe('ForgotPassword', () => {
let wrapper let wrapper
const mocks = { const Wrapper = (functionN) => {
$t: jest.fn((t) => t), return mount(ForgotPassword, functionN)
$router: {
push: mockRouterPush,
},
$apollo: {
query: mockAPIcall,
},
}
const stubs = {
RouterLink: RouterLinkStub,
}
const Wrapper = () => {
return mount(ForgotPassword, { localVue, mocks, stubs })
} }
describe('mount', () => { describe('mount', () => {
beforeEach(() => { beforeEach(() => {
wrapper = Wrapper() wrapper = Wrapper(createMockObject())
}) })
it('renders the component', () => { 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')
})
})
}) })
}) })

View File

@ -5,8 +5,8 @@
<div class="header-body text-center mb-7"> <div class="header-body text-center mb-7">
<b-row class="justify-content-center"> <b-row class="justify-content-center">
<b-col xl="5" lg="6" md="8" class="px-2"> <b-col xl="5" lg="6" md="8" class="px-2">
<h1>{{ $t('settings.password.reset') }}</h1> <h1>{{ $t(displaySetup.headline) }}</h1>
<p class="text-lead">{{ $t('settings.password.subtitle') }}</p> <p class="text-lead">{{ $t(displaySetup.subtitle) }}</p>
</b-col> </b-col>
</b-row> </b-row>
</div> </div>
@ -22,7 +22,7 @@
<input-email v-model="form.email"></input-email> <input-email v-model="form.email"></input-email>
<div class="text-center"> <div class="text-center">
<b-button type="submit" variant="primary"> <b-button type="submit" variant="primary">
{{ $t('settings.password.send_now') }} {{ $t(displaySetup.button) }}
</b-button> </b-button>
</div> </div>
</b-form> </b-form>
@ -41,6 +41,21 @@
import { sendResetPasswordEmail } from '../../graphql/queries' import { sendResetPasswordEmail } from '../../graphql/queries'
import InputEmail from '../../components/Inputs/InputEmail' 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 { export default {
name: 'password', name: 'password',
components: { components: {
@ -52,6 +67,7 @@ export default {
form: { form: {
email: '', email: '',
}, },
displaySetup: {},
} }
}, },
methods: { 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
}
},
} }
</script> </script>
<style></style> <style></style>

View File

@ -238,7 +238,7 @@ describe('Login', () => {
describe('login fails', () => { describe('login fails', () => {
beforeEach(() => { beforeEach(() => {
apolloQueryMock.mockRejectedValue({ apolloQueryMock.mockRejectedValue({
message: 'Ouch!', message: '..No user with this credentials',
}) })
}) })

View File

@ -105,11 +105,11 @@ export default {
loader.hide() loader.hide()
}) })
.catch((error) => { .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')) this.$toasted.error(this.$t('error.no-account'))
} else { } else {
// : this.$t('error.no-email-verify') // : this.$t('error.no-email-verify')
this.$router.push('/thx/login') this.$router.push('/reset/login')
} }
loader.hide() loader.hide()
}) })

View File

@ -151,16 +151,10 @@ describe('Register', () => {
expect(wrapper.find('#Email-input-field').exists()).toBeTruthy() 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', () => { it('has Language selected field', () => {
expect(wrapper.find('.selectedLanguage').exists()).toBeTruthy() expect(wrapper.find('.selectedLanguage').exists()).toBeTruthy()
}) })
it('selects Language value en', async () => { it('selects Language value en', async () => {
wrapper.find('.selectedLanguage').findAll('option').at(1).setSelected() wrapper.find('.selectedLanguage').findAll('option').at(1).setSelected()
expect(wrapper.find('.selectedLanguage').element.value).toBe('en') expect(wrapper.find('.selectedLanguage').element.value).toBe('en')
@ -223,8 +217,6 @@ describe('Register', () => {
wrapper.find('#registerFirstname').setValue('Max') wrapper.find('#registerFirstname').setValue('Max')
wrapper.find('#registerLastname').setValue('Mustermann') wrapper.find('#registerLastname').setValue('Mustermann')
wrapper.find('#Email-input-field').setValue('max.mustermann@gradido.net') 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('.language-switch-select').findAll('option').at(1).setSelected()
wrapper.find('#publisherid').setValue('12345') wrapper.find('#publisherid').setValue('12345')
}) })
@ -280,7 +272,6 @@ describe('Register', () => {
email: 'max.mustermann@gradido.net', email: 'max.mustermann@gradido.net',
firstName: 'Max', firstName: 'Max',
lastName: 'Mustermann', lastName: 'Mustermann',
password: 'Aa123456_',
language: 'en', language: 'en',
publisherId: 12345, publisherId: 12345,
}, },

View File

@ -85,10 +85,6 @@
<input-email v-model="form.email"></input-email> <input-email v-model="form.email"></input-email>
<hr /> <hr />
<input-password-confirmation
v-model="form.password"
:register="register"
></input-password-confirmation>
<b-row> <b-row>
<b-col cols="12"> <b-col cols="12">
@ -194,14 +190,13 @@
</template> </template>
<script> <script>
import InputEmail from '../../components/Inputs/InputEmail.vue' import InputEmail from '../../components/Inputs/InputEmail.vue'
import InputPasswordConfirmation from '../../components/Inputs/InputPasswordConfirmation.vue'
import LanguageSwitchSelect from '../../components/LanguageSwitchSelect.vue' import LanguageSwitchSelect from '../../components/LanguageSwitchSelect.vue'
import { registerUser } from '../../graphql/mutations' import { createUser } from '../../graphql/mutations'
import { localeChanged } from 'vee-validate' import { localeChanged } from 'vee-validate'
import { getCommunityInfoMixin } from '../../mixins/getCommunityInfo' import { getCommunityInfoMixin } from '../../mixins/getCommunityInfo'
export default { export default {
components: { InputPasswordConfirmation, InputEmail, LanguageSwitchSelect }, components: { InputEmail, LanguageSwitchSelect },
name: 'register', name: 'register',
mixins: [getCommunityInfoMixin], mixins: [getCommunityInfoMixin],
data() { data() {
@ -211,10 +206,6 @@ export default {
lastname: '', lastname: '',
email: '', email: '',
agree: false, agree: false,
password: {
password: '',
passwordRepeat: '',
},
}, },
language: '', language: '',
submitted: false, submitted: false,
@ -240,12 +231,11 @@ export default {
async onSubmit() { async onSubmit() {
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: registerUser, mutation: createUser,
variables: { variables: {
email: this.form.email, email: this.form.email,
firstName: this.form.firstname, firstName: this.form.firstname,
lastName: this.form.lastname, lastName: this.form.lastname,
password: this.form.password.password,
language: this.language, language: this.language,
publisherId: this.$store.state.publisherId, publisherId: this.$store.state.publisherId,
}, },
@ -264,8 +254,6 @@ export default {
this.form.email = '' this.form.email = ''
this.form.firstname = '' this.form.firstname = ''
this.form.lastname = '' this.form.lastname = ''
this.form.password.password = ''
this.form.password.passwordRepeat = ''
}, },
}, },
computed: { computed: {

View File

@ -6,95 +6,76 @@ import flushPromises from 'flush-promises'
const localVue = global.localVue const localVue = global.localVue
const apolloQueryMock = jest.fn().mockRejectedValue({ message: 'error' })
const apolloMutationMock = jest.fn() const apolloMutationMock = jest.fn()
const toasterMock = jest.fn() const toasterMock = jest.fn()
const routerPushMock = jest.fn() const routerPushMock = jest.fn()
const stubs = {
RouterLink: RouterLinkStub,
}
const createMockObject = (comingFrom) => {
return {
localVue,
mocks: {
$i18n: {
locale: 'en',
},
$t: jest.fn((t) => t),
$route: {
params: {
optin: '123',
comingFrom,
},
},
$toasted: {
error: toasterMock,
},
$router: {
push: routerPushMock,
},
$loading: {
show: jest.fn(() => {
return { hide: jest.fn() }
}),
},
$apollo: {
mutate: apolloMutationMock,
},
},
stubs,
}
}
describe('ResetPassword', () => { describe('ResetPassword', () => {
let wrapper let wrapper
const mocks = { const Wrapper = (functionName) => {
$i18n: { return mount(ResetPassword, functionName)
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: {
mutate: apolloMutationMock,
query: apolloQueryMock,
},
}
const stubs = {
RouterLink: RouterLinkStub,
}
const Wrapper = () => {
return mount(ResetPassword, { localVue, mocks, stubs })
} }
describe('mount', () => { describe('mount', () => {
beforeEach(() => { beforeEach(() => {
wrapper = Wrapper() wrapper = Wrapper(createMockObject())
})
it('calls the email verification when created', async () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({ variables: { optin: '123' } }),
)
}) })
describe('No valid optin', () => { describe('No valid optin', () => {
it('does not render the Reset Password form when not authenticated', () => { it.skip('does not render the Reset Password form when not authenticated', () => {
expect(wrapper.find('form').exists()).toBeFalsy() expect(wrapper.find('form').exists()).toBeFalsy()
}) })
it('toasts an error when no valid optin is given', () => { it.skip('toasts an error when no valid optin is given', () => {
expect(toasterMock).toHaveBeenCalledWith('error') expect(toasterMock).toHaveBeenCalledWith('error')
}) })
it('has a message suggesting to contact the support', () => { 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.reset')
expect(wrapper.find('div.header').text()).toContain( expect(wrapper.find('div.header').text()).toContain('settings.password.not-authenticated')
'settings.password.reset-password.not-authenticated',
)
}) })
}) })
describe('is authenticated', () => { describe('is authenticated', () => {
beforeEach(() => {
apolloQueryMock.mockResolvedValue({
data: {
loginViaEmailVerificationCode: {
sessionId: 1,
email: 'user@example.org',
},
},
})
})
it.skip('Has sessionId from API call', async () => {
await wrapper.vm.$nextTick()
expect(wrapper.vm.sessionId).toBe(1)
})
it('renders the Reset Password form when authenticated', () => { it('renders the Reset Password form when authenticated', () => {
expect(wrapper.find('div.resetpwd-form').exists()).toBeTruthy() expect(wrapper.find('div.resetpwd-form').exists()).toBeTruthy()
}) })
@ -114,7 +95,7 @@ describe('ResetPassword', () => {
}) })
it('links to /login when clicking "Back"', async () => { it('links to /login when clicking "Back"', async () => {
expect(wrapper.findAllComponents(RouterLinkStub).at(0).props().to).toBe('/Login') expect(wrapper.findAllComponents(RouterLinkStub).at(0).props().to).toBe('/login')
}) })
}) })
@ -128,7 +109,7 @@ describe('ResetPassword', () => {
}) })
it('toggles the first input field to text when eye icon is clicked', async () => { it('toggles the first input field to text when eye icon is clicked', async () => {
wrapper.findAll('button').at(0).trigger('click') await wrapper.findAll('button').at(0).trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.findAll('input').at(0).attributes('type')).toBe('text') expect(wrapper.findAll('input').at(0).attributes('type')).toBe('text')
}) })
@ -142,37 +123,61 @@ describe('ResetPassword', () => {
describe('submit form', () => { describe('submit form', () => {
beforeEach(async () => { beforeEach(async () => {
await wrapper.setData({ authenticated: true, sessionId: 1 }) // wrapper = Wrapper(createMockObject())
await wrapper.vm.$nextTick()
await wrapper.findAll('input').at(0).setValue('Aa123456_') await wrapper.findAll('input').at(0).setValue('Aa123456_')
await wrapper.findAll('input').at(1).setValue('Aa123456_') await wrapper.findAll('input').at(1).setValue('Aa123456_')
await flushPromises() await flushPromises()
await wrapper.find('form').trigger('submit')
}) })
describe('server response with error', () => { describe('server response with error code > 10min', () => {
beforeEach(() => { beforeEach(async () => {
apolloMutationMock.mockRejectedValue({ message: 'error' }) jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({ message: '...Code is older than 10 minutes' })
await wrapper.find('form').trigger('submit')
await flushPromises()
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toasterMock).toHaveBeenCalledWith('error') expect(toasterMock).toHaveBeenCalledWith('...Code is older than 10 minutes')
})
it('router pushes to /password/reset', () => {
expect(routerPushMock).toHaveBeenCalledWith('/password/reset')
})
})
describe('server response with error code > 10min', () => {
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockRejectedValueOnce({ message: 'Error' })
await wrapper.find('form').trigger('submit')
await flushPromises()
})
it('toasts an error message', () => {
expect(toasterMock).toHaveBeenCalledWith('Error')
}) })
}) })
describe('server response with success', () => { describe('server response with success', () => {
beforeEach(() => { beforeEach(async () => {
apolloMutationMock.mockResolvedValue({ apolloMutationMock.mockResolvedValue({
data: { data: {
resetPassword: 'success', resetPassword: 'success',
}, },
}) })
wrapper = Wrapper(createMockObject('checkEmail'))
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('calls the API', () => { it('calls the API', () => {
expect(apolloMutationMock).toBeCalledWith( expect(apolloMutationMock).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
variables: { variables: {
sessionId: 1, code: '123',
email: 'user@example.org',
password: 'Aa123456_', password: 'Aa123456_',
}, },
}), }),

View File

@ -6,13 +6,10 @@
<b-row class="justify-content-center"> <b-row class="justify-content-center">
<b-col xl="5" lg="6" md="8" class="px-2"> <b-col xl="5" lg="6" md="8" class="px-2">
<h1>{{ $t('settings.password.reset') }}</h1> <h1>{{ $t('settings.password.reset') }}</h1>
<div class="pb-4" v-if="!pending"> <div class="pb-4">
<span v-if="authenticated"> <span>
{{ $t('settings.password.reset-password.text') }} {{ $t('settings.password.reset-password.text') }}
</span> </span>
<span v-else>
{{ $t('settings.password.reset-password.not-authenticated') }}
</span>
</div> </div>
</b-col> </b-col>
</b-row> </b-row>
@ -20,16 +17,16 @@
</div> </div>
</b-container> </b-container>
<b-container class="mt--8 p-1"> <b-container class="mt--8 p-1">
<b-row class="justify-content-center" v-if="authenticated"> <b-row class="justify-content-center">
<b-col lg="6" md="8"> <b-col lg="6" md="8">
<b-card no-body class="border-0" style="background-color: #ebebeba3 !important"> <b-card no-body class="border-0" style="background-color: #ebebeba3 !important">
<b-card-body class="p-4"> <b-card-body class="p-4">
<validation-observer ref="observer" v-slot="{ handleSubmit }"> <validation-observer ref="observer" v-slot="{ handleSubmit }">
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)"> <b-form role="form" @submit.prevent="handleSubmit(onSubmit)">
<input-password-confirmation v-model="form" :register="register" /> <input-password-confirmation v-model="form" />
<div class="text-center"> <div class="text-center">
<b-button type="submit" variant="primary" class="mt-4"> <b-button type="submit" variant="primary" class="mt-4">
{{ $t('settings.password.reset') }} {{ $t(displaySetup.button) }}
</b-button> </b-button>
</div> </div>
</b-form> </b-form>
@ -38,9 +35,9 @@
</b-card> </b-card>
</b-col> </b-col>
</b-row> </b-row>
<b-row> <b-row v-if="displaySetup.linkTo">
<b-col class="text-center py-lg-4"> <b-col class="text-center py-lg-4">
<router-link to="/Login" class="mt-3">{{ $t('back') }}</router-link> <router-link :to="displaySetup.linkTo" class="mt-3">{{ $t('back') }}</router-link>
</b-col> </b-col>
</b-row> </b-row>
</b-container> </b-container>
@ -48,8 +45,26 @@
</template> </template>
<script> <script>
import InputPasswordConfirmation from '../../components/Inputs/InputPasswordConfirmation' import InputPasswordConfirmation from '../../components/Inputs/InputPasswordConfirmation'
import { loginViaEmailVerificationCode } from '../../graphql/queries' import { setPassword } from '../../graphql/mutations'
import { resetPassword } from '../../graphql/mutations'
const textFields = {
reset: {
authenticated: 'settings.password.reset-password.text',
notAuthenticated: 'settings.password.not-authenticated',
button: 'settings.password.reset',
linkTo: '/login',
},
checkEmail: {
authenticated: 'settings.password.set-password.text',
notAuthenticated: 'settings.password.not-authenticated',
button: 'settings.password.set',
linkTo: '/login',
},
login: {
headline: 'site.thx.errorTitle',
subtitle: 'site.thx.activateEmail',
},
}
export default { export default {
name: 'ResetPassword', name: 'ResetPassword',
@ -62,21 +77,16 @@ export default {
password: '', password: '',
passwordRepeat: '', passwordRepeat: '',
}, },
authenticated: false, displaySetup: {},
sessionId: null,
email: null,
pending: true,
register: false,
} }
}, },
methods: { methods: {
async onSubmit() { async onSubmit() {
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: resetPassword, mutation: setPassword,
variables: { variables: {
sessionId: this.sessionId, code: this.$route.params.optin,
email: this.email,
password: this.form.password, password: this.form.password,
}, },
}) })
@ -85,35 +95,24 @@ export default {
this.$router.push('/thx/reset') this.$router.push('/thx/reset')
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) if (error.message.includes('Code is older than 10 minutes')) {
this.$toasted.error(error.message)
this.$router.push('/password/reset')
} else {
this.$toasted.error(error.message)
}
}) })
}, },
async authenticate() { setDisplaySetup() {
const loader = this.$loading.show({ if (!this.$route.params.comingFrom) {
container: this.$refs.header, this.displaySetup = textFields.reset
}) } else {
const optin = this.$route.params.optin this.displaySetup = textFields[this.$route.params.comingFrom]
this.$apollo }
.query({
query: loginViaEmailVerificationCode,
variables: {
optin: optin,
},
})
.then((result) => {
this.authenticated = true
this.sessionId = result.data.loginViaEmailVerificationCode.sessionId
this.email = result.data.loginViaEmailVerificationCode.email
})
.catch((error) => {
this.$toasted.error(error.message)
})
loader.hide()
this.pending = false
}, },
}, },
mounted() { created() {
this.authenticate() this.setDisplaySetup()
}, },
} }
</script> </script>