Merge branch 'master' into archive_transform_valid_transactions

This commit is contained in:
Einhornimmond 2022-07-10 08:08:38 +02:00
commit 6423c66b0b
36 changed files with 738 additions and 169 deletions

View File

@ -4,8 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.10.1](https://github.com/gradido/gradido/compare/1.10.0...1.10.1)
- automatic session logout with info modal [`#2001`](https://github.com/gradido/gradido/pull/2001)
- 1910 separate text for the slideshow images. [`#1998`](https://github.com/gradido/gradido/pull/1998)
- Origin/1921 additional parameter checks for createContributionLinks [`#1996`](https://github.com/gradido/gradido/pull/1996)
- add missing locales [`#1999`](https://github.com/gradido/gradido/pull/1999)
- 1906 feature concept for gdd creation per linkqr code [`#1907`](https://github.com/gradido/gradido/pull/1907)
- refactor: 🍰 Not Throwing An Error When Register With Existing Email [`#1962`](https://github.com/gradido/gradido/pull/1962)
- feat: 🍰 Set Role In Admin Interface [`#1974`](https://github.com/gradido/gradido/pull/1974)
- refactor mobile style step 1 [`#1977`](https://github.com/gradido/gradido/pull/1977)
- changed mobil stage picture [`#1995`](https://github.com/gradido/gradido/pull/1995)
#### [1.10.0](https://github.com/gradido/gradido/compare/1.9.0...1.10.0)
> 17 June 2022
- release: v1.10.0 [`#1993`](https://github.com/gradido/gradido/pull/1993)
- frontend redeem contribution link [`#1988`](https://github.com/gradido/gradido/pull/1988)
- change new start picture [`#1990`](https://github.com/gradido/gradido/pull/1990)
- feat: Redeem Contribution Link [`#1987`](https://github.com/gradido/gradido/pull/1987)

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.10.0",
"version": "1.10.1",
"license": "Apache-2.0",
"private": false,
"scripts": {

View File

@ -3,7 +3,7 @@ CONFIG_VERSION=v8.2022-06-20
# Server
PORT=4000
JWT_SECRET=secret123
JWT_EXPIRES_IN=30m
JWT_EXPIRES_IN=10m
GRAPHIQL=false
GDT_API_URL=https://gdt.gradido.net

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.10.0",
"version": "1.10.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",

View File

@ -25,6 +25,7 @@ export enum RIGHTS {
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE',
CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
SET_USER_ROLE = 'SET_USER_ROLE',

View File

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

View File

@ -25,7 +25,7 @@ const constants = {
const server = {
PORT: process.env.PORT || 4000,
JWT_SECRET: process.env.JWT_SECRET || 'secret123',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '30m',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '10m',
GRAPHIQL: process.env.GRAPHIQL === 'true' || false,
GDT_API_URL: process.env.GDT_API_URL || 'https://gdt.gradido.net',
PRODUCTION: process.env.NODE_ENV === 'production' || false,

View File

@ -0,0 +1,15 @@
import { ArgsType, Field, InputType } from 'type-graphql'
import Decimal from 'decimal.js-light'
@InputType()
@ArgsType()
export default class ContributionArgs {
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => String)
creationDate: string
}

View File

@ -1,8 +1,22 @@
import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
@ObjectType()
export class UnconfirmedContribution {
constructor(contribution: Contribution, user: User, creations: Decimal[]) {
this.id = contribution.id
this.userId = contribution.userId
this.amount = contribution.amount
this.memo = contribution.memo
this.date = contribution.contributionDate
this.firstName = user ? user.firstName : ''
this.lastName = user ? user.lastName : ''
this.email = user ? user.email : ''
this.creation = creations
}
@Field(() => String)
firstName: string
@ -27,8 +41,8 @@ export class UnconfirmedContribution {
@Field(() => Decimal)
amount: Decimal
@Field(() => Number)
moderator: number
@Field(() => Number, { nullable: true })
moderator: number | null
@Field(() => [Decimal])
creation: Decimal[]

View File

@ -46,15 +46,23 @@ import { checkOptInCode, activationLink, printTimeDuration } from './UserResolve
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config'
import {
getCreationIndex,
getUserCreation,
getUserCreations,
validateContribution,
isStartEndDateValid,
} from './util/creations'
import {
CONTRIBUTIONLINK_MEMO_MAX_CHARS,
CONTRIBUTIONLINK_MEMO_MIN_CHARS,
CONTRIBUTIONLINK_NAME_MAX_CHARS,
CONTRIBUTIONLINK_NAME_MIN_CHARS,
FULL_CREATION_AVAILABLE,
} from './const/const'
// const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage?
const MAX_CREATION_AMOUNT = new Decimal(1000)
const FULL_CREATION_AVAILABLE = [MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT]
const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255
const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5
@Resolver()
export class AdminResolver {
@ -244,18 +252,17 @@ export class AdminResolver {
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
const creationDateObj = new Date(creationDate)
if (isContributionValid(creations, amount, creationDateObj)) {
const contribution = Contribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.moderatorId = moderator.id
validateContribution(creations, amount, creationDateObj)
const contribution = Contribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.moderatorId = moderator.id
logger.trace('contribution to save', contribution)
await Contribution.save(contribution)
}
logger.trace('contribution to save', contribution)
await Contribution.save(contribution)
return getUserCreation(user.id)
}
@ -321,7 +328,7 @@ export class AdminResolver {
}
// all possible cases not to be true are thrown in this function
isContributionValid(creations, amount, creationDateObj)
validateContribution(creations, amount, creationDateObj)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
@ -398,9 +405,7 @@ export class AdminResolver {
if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.')
const creations = await getUserCreation(contribution.userId, false)
if (!isContributionValid(creations, contribution.amount, contribution.contributionDate)) {
throw new Error('Creation is not valid!!')
}
validateContribution(creations, contribution.amount, contribution.contributionDate)
const receivedCallDate = new Date()
@ -684,64 +689,6 @@ export class AdminResolver {
}
}
interface CreationMap {
id: number
creations: Decimal[]
}
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending)
const creations = await getUserCreations([id], includePending)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
}
async function getUserCreations(ids: number[], includePending = true): Promise<CreationMap[]> {
logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths()
logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter', dateFilter)
const unionString = includePending
? `
UNION
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
WHERE user_id IN (${ids.toString()})
AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL`
: ''
const unionQuery = await queryRunner.manager.query(`
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
WHERE user_id IN (${ids.toString()})
AND type_id = ${TransactionTypeId.CREATION}
AND creation_date >= ${dateFilter}
${unionString}) AS result
GROUP BY month, userId
ORDER BY date DESC
`)
await queryRunner.release()
return ids.map((id) => {
return {
id,
creations: months.map((month) => {
const creation = unionQuery.find(
(raw: { month: string; id: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id,
)
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}),
}
})
}
function updateCreations(creations: Decimal[], contribution: Contribution): Decimal[] {
const index = getCreationIndex(contribution.contributionDate.getMonth())
@ -751,58 +698,3 @@ function updateCreations(creations: Decimal[], contribution: Contribution): Deci
creations[index] = creations[index].plus(contribution.amount.toString())
return creations
}
export const isContributionValid = (
creations: Decimal[],
amount: Decimal,
creationDate: Date,
): boolean => {
logger.trace('isContributionValid', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth())
if (index < 0) {
throw new Error('No information for available creations for the given date')
}
if (amount.greaterThan(creations[index].toString())) {
throw new Error(
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
)
}
return true
}
const isStartEndDateValid = (
startDate: string | null | undefined,
endDate: string | null | undefined,
): void => {
if (!startDate) {
logger.error('Start-Date is not initialized. A Start-Date must be set!')
throw new Error('Start-Date is not initialized. A Start-Date must be set!')
}
if (!endDate) {
logger.error('End-Date is not initialized. An End-Date must be set!')
throw new Error('End-Date is not initialized. An End-Date must be set!')
}
// check if endDate is before startDate
if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) {
logger.error(`The value of validFrom must before or equals the validTo!`)
throw new Error(`The value of validFrom must before or equals the validTo!`)
}
}
const getCreationMonths = (): number[] => {
const now = new Date(Date.now())
return [
now.getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1,
].reverse()
}
const getCreationIndex = (month: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1)
}

View File

@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { createContribution } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { GraphQLError } from 'graphql'
import { userFactory } from '@/seeds/factory/user'
let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('ContributionResolver', () => {
describe('createContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: createContribution,
variables: { amount: 100.0, memo: 'Test Contribution', creationDate: 'not-valid' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated with valid user', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('input not valid', () => {
it('throws error when creationDate not-valid', async () => {
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: 'not-valid',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
}),
)
})
it('throws error when creationDate 3 month behind', async () => {
const date = new Date()
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: date.setMonth(date.getMonth() - 3).toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
}),
)
})
})
describe('valid input', () => {
it('creates contribution', async () => {
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
createContribution: {
amount: '100',
memo: 'Test env contribution',
},
},
}),
)
})
})
})
})
})

View File

@ -0,0 +1,35 @@
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Contribution } from '@entity/Contribution'
import { Args, Authorized, Ctx, Mutation, Resolver } from 'type-graphql'
import ContributionArgs from '../arg/ContributionArgs'
import { UnconfirmedContribution } from '../model/UnconfirmedContribution'
import { validateContribution, getUserCreation } from './util/creations'
@Resolver()
export class ContributionResolver {
@Authorized([RIGHTS.CREATE_CONTRIBUTION])
@Mutation(() => UnconfirmedContribution)
async createContribution(
@Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
const user = getUser(context)
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj)
const contribution = Contribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
logger.trace('contribution to save', contribution)
await Contribution.save(contribution)
return new UnconfirmedContribution(contribution, user, creations)
}
}

View File

@ -28,7 +28,7 @@ import { executeTransaction } from './TransactionResolver'
import { Order } from '@enum/Order'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { getUserCreation, isContributionValid } from './AdminResolver'
import { getUserCreation, validateContribution } from './util/creations'
import { Decay } from '@model/Decay'
import Decimal from 'decimal.js-light'
import { TransactionTypeId } from '@enum/TransactionTypeId'
@ -223,13 +223,7 @@ export class TransactionLinkResolver {
const creations = await getUserCreation(user.id, false)
logger.info('open creations', creations)
if (!isContributionValid(creations, contributionLink.amount, now)) {
logger.error(
'Amount of Contribution link exceeds available amount for this month',
contributionLink.amount,
)
throw new Error('Amount of Contribution link exceeds available amount')
}
validateContribution(creations, contributionLink.amount, now)
const contribution = new DbContribution()
contribution.userId = user.id
contribution.createdAt = now

View File

@ -0,0 +1,12 @@
import Decimal from 'decimal.js-light'
export const MAX_CREATION_AMOUNT = new Decimal(1000)
export const FULL_CREATION_AVAILABLE = [
MAX_CREATION_AMOUNT,
MAX_CREATION_AMOUNT,
MAX_CREATION_AMOUNT,
]
export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
export const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255
export const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5

View File

@ -0,0 +1,119 @@
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { backendLogger as logger } from '@/server/logger'
import { getConnection } from '@dbTools/typeorm'
import Decimal from 'decimal.js-light'
import { FULL_CREATION_AVAILABLE, MAX_CREATION_AMOUNT } from '../const/const'
interface CreationMap {
id: number
creations: Decimal[]
}
export const validateContribution = (
creations: Decimal[],
amount: Decimal,
creationDate: Date,
): void => {
logger.trace('isContributionValid', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth())
if (index < 0) {
throw new Error('No information for available creations for the given date')
}
if (amount.greaterThan(creations[index].toString())) {
throw new Error(
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
)
}
}
export const getUserCreations = async (
ids: number[],
includePending = true,
): Promise<CreationMap[]> => {
logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths()
logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter', dateFilter)
const unionString = includePending
? `
UNION
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
WHERE user_id IN (${ids.toString()})
AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL`
: ''
const unionQuery = await queryRunner.manager.query(`
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
WHERE user_id IN (${ids.toString()})
AND type_id = ${TransactionTypeId.CREATION}
AND creation_date >= ${dateFilter}
${unionString}) AS result
GROUP BY month, userId
ORDER BY date DESC
`)
await queryRunner.release()
return ids.map((id) => {
return {
id,
creations: months.map((month) => {
const creation = unionQuery.find(
(raw: { month: string; id: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id,
)
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}),
}
})
}
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending)
const creations = await getUserCreations([id], includePending)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
}
export const getCreationMonths = (): number[] => {
const now = new Date(Date.now())
return [
now.getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1,
].reverse()
}
export const getCreationIndex = (month: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1)
}
export const isStartEndDateValid = (
startDate: string | null | undefined,
endDate: string | null | undefined,
): void => {
if (!startDate) {
logger.error('Start-Date is not initialized. A Start-Date must be set!')
throw new Error('Start-Date is not initialized. A Start-Date must be set!')
}
if (!endDate) {
logger.error('End-Date is not initialized. An End-Date must be set!')
throw new Error('End-Date is not initialized. An End-Date must be set!')
}
// check if endDate is before startDate
if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) {
logger.error(`The value of validFrom must before or equals the validTo!`)
throw new Error(`The value of validFrom must before or equals the validTo!`)
}
}

View File

@ -230,3 +230,12 @@ export const deleteContributionLink = gql`
deleteContributionLink(id: $id)
}
`
export const createContribution = gql`
mutation ($amount: Decimal!, $memo: String!, $creationDate: String!) {
createContribution(amount: $amount, memo: $memo, creationDate: $creationDate) {
amount
memo
}
}
`

View File

@ -1,6 +1,6 @@
{
"name": "gradido-database",
"version": "1.10.0",
"version": "1.10.1",
"description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database",

View File

@ -28,7 +28,7 @@ COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community"
# backend
BACKEND_CONFIG_VERSION=v8.2022-06-20
JWT_EXPIRES_IN=30m
JWT_EXPIRES_IN=10m
GDT_API_URL=https://gdt.gradido.net
TYPEORM_LOGGING_RELATIVE_PATH=../deployment/bare_metal/log/typeorm.backend.log

View File

@ -1,6 +1,6 @@
{
"name": "bootstrap-vue-gradido-wallet",
"version": "1.10.0",
"version": "1.10.1",
"private": true,
"scripts": {
"start": "node run/server.js",
@ -45,6 +45,7 @@
"jest": "^26.6.3",
"jest-canvas-mock": "^2.3.1",
"jest-environment-jsdom-sixteen": "^2.0.0",
"jwt-decode": "^3.1.2",
"portal-vue": "^2.1.7",
"prettier": "^2.2.1",
"qrcanvas-vue": "2.1.1",
@ -59,6 +60,7 @@
"vue-loading-overlay": "^3.4.2",
"vue-moment": "^4.1.0",
"vue-router": "^3.0.6",
"vue-timers": "^2.0.4",
"vue2-transitions": "^0.2.3",
"vuex": "^3.6.0",
"vuex-persistedstate": "^4.0.0-beta.3"

View File

@ -46,7 +46,7 @@ export default {
<style lang="scss">
.authNavbar > .nav-link {
color: #383838 !important;
color: #0e79bc !important;
}
.navbar-toggler {
@ -54,7 +54,7 @@ export default {
}
.authNavbar > .router-link-exact-active {
color: #0e79bc !important;
color: #383838 !important;
}
button.navbar-toggler > span.navbar-toggler-icon {

View File

@ -5,7 +5,7 @@
@click.prevent="saveLocale(lang.code)"
:key="lang.code"
class="pointer pr-2"
:class="$store.state.language === lang.code ? 'c-blau' : 'c-grey'"
:class="$store.state.language === lang.code ? 'c-grey' : 'c-blau'"
>
<span class="locales">{{ lang.name }}</span>
<span class="ml-3">{{ locales.length - 1 > index ? $t('math.pipe') : '' }}</span>

View File

@ -0,0 +1,99 @@
import { mount } from '@vue/test-utils'
import SessionLogoutTimeout from './SessionLogoutTimeout'
const localVue = global.localVue
const apolloQueryMock = jest.fn()
const setTokenTime = (seconds) => {
const now = new Date()
return Math.floor(new Date(now.setSeconds(now.getSeconds() + seconds)).getTime() / 1000)
}
const mocks = {
$store: {
state: {
token: '1234',
tokenTime: setTokenTime(120),
},
},
$i18n: {
locale: 'en',
},
$t: jest.fn((t) => t),
$apollo: {
query: apolloQueryMock,
},
$route: {
meta: {
requiresAuth: true,
},
},
}
describe('SessionLogoutTimeout', () => {
let wrapper, spy
const Wrapper = () => {
return mount(SessionLogoutTimeout, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper()
})
it('renders the component div.session-logout-timeout', () => {
expect(wrapper.find('div.session-logout-timeout').exists()).toBe(true)
})
describe('timers', () => {
it('has a token expires timer', () => {
expect(wrapper.vm.$options.timers).toEqual({
tokenExpires: expect.objectContaining({
name: 'tokenExpires',
time: 15000,
repeat: true,
immediate: true,
autostart: true,
isSwitchTab: false,
}),
})
})
describe('token is expired', () => {
beforeEach(() => {
mocks.$store.state.tokenTime = setTokenTime(-60)
wrapper = Wrapper()
})
it('emits logout', () => {
expect(wrapper.emitted('logout')).toBeTruthy()
})
})
describe('token time less than 75 seconds', () => {
beforeEach(() => {
mocks.$store.state.tokenTime = setTokenTime(60)
jest.useFakeTimers()
wrapper = Wrapper()
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve(true))
})
it('sets the timer to 1000', () => {
expect(wrapper.vm.timers.tokenExpires.time).toBe(1000)
})
it.skip('opens the modal', () => {
jest.advanceTimersByTime(1000)
jest.advanceTimersByTime(1000)
jest.advanceTimersByTime(1000)
jest.advanceTimersByTime(1000)
expect(spy).toBeCalled()
})
})
})
})
})

View File

@ -0,0 +1,100 @@
<template>
<div class="session-logout-timeout">
<b-modal
id="modalSessionTimeOut"
class="bg-variant-danger"
hide-header-close
hide-header
hide-footer
no-close-on-backdrop
>
<b-card header-tag="header" footer-tag="footer">
<b-card-text>
<div class="p-3 h2">{{ $t('session.warningText') }}</div>
<div class="p-3">
{{ $t('session.lightText') }}
</div>
<div class="p-3 h2 text-warning">
{{ $t('session.logoutIn') }}
<b>{{ tokenExpiresInSeconds }}</b>
{{ $t('time.seconds') }}
</div>
</b-card-text>
<b-row>
<b-col class="text-center">
<b-button size="lg" variant="success" @click="handleOk">
{{ $t('session.extend') }}
</b-button>
</b-col>
</b-row>
</b-card>
<template #modal-footer>
<b-button size="sm" variant="danger" @click="$emit('logout')">
{{ $t('navigation.logout') }}
</b-button>
<b-button size="lg" variant="success" @click="handleOk">
{{ $t('session.extend') }}
</b-button>
</template>
</b-modal>
</div>
</template>
<script>
import { verifyLogin } from '@/graphql/queries'
export default {
name: 'SessionLogoutTimeout',
data() {
return {
now: new Date().getTime(),
}
},
timers: {
tokenExpires: {
time: 15000,
autostart: true,
repeat: true,
immediate: true,
},
},
methods: {
tokenExpires() {
this.now = new Date().getTime()
if (this.tokenExpiresInSeconds < 75 && this.timers.tokenExpires.time !== 1000) {
this.timers.tokenExpires.time = 1000
this.$timer.restart('tokenExpires')
this.$bvModal.show('modalSessionTimeOut')
}
if (this.tokenExpiresInSeconds <= 0) {
this.$timer.stop('tokenExpires')
this.$emit('logout')
}
},
handleOk(bvModalEvent) {
bvModalEvent.preventDefault()
this.$apollo
.query({
query: verifyLogin,
fetchPolicy: 'network-only',
})
.then((result) => {
this.timers.tokenExpires.time = 15000
this.$timer.restart('tokenExpires')
this.$bvModal.hide('modalSessionTimeOut')
})
.catch(() => {
this.$timer.stop('tokenExpires')
this.$emit('logout')
})
},
},
computed: {
tokenExpiresInSeconds() {
return Math.floor((new Date(this.$store.state.tokenTime * 1000).getTime() - this.now) / 1000)
},
},
beforeDestroy() {
this.$timer.stop('tokenExpires')
},
}
</script>

View File

@ -9,15 +9,16 @@ const mockAPIcall = jest.fn()
const navigatorClipboardMock = jest.fn()
const mocks = {
$i18n: {
locale: 'en',
},
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$tc: jest.fn((tc) => tc),
$apollo: {
mutate: mockAPIcall,
},
$store: {
state: {
firstName: 'Testy',
},
},
}
const propsData = {
@ -77,7 +78,7 @@ describe('TransactionLink', () => {
navigator.clipboard = navigatorClipboard
})
describe('copy with success', () => {
describe('copy link with success', () => {
beforeEach(async () => {
navigatorClipboardMock.mockResolvedValue()
await wrapper.find('.test-copy-link .dropdown-item').trigger('click')
@ -92,6 +93,47 @@ describe('TransactionLink', () => {
expect(toastSuccessSpy).toBeCalledWith('gdd_per_link.link-copied')
})
})
describe('copy link and text with success', () => {
beforeEach(async () => {
navigatorClipboardMock.mockResolvedValue()
await wrapper.find('.test-copy-text .dropdown-item').trigger('click')
})
it('should call clipboard.writeText', () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'http://localhost/redeem/c00000000c000000c0000\n' +
'Testy transaction-link.send_you 75 Gradido.\n' +
'"Katzenauge, Eulenschrei, was verschwunden komm herbei!"\n' +
'gdd_per_link.credit-your-gradido gdd_per_link.validUntilDate',
)
})
it('toasts success message', () => {
expect(toastSuccessSpy).toBeCalledWith('gdd_per_link.link-and-text-copied')
})
})
describe('copy link with error', () => {
beforeEach(async () => {
navigatorClipboardMock.mockRejectedValue()
await wrapper.find('.test-copy-link .dropdown-item').trigger('click')
})
it('toasts an error', () => {
expect(toastErrorSpy).toBeCalledWith('gdd_per_link.not-copied')
})
})
describe('copy link and text with error', () => {
beforeEach(async () => {
navigatorClipboardMock.mockRejectedValue()
await wrapper.find('.test-copy-text .dropdown-item').trigger('click')
})
it('toasts an error', () => {
expect(toastErrorSpy).toBeCalledWith('gdd_per_link.not-copied')
})
})
})
describe('qr code modal', () => {

View File

@ -22,6 +22,14 @@
<b-icon icon="clipboard"></b-icon>
{{ $t('gdd_per_link.copy') }}
</b-dropdown-item>
<b-dropdown-item
v-if="validLink"
class="test-copy-text pt-3"
@click="copyLinkWithText()"
>
<b-icon icon="clipboard-plus"></b-icon>
{{ $t('gdd_per_link.copy-with-text') }}
</b-dropdown-item>
<b-dropdown-item
v-if="validLink"
@click="$bvModal.show('modalPopover-' + id)"
@ -99,6 +107,24 @@ export default {
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
copyLinkWithText() {
navigator.clipboard
.writeText(
`${this.link}
${this.$store.state.firstName} ${this.$t('transaction-link.send_you')} ${this.amount} Gradido.
"${this.memo}"
${this.$t('gdd_per_link.credit-your-gradido')} ${this.$t('gdd_per_link.validUntilDate', {
date: this.$d(new Date(this.validUntil), 'short'),
})}`,
)
.then(() => {
this.toastSuccess(this.$t('gdd_per_link.link-and-text-copied'))
})
.catch(() => {
this.$bvModal.show('modalPopoverCopyError' + this.id)
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
deleteLink() {
this.$bvModal.msgBoxConfirm(this.$t('gdd_per_link.delete-the-link')).then(async (value) => {
if (value)

View File

@ -35,9 +35,10 @@ describe('AuthLayout', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('Mobile Version Start', () => {
beforeEach(() => {
wrapper.vm.mobileStart = true
wrapper.findComponent({ name: 'AuthMobileStart' }).vm.$emit('set-mobile-start', true)
})
it('has Component AuthMobileStart', () => {

View File

@ -30,8 +30,8 @@
</b-row>
<b-row class="mt-0 mt-md-5 pl-2 pl-md-0 pl-lg-0">
<b-col lg="9" md="9" sm="12">
<div class="h1 mb--2">{{ $t('welcome') }}</div>
<div class="h1 mb-0">{{ $t('WelcomeBy', { name: communityName }) }}</div>
<div class="mb--2">{{ $t('welcome') }}</div>
<div class="h1 mb-0">{{ communityName }}</div>
<div class="mb-0">{{ $t('1000thanks') }}</div>
</b-col>
<b-col cols="3" class="text-right d-none d-sm-none d-md-inline">

View File

@ -32,6 +32,7 @@
</fade-transition>
</div>
<content-footer v-if="!$route.meta.hideFooter"></content-footer>
<session-logout-timeout @logout="logout"></session-logout-timeout>
</div>
</div>
</div>
@ -39,6 +40,7 @@
<script>
import Navbar from '@/components/Menu/Navbar.vue'
import Sidebar from '@/components/Menu/Sidebar.vue'
import SessionLogoutTimeout from '@/components/SessionLogoutTimeout.vue'
import { logout, transactionsQuery } from '@/graphql/queries'
import ContentFooter from '@/components/ContentFooter.vue'
import { FadeTransition } from 'vue2-transitions'
@ -49,6 +51,7 @@ export default {
components: {
Navbar,
Sidebar,
SessionLogoutTimeout,
ContentFooter,
FadeTransition,
},

View File

@ -125,7 +125,9 @@
"gdd_per_link": {
"choose-amount": "Wähle einen Betrag aus, welchen du per Link versenden möchtest. Du kannst auch noch eine Nachricht eintragen. Beim Klick „Jetzt generieren“ wird ein Link erstellt, den du versenden kannst.",
"copy": "kopieren",
"copy-with-text": "Link und Text kopieren",
"created": "Der Link wurde erstellt!",
"credit-your-gradido": "Damit die Gradido gutgeschrieben werden können, klicke auf den Link!",
"decay-14-day": "Vergänglichkeit für 14 Tage",
"delete-the-link": "Den Link löschen?",
"deleted": "Der Link wurde gelöscht!",
@ -133,6 +135,7 @@
"has-account": "Du besitzt bereits ein Gradido Konto?",
"header": "Gradidos versenden per Link",
"isFree": "Gradido ist weltweit kostenfrei.",
"link-and-text-copied": "Der Link und deine Nachricht wurden in die Zwischenablage kopiert. Du kannst ihn jetzt in eine E-Mail oder Nachricht einfügen.",
"link-copied": "Link wurde in die Zwischenablage kopiert. Du kannst ihn jetzt in eine E-Mail oder Nachricht einfügen.",
"link-deleted": "Der Link wurde am {date} gelöscht.",
"link-expired": "Der Link ist nicht mehr gültig. Die Gültigkeit ist am {date} abgelaufen.",
@ -149,7 +152,8 @@
"redeemed-title": "eingelöst",
"to-login": "Log dich ein",
"to-register": "Registriere ein neues Konto.",
"validUntil": "Gültig bis"
"validUntil": "Gültig bis",
"validUntilDate": "Der Link ist bis zum {date} gültig."
},
"gdt": {
"calculation": "Berechnung der Gradido Transform",
@ -201,6 +205,12 @@
"qrCode": "QR Code",
"send_gdd": "GDD versenden",
"send_per_link": "GDD versenden per Link",
"session": {
"extend": "Angemeldet bleiben",
"lightText": "Wenn du länger als 10 Minuten keine Aktion getätigt hast, wirst du aus Sicherheitsgründen abgemeldet.",
"logoutIn": "Abmelden in ",
"warningText": "Bist du noch da?"
},
"settings": {
"language": {
"changeLanguage": "Sprache ändern",
@ -278,6 +288,5 @@
"send_you": "sendet dir"
},
"via_link": "über einen Link",
"welcome": "Willkommen",
"WelcomeBy": "bei {name}"
"welcome": "Willkommen in der Gemeinschaft"
}

View File

@ -125,7 +125,9 @@
"gdd_per_link": {
"choose-amount": "Select an amount that you would like to send via link. You can also enter a message. Click 'Generate now' to create a link that you can share.",
"copy": "copy",
"copy-with-text": "Copy link and text",
"created": "Link was created!",
"credit-your-gradido": "For the Gradido to be credited, click on the link!",
"decay-14-day": "Decay for 14 days",
"delete-the-link": "Delete the link?",
"deleted": "The link was deleted!",
@ -133,6 +135,7 @@
"has-account": "You already have a Gradido account?",
"header": "Send Gradidos via link",
"isFree": "Gradido is free of charge worldwide.",
"link-and-text-copied": "The link and your message have been copied to the clipboard. You can now include it in an email or message.",
"link-copied": "Link has been copied to the clipboard. You can now paste it into an email or message.",
"link-deleted": "The link was deleted on {date}.",
"link-expired": "The link is no longer valid. The validity expired on {date}.",
@ -149,7 +152,8 @@
"redeemed-title": "redeemed",
"to-login": "Log in",
"to-register": "Register a new account.",
"validUntil": "Valid until"
"validUntil": "Valid until",
"validUntilDate": "The link is valid until {date}."
},
"gdt": {
"calculation": "Calculation of Gradido Transform",
@ -201,6 +205,12 @@
"qrCode": "QR Code",
"send_gdd": "GDD send",
"send_per_link": "GDD send via link",
"session": {
"extend": "Stay logged in",
"lightText": "If you have not performed any action for more than 10 minutes, you will be logged out for security reasons.",
"logoutIn": "Log out in ",
"warningText": "Are you still there?"
},
"settings": {
"language": {
"changeLanguage": "Change language",
@ -278,6 +288,5 @@
"send_you": "wants to send you"
},
"via_link": "via Link",
"welcome": "Welcome",
"WelcomeBy": "by {name}"
"welcome": "Welcome to the community"
}

View File

@ -18,6 +18,8 @@ import 'vue-loading-overlay/dist/vue-loading.css'
import VueApollo from 'vue-apollo'
import VueTimers from 'vue-timers'
export default {
install(Vue) {
Vue.use(GlobalComponents)
@ -29,5 +31,6 @@ export default {
Vue.use(FlatPickr)
Vue.use(Loading)
Vue.use(VueApollo)
Vue.use(VueTimers)
},
}

View File

@ -3,6 +3,7 @@ import Vuex from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import { localeChanged } from 'vee-validate'
import i18n from '@/i18n.js'
import jwtDecode from 'jwt-decode'
Vue.use(Vuex)
@ -26,6 +27,11 @@ export const mutations = {
},
token: (state, token) => {
state.token = token
if (token) {
state.tokenTime = jwtDecode(token).exp
} else {
state.tokenTime = null
}
},
newsletterState: (state, newsletterState) => {
state.newsletterState = newsletterState
@ -85,6 +91,7 @@ try {
lastName: '',
// username: '',
token: null,
tokenTime: null,
isAdmin: false,
newsletterState: null,
hasElopage: false,

View File

@ -3,6 +3,7 @@ import Vuex from 'vuex'
import Vue from 'vue'
import i18n from '@/i18n.js'
import { localeChanged } from 'vee-validate'
import jwtDecode from 'jwt-decode'
jest.mock('vuex')
jest.mock('@/i18n.js')
@ -11,6 +12,11 @@ jest.mock('vee-validate', () => {
localeChanged: jest.fn(),
}
})
jest.mock('jwt-decode', () => {
return jest.fn(() => {
return { exp: '1234' }
})
})
i18n.locale = 'blubb'
@ -59,6 +65,25 @@ describe('Vuex store', () => {
token(state, '1234')
expect(state.token).toEqual('1234')
})
describe('token has a value', () => {
it('sets the state of tokenTime', () => {
const state = { token: null, tokenTime: null }
token(state, 'token')
expect(jwtDecode).toBeCalledWith('token')
expect(state.tokenTime).toEqual('1234')
})
})
describe('token has null value', () => {
it('sets the state of tokenTime to null', () => {
jest.clearAllMocks()
const state = { token: null, tokenTime: '123' }
token(state, null)
expect(jwtDecode).not.toBeCalled()
expect(state.tokenTime).toEqual(null)
})
})
})
describe('firstName', () => {

View File

@ -8,6 +8,7 @@ import * as rules from 'vee-validate/dist/rules'
import { messages } from 'vee-validate/dist/locale/en.json'
import RegeneratorRuntime from 'regenerator-runtime'
import VueTimers from 'vue-timers'
import VueMoment from 'vue-moment'
@ -46,6 +47,7 @@ global.localVue.use(Vuex)
global.localVue.use(IconsPlugin)
global.localVue.use(RegeneratorRuntime)
global.localVue.use(VueMoment)
global.localVue.use(VueTimers)
global.localVue.component('validation-provider', ValidationProvider)
global.localVue.component('validation-observer', ValidationObserver)
// global.localVue.directive('click-outside', clickOutside)

View File

@ -9821,6 +9821,11 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
jwt-decode@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59"
integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==
killable@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
@ -14472,6 +14477,11 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue-timers@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/vue-timers/-/vue-timers-2.0.4.tgz#7e1c443abf2109db5eeab6e62b0f5a47e94cf70b"
integrity sha512-QOEVdO4V4o9WjFG6C0Kn9tfdTeeECjqvEQozcQlfL1Tn8v0qx4uUPhTYoc1+s6qoJnSbu8f68x8+nm1ZEir0kw==
vue2-transitions@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/vue2-transitions/-/vue2-transitions-0.2.3.tgz#69c9d75b1db05f231b80980c03459d68490ba27d"

View File

@ -1,6 +1,6 @@
{
"name": "gradido",
"version": "1.10.0",
"version": "1.10.1",
"description": "Gradido",
"main": "index.js",
"repository": "git@github.com:gradido/gradido.git",