Merge branch 'master' into mass_creation_do_not_show_unactivated

This commit is contained in:
Ulf Gebhardt 2022-03-29 20:31:05 +02:00 committed by GitHub
commit 10ae00dba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 374 additions and 222 deletions

View File

@ -528,7 +528,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: 54 min_coverage: 55
token: ${{ github.token }} token: ${{ github.token }}
########################################################################## ##########################################################################

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v1.2022-03-18 CONFIG_VERSION=v3.2022-03-29
# Server # Server
PORT=4000 PORT=4000
@ -41,8 +41,10 @@ EMAIL_PASSWORD=xxx
EMAIL_SMTP_URL=gmail.com EMAIL_SMTP_URL=gmail.com
EMAIL_SMTP_PORT=587 EMAIL_SMTP_PORT=587
EMAIL_LINK_VERIFICATION=http://localhost/checkEmail/{optin}{code} EMAIL_LINK_VERIFICATION=http://localhost/checkEmail/{optin}{code}
EMAIL_LINK_SETPASSWORD=http://localhost/reset/{optin} EMAIL_LINK_SETPASSWORD=http://localhost/reset/{code}
EMAIL_CODE_VALID_TIME=10 EMAIL_LINK_FORGOTPASSWORD=http://localhost/forgot-password
EMAIL_CODE_VALID_TIME=1440
EMAIL_CODE_REQUEST_TIME=10
# Webhook # Webhook
WEBHOOK_ELOPAGE_SECRET=secret WEBHOOK_ELOPAGE_SECRET=secret

View File

@ -24,6 +24,7 @@ export enum RIGHTS {
QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK', QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK',
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE',
// Admin // Admin
SEARCH_USERS = 'SEARCH_USERS', SEARCH_USERS = 'SEARCH_USERS',
CREATE_PENDING_CREATION = 'CREATE_PENDING_CREATION', CREATE_PENDING_CREATION = 'CREATE_PENDING_CREATION',

View File

@ -22,6 +22,7 @@ export const ROLE_USER = new Role('user', [
RIGHTS.DELETE_TRANSACTION_LINK, RIGHTS.DELETE_TRANSACTION_LINK,
RIGHTS.REDEEM_TRANSACTION_LINK, RIGHTS.REDEEM_TRANSACTION_LINK,
RIGHTS.LIST_TRANSACTION_LINKS, RIGHTS.LIST_TRANSACTION_LINKS,
RIGHTS.GDT_BALANCE,
]) ])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights

View File

@ -14,7 +14,7 @@ const constants = {
DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0 DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0
CONFIG_VERSION: { CONFIG_VERSION: {
DEFAULT: 'DEFAULT', DEFAULT: 'DEFAULT',
EXPECTED: 'v1.2022-03-18', EXPECTED: 'v3.2022-03-29',
CURRENT: '', CURRENT: '',
}, },
} }
@ -70,8 +70,15 @@ const email = {
process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{optin}{code}', process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{optin}{code}',
EMAIL_LINK_SETPASSWORD: EMAIL_LINK_SETPASSWORD:
process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{optin}', process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{optin}',
EMAIL_LINK_FORGOTPASSWORD:
process.env.EMAIL_LINK_FORGOTPASSWORD || 'http://localhost/forgot-password',
// time in minutes a optin code is valid
EMAIL_CODE_VALID_TIME: process.env.EMAIL_CODE_VALID_TIME EMAIL_CODE_VALID_TIME: process.env.EMAIL_CODE_VALID_TIME
? parseInt(process.env.EMAIL_CODE_VALID_TIME) || 10 ? parseInt(process.env.EMAIL_CODE_VALID_TIME) || 1440
: 1440,
// time in minutes that must pass to request a new optin code
EMAIL_CODE_REQUEST_TIME: process.env.EMAIL_CODE_REQUEST_TIME
? parseInt(process.env.EMAIL_CODE_REQUEST_TIME) || 10
: 10, : 10,
} }

View File

@ -0,0 +1,11 @@
import { registerEnumType } from 'type-graphql'
export enum OptInType {
EMAIL_OPT_IN_REGISTER = 1,
EMAIL_OPT_IN_RESET_PASSWORD = 2,
}
registerEnumType(OptInType, {
name: 'OptInType', // this one is mandatory
description: 'Type of the email optin', // this one is optional
})

View File

@ -1,22 +1,55 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field } from 'type-graphql' import { ObjectType, Field } from 'type-graphql'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import CONFIG from '@/config'
@ObjectType() @ObjectType()
export class Balance { export class Balance {
constructor(json: any) { constructor(data: {
this.balance = json.balance balance: Decimal
this.decay = json.decay decay: Decimal
this.decayDate = json.decay_date lastBookedBalance: Decimal
balanceGDT: number | null
count: number
linkCount: number
decayStartBlock?: Date
lastBookedDate?: Date | null
}) {
this.balance = data.balance
this.decay = data.decay
this.lastBookedBalance = data.lastBookedBalance
this.balanceGDT = data.balanceGDT || null
this.count = data.count
this.linkCount = data.linkCount
this.decayStartBlock = data.decayStartBlock || CONFIG.DECAY_START_TIME
this.lastBookedDate = data.lastBookedDate || null
} }
// the actual balance, decay included
@Field(() => Decimal) @Field(() => Decimal)
balance: Decimal balance: Decimal
// the decay since the last booked balance
@Field(() => Decimal) @Field(() => Decimal)
decay: Decimal decay: Decimal
@Field(() => Decimal)
lastBookedBalance: Decimal
@Field(() => Number, { nullable: true })
balanceGDT: number | null
// the count of all transactions
@Field(() => Number)
count: number
// the count of transaction links
@Field(() => Number)
linkCount: number
@Field(() => Date) @Field(() => Date)
decayDate: Date decayStartBlock: Date
// may be null as there may be no transaction
@Field(() => Date, { nullable: true })
lastBookedDate: Date | null
} }

View File

@ -1,40 +1,16 @@
import { ObjectType, Field } from 'type-graphql' import { ObjectType, Field } from 'type-graphql'
import CONFIG from '@/config'
import Decimal from 'decimal.js-light'
import { Transaction } from './Transaction' import { Transaction } from './Transaction'
import { Balance } from './Balance'
@ObjectType() @ObjectType()
export class TransactionList { export class TransactionList {
constructor( constructor(balance: Balance, transactions: Transaction[]) {
balance: Decimal,
transactions: Transaction[],
count: number,
linkCount: number,
balanceGDT?: number | null,
decayStartBlock: Date = CONFIG.DECAY_START_TIME,
) {
this.balance = balance this.balance = balance
this.transactions = transactions this.transactions = transactions
this.count = count
this.linkCount = linkCount
this.balanceGDT = balanceGDT || null
this.decayStartBlock = decayStartBlock
} }
@Field(() => Number, { nullable: true }) @Field(() => Balance)
balanceGDT: number | null balance: Balance
@Field(() => Number)
count: number
@Field(() => Number)
linkCount: number
@Field(() => Decimal)
balance: Decimal
@Field(() => Date)
decayStartBlock: Date
@Field(() => [Transaction]) @Field(() => [Transaction])
transactions: Transaction[] transactions: Transaction[]

View File

@ -39,6 +39,9 @@ import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters' import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { communityUser } from '@/util/communityUser' import { communityUser } from '@/util/communityUser'
import { checkOptInCode, activationLink, printTimeDuration } from './UserResolver'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import CONFIG from '@/config'
// const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage? // const EMAIL_OPT_UNKNOWN = 3 // elopage?
@ -375,6 +378,40 @@ export class AdminResolver {
return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
} }
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
const user = await dbUser.findOneOrFail({ email: email })
// can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({
where: { userId: user.id },
order: { updatedAt: 'DESC' },
})
optInCode = await checkOptInCode(optInCode, user.id)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
link: activationLink(optInCode),
firstName: user.firstName,
lastName: user.lastName,
email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
})
/* uncomment this, when you need the activation link on the console
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
// eslint-disable-next-line no-console
console.log(`Account confirmation link: ${activationLink}`)
}
*/
return true
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN]) @Authorized([RIGHTS.LIST_TRANSACTION_LINKS_ADMIN])
@Query(() => TransactionLinkResult) @Query(() => TransactionLinkResult)
async listTransactionLinksAdmin( async listTransactionLinksAdmin(

View File

@ -5,36 +5,74 @@ import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
import { Balance } from '@model/Balance' import { Balance } from '@model/Balance'
import { calculateDecay } from '@/util/decay' import { calculateDecay } from '@/util/decay'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { Transaction } from '@entity/Transaction' import { Transaction as dbTransaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { GdtResolver } from './GdtResolver'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { MoreThan, getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
@Resolver() @Resolver()
export class BalanceResolver { export class BalanceResolver {
@Authorized([RIGHTS.BALANCE]) @Authorized([RIGHTS.BALANCE])
@Query(() => Balance) @Query(() => Balance)
async balance(@Ctx() context: any): Promise<Balance> { async balance(@Ctx() context: any): Promise<Balance> {
// load user and balance
const { user } = context const { user } = context
const now = new Date() const now = new Date()
const lastTransaction = await Transaction.findOne( const gdtResolver = new GdtResolver()
{ userId: user.id }, const balanceGDT = await gdtResolver.gdtBalance(context)
{ order: { balanceDate: 'DESC' } },
) const lastTransaction = context.lastTransaction
? context.lastTransaction
: await dbTransaction.findOne({ userId: user.id }, { order: { balanceDate: 'DESC' } })
// No balance found // No balance found
if (!lastTransaction) { if (!lastTransaction) {
return new Balance({ return new Balance({
balance: new Decimal(0), balance: new Decimal(0),
decay: new Decimal(0), decay: new Decimal(0),
decay_date: now.toString(), lastBookedBalance: new Decimal(0),
balanceGDT,
count: 0,
linkCount: 0,
}) })
} }
const count =
context.transactionCount || context.transactionCount === 0
? context.transactionCount
: await dbTransaction.count({ where: { userId: user.id } })
const linkCount =
context.linkCount || context.linkCount === 0
? context.linkCount
: await dbTransactionLink.count({
where: {
userId: user.id,
redeemedAt: null,
validUntil: MoreThan(new Date()),
},
})
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount } = context.sumHoldAvailableAmount
? { sumHoldAvailableAmount: context.sumHoldAvailableAmount }
: await transactionLinkRepository.summary(user.id, now)
const calculatedDecay = calculateDecay(
lastTransaction.balance.minus(sumHoldAvailableAmount.toString()),
lastTransaction.balanceDate,
now,
)
return new Balance({ return new Balance({
balance: lastTransaction.balance, balance: calculatedDecay.balance.toDecimalPlaces(2, Decimal.ROUND_DOWN), // round towards zero
decay: calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance, decay: calculatedDecay.decay.toDecimalPlaces(2, Decimal.ROUND_FLOOR), // round towards - infinity
decay_date: now.toString(), lastBookedBalance: lastTransaction.balance.toDecimalPlaces(2, Decimal.ROUND_DOWN),
balanceGDT,
count,
linkCount,
lastBookedDate: lastTransaction.balanceDate,
}) })
} }
} }

View File

@ -5,7 +5,7 @@ import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql'
import CONFIG from '@/config' import CONFIG from '@/config'
import { GdtEntryList } from '@model/GdtEntryList' import { GdtEntryList } from '@model/GdtEntryList'
import Paginated from '@arg/Paginated' import Paginated from '@arg/Paginated'
import { apiGet } from '@/apis/HttpRequest' import { apiGet, apiPost } from '@/apis/HttpRequest'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
@ -13,13 +13,11 @@ import { RIGHTS } from '@/auth/RIGHTS'
export class GdtResolver { export class GdtResolver {
@Authorized([RIGHTS.LIST_GDT_ENTRIES]) @Authorized([RIGHTS.LIST_GDT_ENTRIES])
@Query(() => GdtEntryList) @Query(() => GdtEntryList)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async listGDTEntries( async listGDTEntries(
@Args() @Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Ctx() context: any, @Ctx() context: any,
): Promise<GdtEntryList> { ): Promise<GdtEntryList> {
// load user
const userEntity = context.user const userEntity = context.user
try { try {
@ -35,6 +33,25 @@ export class GdtResolver {
} }
} }
@Authorized([RIGHTS.GDT_BALANCE])
@Query(() => Number)
async gdtBalance(@Ctx() context: any): Promise<number | null> {
const { user } = context
try {
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: user.email,
})
if (!resultGDTSum.success) {
throw new Error('Call not successful')
}
return Number(resultGDTSum.data.sum) || 0
} catch (err: any) {
// eslint-disable-next-line no-console
console.log('Could not query GDT Server', err)
return null
}
}
@Authorized([RIGHTS.EXIST_PID]) @Authorized([RIGHTS.EXIST_PID])
@Query(() => Number) @Query(() => Number)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -6,7 +6,6 @@
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection } from '@dbTools/typeorm' import { getCustomRepository, getConnection } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail' import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
import { Transaction } from '@model/Transaction' import { Transaction } from '@model/Transaction'
@ -24,7 +23,6 @@ import { User as dbUser } from '@entity/User'
import { Transaction as dbTransaction } from '@entity/Transaction' import { Transaction as dbTransaction } from '@entity/Transaction'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { apiPost } from '@/apis/HttpRequest'
import { TransactionTypeId } from '@enum/TransactionTypeId' import { TransactionTypeId } from '@enum/TransactionTypeId'
import { calculateBalance, isHexPublicKey } from '@/util/validate' import { calculateBalance, isHexPublicKey } from '@/util/validate'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
@ -32,7 +30,8 @@ import { User } from '@model/User'
import { communityUser } from '@/util/communityUser' import { communityUser } from '@/util/communityUser'
import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions' import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { calculateDecay } from '@/util/decay'
import { BalanceResolver } from './BalanceResolver'
const MEMO_MAX_CHARS = 255 const MEMO_MAX_CHARS = 255
const MEMO_MIN_CHARS = 5 const MEMO_MIN_CHARS = 5
@ -154,23 +153,11 @@ export class TransactionResolver {
{ order: { balanceDate: 'DESC' } }, { order: { balanceDate: 'DESC' } },
) )
// get GDT const balanceResolver = new BalanceResolver()
let balanceGDT = null context.lastTransaction = lastTransaction
try {
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: user.email,
})
if (!resultGDTSum.success) {
throw new Error('Call not successful')
}
balanceGDT = Number(resultGDTSum.data.sum) || 0
} catch (err: any) {
// eslint-disable-next-line no-console
console.log('Could not query GDT Server', err)
}
if (!lastTransaction) { if (!lastTransaction) {
return new TransactionList(new Decimal(0), [], 0, 0, balanceGDT) return new TransactionList(await balanceResolver.balance(context), [])
} }
// find transactions // find transactions
@ -183,6 +170,7 @@ export class TransactionResolver {
offset, offset,
order, order,
) )
context.transactionCount = userTransactionsCount
// find involved users; I am involved // find involved users; I am involved
const involvedUserIds: number[] = [user.id] const involvedUserIds: number[] = [user.id]
@ -205,6 +193,8 @@ export class TransactionResolver {
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, transactionLinkcount } = const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, transactionLinkcount } =
await transactionLinkRepository.summary(user.id, now) await transactionLinkRepository.summary(user.id, now)
context.linkCount = transactionLinkcount
context.sumHoldAvailableAmount = sumHoldAvailableAmount
// decay & link transactions // decay & link transactions
if (currentPage === 1 && order === Order.DESC) { if (currentPage === 1 && order === Order.DESC) {
@ -237,15 +227,7 @@ export class TransactionResolver {
}) })
// Construct Result // Construct Result
return new TransactionList( return new TransactionList(await balanceResolver.balance(context), transactions)
calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance.minus(
sumHoldAvailableAmount.toString(),
),
transactions,
userTransactionsCount,
transactionLinkcount,
balanceGDT,
)
} }
@Authorized([RIGHTS.SEND_COINS]) @Authorized([RIGHTS.SEND_COINS])

View File

@ -11,6 +11,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User' import { User } from '@entity/User'
import CONFIG from '@/config' import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { printTimeDuration } from './UserResolver'
// import { klicktippSignIn } from '@/apis/KlicktippController' // import { klicktippSignIn } from '@/apis/KlicktippController'
@ -133,6 +134,7 @@ describe('UserResolver', () => {
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
duration: expect.any(String),
}) })
}) })
}) })
@ -220,10 +222,6 @@ describe('UserResolver', () => {
expect(newUser[0].password).toEqual('3917921995996627700') expect(newUser[0].password).toEqual('3917921995996627700')
}) })
it('removes the optin', async () => {
await expect(LoginEmailOptIn.find()).resolves.toHaveLength(0)
})
/* /*
it('calls the klicktipp API', () => { it('calls the klicktipp API', () => {
expect(klicktippSignIn).toBeCalledWith( expect(klicktippSignIn).toBeCalledWith(
@ -415,3 +413,17 @@ describe('UserResolver', () => {
}) })
}) })
}) })
describe('printTimeDuration', () => {
it('works with 10 minutes', () => {
expect(printTimeDuration(10)).toBe('10 minutes')
})
it('works with 1440 minutes', () => {
expect(printTimeDuration(1440)).toBe('24 hours')
})
it('works with 1410 minutes', () => {
expect(printTimeDuration(1410)).toBe('23 hours and 30 minutes')
})
})

View File

@ -3,7 +3,7 @@
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, QueryRunner } from '@dbTools/typeorm' import { getConnection, getCustomRepository } from '@dbTools/typeorm'
import CONFIG from '@/config' import CONFIG from '@/config'
import { User } from '@model/User' import { User } from '@model/User'
import { User as DbUser } from '@entity/User' import { User as DbUser } from '@entity/User'
@ -15,8 +15,9 @@ import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware' import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { UserSettingRepository } from '@repository/UserSettingRepository' import { UserSettingRepository } from '@repository/UserSettingRepository'
import { Setting } from '@enum/Setting' import { Setting } from '@enum/Setting'
import { OptInType } from '@enum/OptInType'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { klicktippSignIn } from '@/apis/KlicktippController' import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
@ -24,9 +25,6 @@ import { ROLE_ADMIN } from '@/auth/ROLES'
import { hasElopageBuys } from '@/util/hasElopageBuys' import { hasElopageBuys } from '@/util/hasElopageBuys'
import { ServerUser } from '@entity/ServerUser' import { ServerUser } from '@entity/ServerUser'
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
@ -148,57 +146,47 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
return message return message
} }
const createEmailOptIn = async (
loginUserId: number, const newEmailOptIn = (userId: number): LoginEmailOptIn => {
queryRunner: QueryRunner, const emailOptIn = new LoginEmailOptIn()
): Promise<LoginEmailOptIn> => {
let emailOptIn = await LoginEmailOptIn.findOne({
userId: loginUserId,
emailOptInTypeId: EMAIL_OPT_IN_REGISTER,
})
if (emailOptIn) {
if (isOptInCodeValid(emailOptIn)) {
throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`)
}
emailOptIn.updatedAt = new Date()
emailOptIn.resendCount++
} else {
emailOptIn = new LoginEmailOptIn()
emailOptIn.verificationCode = random(64) emailOptIn.verificationCode = random(64)
emailOptIn.userId = loginUserId emailOptIn.userId = userId
emailOptIn.emailOptInTypeId = EMAIL_OPT_IN_REGISTER emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
}
await queryRunner.manager.save(emailOptIn).catch((error) => {
// eslint-disable-next-line no-console
console.log('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
return emailOptIn return emailOptIn
} }
const getOptInCode = async (loginUserId: number): Promise<LoginEmailOptIn> => { // needed by AdminResolver
let optInCode = await LoginEmailOptIn.findOne({ // checks if given code exists and can be resent
userId: loginUserId, // if optIn does not exits, it is created
emailOptInTypeId: EMAIL_OPT_IN_RESET_PASSWORD, export const checkOptInCode = async (
}) optInCode: LoginEmailOptIn | undefined,
userId: number,
// Check for `CONFIG.EMAIL_CODE_VALID_TIME` minute delay optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<LoginEmailOptIn> => {
if (optInCode) { if (optInCode) {
if (isOptInCodeValid(optInCode)) { if (!canResendOptIn(optInCode)) {
throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
} }
optInCode.updatedAt = new Date() optInCode.updatedAt = new Date()
optInCode.resendCount++ optInCode.resendCount++
} else { } else {
optInCode = new LoginEmailOptIn() optInCode = newEmailOptIn(userId)
optInCode.verificationCode = random(64)
optInCode.userId = loginUserId
optInCode.emailOptInTypeId = EMAIL_OPT_IN_RESET_PASSWORD
} }
await LoginEmailOptIn.save(optInCode) optInCode.emailOptInTypeId = optInType
await LoginEmailOptIn.save(optInCode).catch(() => {
throw new Error('Unable to save optin code.')
})
return optInCode return optInCode
} }
export const activationLink = (optInCode: LoginEmailOptIn): string => {
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString())
}
@Resolver() @Resolver()
export class UserResolver { export class UserResolver {
@Authorized([RIGHTS.VERIFY_LOGIN]) @Authorized([RIGHTS.VERIFY_LOGIN])
@ -363,9 +351,12 @@ export class UserResolver {
throw new Error('error saving user') throw new Error('error saving user')
}) })
// Store EmailOptIn in DB const emailOptIn = newEmailOptIn(dbUser.id)
// TODO: this has duplicate code with sendResetPasswordEmail await queryRunner.manager.save(emailOptIn).catch((error) => {
const emailOptIn = await createEmailOptIn(dbUser.id, queryRunner) // eslint-disable-next-line no-console
console.log('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g, /{optin}/g,
@ -378,6 +369,7 @@ export class UserResolver {
firstName, firstName,
lastName, lastName,
email, email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
}) })
/* uncomment this, when you need the activation link on the console /* uncomment this, when you need the activation link on the console
@ -398,70 +390,26 @@ export class UserResolver {
return new User(dbUser) return new User(dbUser)
} }
// THis is used by the admin only - should we move it to the admin resolver?
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
const user = await DbUser.findOneOrFail({ email: email })
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
try {
const emailOptIn = await createEmailOptIn(user.id, queryRunner)
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailOptIn.verificationCode.toString(),
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
link: activationLink,
firstName: user.firstName,
lastName: user.lastName,
email,
})
/* uncomment this, when you need the activation link on the console
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
// eslint-disable-next-line no-console
console.log(`Account confirmation link: ${activationLink}`)
}
*/
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
throw e
} finally {
await queryRunner.release()
}
return true
}
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL]) @Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
@Query(() => Boolean) @Query(() => Boolean)
async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> { async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> {
// TODO: this has duplicate code with createUser
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const user = await DbUser.findOneOrFail({ email }) const user = await DbUser.findOneOrFail({ email })
const optInCode = await getOptInCode(user.id) // can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({
userId: user.id,
})
const link = CONFIG.EMAIL_LINK_SETPASSWORD.replace( optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
/{optin}/g,
optInCode.verificationCode.toString(),
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmail({ const emailSent = await sendResetPasswordEmailMailer({
link, link: activationLink(optInCode),
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
email, email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
}) })
/* uncomment this, when you need the activation link on the console /* uncomment this, when you need the activation link on the console
@ -494,8 +442,10 @@ export class UserResolver {
}) })
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInCodeValid(optInCode)) { if (!isOptInValid(optInCode)) {
throw new Error(`email already more than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
} }
// load user // load user
@ -538,11 +488,6 @@ export class UserResolver {
throw new Error('error saving user: ' + 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() await queryRunner.commitTransaction()
} catch (e) { } catch (e) {
await queryRunner.rollbackTransaction() await queryRunner.rollbackTransaction()
@ -553,7 +498,7 @@ export class UserResolver {
// Sign into Klicktipp // Sign into Klicktipp
// TODO do we always signUp the user? How to handle things with old users? // TODO do we always signUp the user? How to handle things with old users?
if (optInCode.emailOptInTypeId === EMAIL_OPT_IN_REGISTER) { if (optInCode.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) {
try { try {
await klicktippSignIn(user.email, user.language, user.firstName, user.lastName) await klicktippSignIn(user.email, user.language, user.firstName, user.lastName)
} catch { } catch {
@ -573,8 +518,10 @@ export class UserResolver {
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> { async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn }) const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn })
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInCodeValid(optInCode)) { if (!isOptInValid(optInCode)) {
throw new Error(`email was sent more than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`) throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
} }
return true return true
} }
@ -680,7 +627,34 @@ export class UserResolver {
return hasElopageBuys(userEntity.email) return hasElopageBuys(userEntity.email)
} }
} }
function isOptInCodeValid(optInCode: LoginEmailOptIn) {
const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime() const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
return timeElapsed <= CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000 const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
}
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
}
const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
if (time > 60) {
return {
hours: Math.floor(time / 60),
minutes: time % 60,
}
}
return { minutes: time }
}
export const printTimeDuration = (duration: number): string => {
const time = getTimeDurationObject(duration)
const result = time.minutes > 0 ? `${time.minutes} minutes` : ''
if (time.hours) return `${time.hours} hours` + (result !== '' ? ` and ${result}` : '')
return result
} }

View File

@ -15,6 +15,7 @@ describe('sendAccountActivationEmail', () => {
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
duration: '23 hours and 30 minutes',
}) })
}) })
@ -23,7 +24,9 @@ describe('sendAccountActivationEmail', () => {
to: `Peter Lustig <peter@lustig.de>`, to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: E-Mail Überprüfung', subject: 'Gradido: E-Mail Überprüfung',
text: text:
expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('activationLink'), expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('activationLink') &&
expect.stringContaining('23 Stunden und 30 Minuten'),
}) })
}) })
}) })

View File

@ -1,15 +1,17 @@
import { sendEMail } from './sendEMail' import { sendEMail } from './sendEMail'
import { accountActivation } from './text/accountActivation' import { accountActivation } from './text/accountActivation'
import CONFIG from '@/config'
export const sendAccountActivationEmail = (data: { export const sendAccountActivationEmail = (data: {
link: string link: string
firstName: string firstName: string
lastName: string lastName: string
email: string email: string
duration: string
}): Promise<boolean> => { }): Promise<boolean> => {
return sendEMail({ return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`, to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: accountActivation.de.subject, subject: accountActivation.de.subject,
text: accountActivation.de.text(data), text: accountActivation.de.text({ ...data, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD }),
}) })
} }

View File

@ -15,6 +15,7 @@ describe('sendResetPasswordEmail', () => {
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
duration: '23 hours and 30 minutes',
}) })
}) })
@ -22,7 +23,10 @@ describe('sendResetPasswordEmail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`, to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: Passwort zurücksetzen', subject: 'Gradido: Passwort zurücksetzen',
text: expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('resetLink'), text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('resetLink') &&
expect.stringContaining('23 Stunden und 30 Minuten'),
}) })
}) })
}) })

View File

@ -1,15 +1,17 @@
import { sendEMail } from './sendEMail' import { sendEMail } from './sendEMail'
import { resetPassword } from './text/resetPassword' import { resetPassword } from './text/resetPassword'
import CONFIG from '@/config'
export const sendResetPasswordEmail = (data: { export const sendResetPasswordEmail = (data: {
link: string link: string
firstName: string firstName: string
lastName: string lastName: string
email: string email: string
duration: string
}): Promise<boolean> => { }): Promise<boolean> => {
return sendEMail({ return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`, to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: resetPassword.de.subject, subject: resetPassword.de.subject,
text: resetPassword.de.text(data), text: resetPassword.de.text({ ...data, resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD }),
}) })
} }

View File

@ -1,7 +1,14 @@
export const accountActivation = { export const accountActivation = {
de: { de: {
subject: 'Gradido: E-Mail Überprüfung', subject: 'Gradido: E-Mail Überprüfung',
text: (data: { link: string; firstName: string; lastName: string; email: string }): string => text: (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
resendLink: string
}): string =>
`Hallo ${data.firstName} ${data.lastName}, `Hallo ${data.firstName} ${data.lastName},
Deine E-Mail-Adresse wurde soeben bei Gradido registriert. Deine E-Mail-Adresse wurde soeben bei Gradido registriert.
@ -10,6 +17,15 @@ Klicke bitte auf diesen Link, um die Registrierung abzuschließen und dein Gradi
${data.link} ${data.link}
oder kopiere den obigen Link in dein Browserfenster. oder kopiere den obigen Link in dein Browserfenster.
Der Link hat eine Gültigkeit von ${data.duration
.replace('hours', 'Stunden')
.replace('minutes', 'Minuten')
.replace(
' and ',
' und ',
)}. Sollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen, in dem du deine E-Mail-Adresse eingibst:
${data.resendLink}
Mit freundlichen Grüßen, Mit freundlichen Grüßen,
dein Gradido-Team`, dein Gradido-Team`,
}, },

View File

@ -1,13 +1,29 @@
export const resetPassword = { export const resetPassword = {
de: { de: {
subject: 'Gradido: Passwort zurücksetzen', subject: 'Gradido: Passwort zurücksetzen',
text: (data: { link: string; firstName: string; lastName: string; email: string }): string => text: (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
resendLink: string
}): string =>
`Hallo ${data.firstName} ${data.lastName}, `Hallo ${data.firstName} ${data.lastName},
Du oder jemand anderes hat für dieses Konto ein Zurücksetzen des Passworts angefordert. Du oder jemand anderes hat für dieses Konto ein Zurücksetzen des Passworts angefordert.
Wenn du es warst, klicke bitte auf den Link: ${data.link} Wenn du es warst, klicke bitte auf den Link: ${data.link}
oder kopiere den obigen Link in Dein Browserfenster. oder kopiere den obigen Link in Dein Browserfenster.
Der Link hat eine Gültigkeit von ${data.duration
.replace('hours', 'Stunden')
.replace('minutes', 'Minuten')
.replace(
' and ',
' und ',
)}. Sollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen, in dem du deine E-Mail-Adresse eingibst:
${data.resendLink}
Mit freundlichen Grüßen, Mit freundlichen Grüßen,
dein Gradido-Team`, dein Gradido-Team`,
}, },

View File

@ -18,7 +18,7 @@ WEBHOOK_GITHUB_SECRET=secret
WEBHOOK_GITHUB_BRANCH=master WEBHOOK_GITHUB_BRANCH=master
# backend # backend
BACKEND_CONFIG_VERSION=v1.2022-03-18 BACKEND_CONFIG_VERSION=v3.2022-03-29
EMAIL=true EMAIL=true
EMAIL_USERNAME=peter@lustig.de EMAIL_USERNAME=peter@lustig.de

View File

@ -45,11 +45,16 @@ export const logout = gql`
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) {
balance {
balance
decay
lastBookedBalance
balanceGDT balanceGDT
count count
linkCount linkCount
balance
decayStartBlock decayStartBlock
lastBookedDate
}
transactions { transactions {
id id
typeId typeId

View File

@ -145,11 +145,13 @@ describe('DashboardLayoutGdd', () => {
apolloMock.mockResolvedValue({ apolloMock.mockResolvedValue({
data: { data: {
transactionList: { transactionList: {
balance: {
balanceGDT: 100, balanceGDT: 100,
count: 4, count: 4,
linkCount: 8, linkCount: 8,
balance: 1450, balance: 1450,
decay: 1250, decay: 1250,
},
transactions: ['transaction', 'transaction', 'transaction', 'transaction'], transactions: ['transaction', 'transaction', 'transaction', 'transaction'],
}, },
}, },

View File

@ -103,12 +103,14 @@ export default {
data: { transactionList }, data: { transactionList },
} = result } = result
this.GdtBalance = this.GdtBalance =
transactionList.balanceGDT === null ? null : Number(transactionList.balanceGDT) transactionList.balance.balanceGDT === null
? null
: Number(transactionList.balance.balanceGDT)
this.transactions = transactionList.transactions this.transactions = transactionList.transactions
this.balance = Number(transactionList.balance) this.balance = Number(transactionList.balance.balance)
this.transactionCount = transactionList.count this.transactionCount = transactionList.balance.count
this.transactionLinkCount = transactionList.linkCount this.transactionLinkCount = transactionList.balance.linkCount
this.decayStartBlock = new Date(transactionList.decayStartBlock) this.decayStartBlock = new Date(transactionList.balance.decayStartBlock)
this.pending = false this.pending = false
}) })
.catch((error) => { .catch((error) => {

View File

@ -150,13 +150,18 @@ describe('ResetPassword', () => {
describe('server response with error code > 10min', () => { describe('server response with error code > 10min', () => {
beforeEach(async () => { beforeEach(async () => {
apolloMutationMock.mockRejectedValue({ message: '...Code is older than 10 minutes' }) jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: '...email was sent more than 23 hours and 10 minutes ago',
})
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastErrorSpy).toHaveBeenCalledWith('...Code is older than 10 minutes') expect(toastErrorSpy).toHaveBeenCalledWith(
'...email was sent more than 23 hours and 10 minutes ago',
)
}) })
it('router pushes to /forgot-password/resetPassword', () => { it('router pushes to /forgot-password/resetPassword', () => {

View File

@ -108,7 +108,11 @@ export default {
}) })
.catch((error) => { .catch((error) => {
this.toastError(error.message) this.toastError(error.message)
if (error.message.includes('Code is older than 10 minutes')) if (
error.message.match(
/email was sent more than ([0-9]+ hours)?( and )?([0-9]+ minutes)? ago/,
)
)
this.$router.push('/forgot-password/resetPassword') this.$router.push('/forgot-password/resetPassword')
}) })
}, },