Merge branch 'master' into 1682-new-design-for-the-login-and-registration-area

This commit is contained in:
Alexander Friedland 2022-03-30 16:46:10 +02:00 committed by GitHub
commit 5419ff6616
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1012 additions and 278 deletions

View File

@ -528,7 +528,7 @@ jobs:
report_name: Coverage Backend
type: lcov
result_path: ./backend/coverage/lcov.info
min_coverage: 54
min_coverage: 55
token: ${{ github.token }}
##########################################################################

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import CreationTransactionListFormular from './CreationTransactionListFormular.vue'
import CreationTransactionList from './CreationTransactionList.vue'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue
@ -46,11 +46,11 @@ const propsData = {
fields: ['date', 'balance', 'name', 'memo', 'decay'],
}
describe('CreationTransactionListFormular', () => {
describe('CreationTransactionList', () => {
let wrapper
const Wrapper = () => {
return mount(CreationTransactionListFormular, { localVue, mocks, propsData })
return mount(CreationTransactionList, { localVue, mocks, propsData })
}
describe('mount', () => {

View File

@ -1,6 +1,6 @@
<template>
<div class="component-creation-transaction-list">
{{ $t('transactionlist.title') }}
<div class="h3">{{ $t('transactionlist.title') }}</div>
<b-table striped hover :fields="fields" :items="items"></b-table>
</div>
</template>

View File

@ -0,0 +1,129 @@
import { mount } from '@vue/test-utils'
import OpenCreationsTable from './OpenCreationsTable.vue'
const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue({})
const apolloQueryMock = jest.fn().mockResolvedValue({})
const propsData = {
items: [
{
id: 4,
firstName: 'Bob',
lastName: 'der Baumeister',
email: 'bob@baumeister.de',
amount: 300,
memo: 'Aktives Grundeinkommen für Januar 2022',
date: '2022-01-01T00:00:00.000Z',
moderator: 1,
creation: [700, 1000, 1000],
__typename: 'PendingCreation',
},
{
id: 5,
firstName: 'Räuber',
lastName: 'Hotzenplotz',
email: 'raeuber@hotzenplotz.de',
amount: 210,
memo: 'Aktives Grundeinkommen für Januar 2022',
date: '2022-01-01T00:00:00.000Z',
moderator: 1,
creation: [790, 1000, 1000],
__typename: 'PendingCreation',
},
{
id: 6,
firstName: 'Stephen',
lastName: 'Hawking',
email: 'stephen@hawking.uk',
amount: 330,
memo: 'Aktives Grundeinkommen für Januar 2022',
date: '2022-01-01T00:00:00.000Z',
moderator: 1,
creation: [670, 1000, 1000],
__typename: 'PendingCreation',
},
],
fields: [
{ key: 'bookmark', label: 'delete' },
{ key: 'email', label: 'e_mail' },
{ key: 'firstName', label: 'firstname' },
{ key: 'lastName', label: 'lastname' },
{
key: 'amount',
label: 'creation',
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'memo', label: 'text' },
{
key: 'date',
label: 'date',
formatter: (value) => {
return value
},
},
{ key: 'moderator', label: 'moderator' },
{ key: 'edit_creation', label: 'edit' },
{ key: 'confirm', label: 'save' },
],
}
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$apollo: {
mutate: apolloMutateMock,
query: apolloQueryMock,
},
$store: {
state: {
moderator: {
id: 0,
name: 'test moderator',
},
},
},
}
describe('OpenCreationsTable', () => {
let wrapper
const Wrapper = () => {
return mount(OpenCreationsTable, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class .open-creations-table', () => {
expect(wrapper.find('div.open-creations-table').exists()).toBeTruthy()
})
it('has a table with three rows', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(3)
})
it('find first button.bi-pencil-square for open EditCreationFormular ', () => {
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBeTruthy()
})
describe('show edit details', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).find('.bi-pencil-square').trigger('click')
})
it.skip('has a component element with name EditCreationFormular', () => {
expect(wrapper.findComponent({ name: 'EditCreationFormular' }).exists()).toBe(true)
})
it.skip('renders the component component-edit-creation-formular', () => {
expect(wrapper.find('div.component-edit-creation-formular').exists()).toBeTruthy()
})
})
})
})

View File

@ -1,5 +1,5 @@
<template>
<div class="component-open-creations-table">
<div class="open-creations-table">
<b-table-lite :items="items" :fields="fields" caption-top striped hover stacked="md">
<template #cell(bookmark)="row">
<b-button

View File

@ -69,10 +69,9 @@
: ''
"
/>
<creation-transaction-list-formular
v-if="!row.item.deletedAt"
:userId="row.item.userId"
/>
<creation-transaction-list v-if="!row.item.deletedAt" :userId="row.item.userId" />
<transaction-link-list :userId="row.item.userId" />
<deleted-user-formular :item="row.item" @updateDeletedAt="updateDeletedAt" />
</b-card>
</template>
@ -82,7 +81,8 @@
<script>
import CreationFormular from '../CreationFormular.vue'
import ConfirmRegisterMailFormular from '../ConfirmRegisterMailFormular.vue'
import CreationTransactionListFormular from '../CreationTransactionListFormular.vue'
import CreationTransactionList from '../CreationTransactionList.vue'
import TransactionLinkList from '../TransactionLinkList.vue'
import DeletedUserFormular from '../DeletedUserFormular.vue'
export default {
@ -90,7 +90,8 @@ export default {
components: {
CreationFormular,
ConfirmRegisterMailFormular,
CreationTransactionListFormular,
CreationTransactionList,
TransactionLinkList,
DeletedUserFormular,
},
props: {

View File

@ -0,0 +1,140 @@
import { mount } from '@vue/test-utils'
import TransactionLinkList from './TransactionLinkList.vue'
import { listTransactionLinksAdmin } from '../graphql/listTransactionLinksAdmin.js'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue
const apolloQueryMock = jest.fn()
apolloQueryMock.mockResolvedValue({
data: {
listTransactionLinksAdmin: {
linkCount: 8,
linkList: [
{
amount: '19.99',
code: '62ef8236ace7217fbd066c5a',
createdAt: '2022-03-24T17:43:09.000Z',
deletedAt: null,
holdAvailableAmount: '20.51411720068412022949',
id: 36,
memo: 'Kein Trick, keine Zauberrei,\nbei Gradidio sei dabei!',
redeemedAt: null,
validUntil: '2022-04-07T17:43:09.000Z',
},
{
amount: '19.99',
code: '2b603f36521c617fbd066cef',
createdAt: '2022-03-24T17:43:09.000Z',
deletedAt: null,
holdAvailableAmount: '20.51411720068412022949',
id: 37,
memo: 'Kein Trick, keine Zauberrei,\nbei Gradidio sei dabei!',
redeemedAt: null,
validUntil: '2022-04-07T17:43:09.000Z',
},
{
amount: '19.99',
code: '0bb789b5bd5b717fbd066eb5',
createdAt: '2022-03-24T17:43:09.000Z',
deletedAt: '2022-03-24T17:43:09.000Z',
holdAvailableAmount: '20.51411720068412022949',
id: 40,
memo: 'Da habe ich mich wohl etwas übernommen.',
redeemedAt: '2022-04-07T14:43:09.000Z',
validUntil: '2022-04-07T17:43:09.000Z',
},
{
amount: '19.99',
code: '2d4a763e516b317fbd066a85',
createdAt: '2022-01-01T00:00:00.000Z',
deletedAt: null,
holdAvailableAmount: '20.51411720068412022949',
id: 33,
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
redeemedAt: null,
validUntil: '2022-01-15T00:00:00.000Z',
},
],
},
},
})
const propsData = {
userId: 42,
}
const mocks = {
$apollo: {
query: apolloQueryMock,
},
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
describe('TransactionLinkList', () => {
let wrapper
const Wrapper = () => {
return mount(TransactionLinkList, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper()
})
it('calls the API', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listTransactionLinksAdmin,
variables: {
currentPage: 1,
pageSize: 5,
userId: 42,
},
}),
)
})
it('has 4 items in the table', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(4)
})
it('has pagination buttons', () => {
expect(wrapper.findComponent({ name: 'BPagination' }).exists()).toBe(true)
})
describe('next page', () => {
beforeAll(async () => {
jest.clearAllMocks()
await wrapper.findComponent({ name: 'BPagination' }).vm.$emit('input', 2)
})
it('calls the API again', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listTransactionLinksAdmin,
variables: {
currentPage: 1,
pageSize: 5,
userId: 42,
},
}),
)
})
})
describe('server response with error', () => {
beforeEach(() => {
apolloQueryMock.mockRejectedValue({ message: 'Oh no!' })
wrapper = Wrapper()
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh no!')
})
})
})
})

View File

@ -0,0 +1,105 @@
<template>
<div class="transaction-link-list">
<div v-if="items.length > 0">
<div class="h3">{{ $t('transactionlink.form_header') }}</div>
<b-table striped hover :fields="fields" :items="items"></b-table>
</div>
<b-pagination
pills
size="lg"
v-model="currentPage"
:per-page="perPage"
:total-rows="rows"
align="center"
></b-pagination>
</div>
</template>
<script>
import { listTransactionLinksAdmin } from '../graphql/listTransactionLinksAdmin.js'
export default {
name: 'TransactionLinkList',
props: {
userId: { type: Number, required: true },
},
data() {
return {
items: [],
rows: 0,
currentPage: 1,
perPage: 5,
}
},
methods: {
getListTransactionLinks() {
this.$apollo
.query({
query: listTransactionLinksAdmin,
variables: {
currentPage: this.currentPage,
pageSize: this.perPage,
userId: this.userId,
},
})
.then((result) => {
this.rows = result.data.listTransactionLinksAdmin.linkCount
this.items = result.data.listTransactionLinksAdmin.linkList
})
.catch((error) => {
this.toastError(error.message)
})
},
},
computed: {
fields() {
return [
{
key: 'createdAt',
label: this.$t('transactionlink.created'),
formatter: (value, key, item) => {
return this.$d(new Date(value))
},
},
{
key: 'amount',
label: this.$t('transactionlist.amount'),
formatter: (value, key, item) => {
return `${value} GDD`
},
},
{ key: 'memo', label: this.$t('transactionlist.memo') },
{
key: 'validUntil',
label: this.$t('transactionlink.valid_until'),
formatter: (value, key, item) => {
return this.$d(new Date(value))
},
},
{
key: 'status',
label: 'status',
formatter: (value, key, item) => {
// deleted
if (item.deletedAt) return this.$t('deleted') + ': ' + this.$d(new Date(item.deletedAt))
// redeemed
if (item.redeemedAt)
return this.$t('redeemed') + ': ' + this.$d(new Date(item.redeemedAt))
// expired
if (new Date() > new Date(item.validUntil))
return this.$t('expired') + ': ' + this.$d(new Date(item.validUntil))
// open
return this.$t('open')
},
},
]
},
},
created() {
this.getListTransactionLinks()
},
watch: {
currentPage() {
this.getListTransactionLinks()
},
},
}
</script>

View File

@ -0,0 +1,20 @@
import gql from 'graphql-tag'
export const listTransactionLinksAdmin = gql`
query ($currentPage: Int = 1, $pageSize: Int = 5, $userId: Int!) {
listTransactionLinksAdmin(currentPage: $currentPage, pageSize: $pageSize, userId: $userId) {
linkCount
linkList {
id
amount
holdAvailableAmount
memo
code
createdAt
validUntil
redeemedAt
deletedAt
}
}
}
`

View File

@ -27,6 +27,7 @@
"edit": "Bearbeiten",
"enabled": "aktiviert",
"error": "Fehler",
"expired": "abgelaufen",
"e_mail": "E-Mail",
"firstname": "Vorname",
"footer": {
@ -56,6 +57,7 @@
"user_search": "Nutzersuche"
},
"not_open_creations": "Keine offenen Schöpfungen",
"open": "offen",
"open_creations": "Offene Schöpfungen",
"overlay": {
"confirm": {
@ -66,6 +68,7 @@
"yes": "Ja, Schöpfung bestätigen und speichern!"
}
},
"redeemed": "eingelöst",
"remove": "Entfernen",
"removeNotSelf": "Als Admin / Moderator kannst du dich nicht selber löschen.",
"remove_all": "alle Nutzer entfernen",
@ -73,6 +76,11 @@
"status": "Status",
"success": "Erfolg",
"text": "Text",
"transactionlink": {
"created": "Erstellt",
"form_header": "Transaktion-Links",
"valid_until": "Gültig bis"
},
"transactionlist": {
"amount": "Betrag",
"balanceDate": "Schöpfungsdatum",

View File

@ -27,6 +27,7 @@
"edit": "Edit",
"enabled": "enabled",
"error": "Error",
"expired": "expired",
"e_mail": "E-mail",
"firstname": "Firstname",
"footer": {
@ -56,6 +57,7 @@
"user_search": "User search"
},
"not_open_creations": "No open creations",
"open": "open",
"open_creations": "Open creations",
"overlay": {
"confirm": {
@ -66,6 +68,7 @@
"yes": "Yes, confirm and save creation!"
}
},
"redeemed": "redeemed",
"remove": "Remove",
"removeNotSelf": "As admin / moderator you cannot delete yourself.",
"remove_all": "Remove all users",
@ -73,6 +76,11 @@
"status": "Status",
"success": "Success",
"text": "Text",
"transactionlink": {
"created": "Created",
"form_header": "Transaction links",
"valid_until": "Valid until"
},
"transactionlist": {
"amount": "Amount",
"balanceDate": "Creation date",

View File

@ -71,6 +71,8 @@ describe('Creation', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
isDeleted: false,
notActivated: false,
},
}),
)
@ -269,6 +271,8 @@ describe('Creation', () => {
searchText: 'XX',
currentPage: 1,
pageSize: 25,
isDeleted: false,
notActivated: false,
},
}),
)
@ -284,6 +288,8 @@ describe('Creation', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
isDeleted: false,
notActivated: false,
},
}),
)
@ -299,6 +305,8 @@ describe('Creation', () => {
searchText: '',
currentPage: 2,
pageSize: 25,
isDeleted: false,
notActivated: false,
},
}),
)

View File

@ -102,6 +102,8 @@ export default {
searchText: this.criteria,
currentPage: this.currentPage,
pageSize: this.perPage,
notActivated: false,
isDeleted: false,
},
fetchPolicy: 'network-only',
})

View File

@ -82,8 +82,8 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
notActivated: false,
isDeleted: false,
notActivated: null,
isDeleted: null,
},
}),
)
@ -102,7 +102,7 @@ describe('UserSearch', () => {
currentPage: 1,
pageSize: 25,
notActivated: true,
isDeleted: false,
isDeleted: null,
},
}),
)
@ -121,7 +121,7 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
notActivated: false,
notActivated: null,
isDeleted: true,
},
}),
@ -141,8 +141,8 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 2,
pageSize: 25,
notActivated: false,
isDeleted: false,
notActivated: null,
isDeleted: null,
},
}),
)
@ -161,8 +161,8 @@ describe('UserSearch', () => {
searchText: 'search string',
currentPage: 1,
pageSize: 25,
notActivated: false,
isDeleted: false,
notActivated: null,
isDeleted: null,
},
}),
)
@ -178,8 +178,8 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
notActivated: false,
isDeleted: false,
notActivated: null,
isDeleted: null,
},
}),
)

View File

@ -3,11 +3,11 @@
<div class="user-search-first-div">
<b-button class="unconfirmedRegisterMails" variant="light" @click="unconfirmedRegisterMails">
<b-icon icon="envelope" variant="danger"></b-icon>
{{ filterCheckedEmails ? $t('all_emails') : $t('unregistered_emails') }}
{{ filterCheckedEmails ? $t('unregistered_emails') : $t('all_emails') }}
</b-button>
<b-button class="deletedUserSearch" variant="light" @click="deletedUserSearch">
<b-icon icon="x-circle" variant="danger"></b-icon>
{{ filterDeletedUser ? $t('all_emails') : $t('deleted_user') }}
{{ filterDeletedUser ? $t('deleted_user') : $t('all_emails') }}
</b-button>
</div>
<label>{{ $t('user_search') }}</label>
@ -60,8 +60,8 @@ export default {
searchResult: [],
massCreation: [],
criteria: '',
filterCheckedEmails: false,
filterDeletedUser: false,
filterCheckedEmails: null,
filterDeletedUser: null,
rows: 0,
currentPage: 1,
perPage: 25,
@ -70,11 +70,11 @@ export default {
},
methods: {
unconfirmedRegisterMails() {
this.filterCheckedEmails = !this.filterCheckedEmails
this.filterCheckedEmails = this.filterCheckedEmails ? null : true
this.getUsers()
},
deletedUserSearch() {
this.filterDeletedUser = !this.filterDeletedUser
this.filterDeletedUser = this.filterDeletedUser ? null : true
this.getUsers()
},
getUsers() {

View File

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

View File

@ -24,6 +24,7 @@ export enum RIGHTS {
QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK',
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
CREATE_PENDING_CREATION = 'CREATE_PENDING_CREATION',
@ -35,4 +36,5 @@ export enum RIGHTS {
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
CREATION_TRANSACTION_LIST = 'CREATION_TRANSACTION_LIST',
LIST_TRANSACTION_LINKS_ADMIN = 'LIST_TRANSACTION_LINKS_ADMIN',
}

View File

@ -22,6 +22,7 @@ export const ROLE_USER = new Role('user', [
RIGHTS.DELETE_TRANSACTION_LINK,
RIGHTS.REDEEM_TRANSACTION_LINK,
RIGHTS.LIST_TRANSACTION_LINKS,
RIGHTS.GDT_BALANCE,
])
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
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v1.2022-03-18',
EXPECTED: 'v3.2022-03-29',
CURRENT: '',
},
}
@ -70,8 +70,15 @@ const email = {
process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{optin}{code}',
EMAIL_LINK_SETPASSWORD:
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
? 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,
}

View File

@ -12,8 +12,8 @@ export default class SearchUsersArgs {
pageSize?: number
@Field(() => Boolean, { nullable: true })
notActivated?: boolean
notActivated?: boolean | null
@Field(() => Boolean, { nullable: true })
isDeleted?: boolean
isDeleted?: boolean | null
}

View File

@ -0,0 +1,13 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class TransactionLinkFilters {
@Field(() => Boolean, { nullable: true, defaultValue: true })
withDeleted?: boolean
@Field(() => Boolean, { nullable: true, defaultValue: true })
withExpired?: boolean
@Field(() => Boolean, { nullable: true, defaultValue: true })
withRedeemed?: boolean
}

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 Decimal from 'decimal.js-light'
import CONFIG from '@/config'
@ObjectType()
export class Balance {
constructor(json: any) {
this.balance = json.balance
this.decay = json.decay
this.decayDate = json.decay_date
constructor(data: {
balance: Decimal
decay: Decimal
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)
balance: Decimal
// the decay since the last booked balance
@Field(() => 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)
decayDate: Date
decayStartBlock: Date
// may be null as there may be no transaction
@Field(() => Date, { nullable: true })
lastBookedDate: Date | null
}

View File

@ -1,4 +1,4 @@
import { ObjectType, Field } from 'type-graphql'
import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { User } from './User'
@ -52,3 +52,12 @@ export class TransactionLink {
@Field(() => User, { nullable: true })
redeemedBy: User | null
}
@ObjectType()
export class TransactionLinkResult {
@Field(() => Int)
linkCount: number
@Field(() => [TransactionLink])
linkList: TransactionLink[]
}

View File

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

View File

@ -9,6 +9,8 @@ import {
ObjectLiteral,
getConnection,
In,
MoreThan,
FindOperator,
} from '@dbTools/typeorm'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { PendingCreation } from '@model/PendingCreation'
@ -21,6 +23,8 @@ import UpdatePendingCreationArgs from '@arg/UpdatePendingCreationArgs'
import SearchUsersArgs from '@arg/SearchUsersArgs'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { Transaction } from '@model/Transaction'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { TransactionRepository } from '@repository/Transaction'
import { calculateDecay } from '@/util/decay'
import { AdminPendingCreation } from '@entity/AdminPendingCreation'
@ -32,8 +36,12 @@ import { TransactionTypeId } from '@enum/TransactionTypeId'
import Decimal from 'decimal.js-light'
import { Decay } from '@model/Decay'
import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order'
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_UNKNOWN = 3 // elopage?
@ -50,19 +58,19 @@ export class AdminResolver {
searchText,
currentPage = 1,
pageSize = 25,
notActivated = false,
isDeleted = false,
notActivated = null,
isDeleted = null,
}: SearchUsersArgs,
): Promise<SearchUsersResult> {
const userRepository = getCustomRepository(UserRepository)
const filterCriteria: ObjectLiteral[] = []
if (notActivated) {
filterCriteria.push({ emailChecked: false })
if (notActivated !== null) {
filterCriteria.push({ emailChecked: !notActivated })
}
if (isDeleted) {
filterCriteria.push({ deletedAt: Not(IsNull()) })
if (isDeleted !== null) {
filterCriteria.push({ deletedAt: isDeleted ? Not(IsNull()) : IsNull() })
}
const userFields = ['id', 'firstName', 'lastName', 'email', 'emailChecked', 'deletedAt']
@ -369,6 +377,75 @@ export class AdminResolver {
const user = await dbUser.findOneOrFail({ id: userId })
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])
@Query(() => TransactionLinkResult)
async listTransactionLinksAdmin(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Args()
filters: TransactionLinkFilters,
@Arg('userId', () => Int) userId: number,
): Promise<TransactionLinkResult> {
const user = await dbUser.findOneOrFail({ id: userId })
const where: {
userId: number
redeemedBy?: number | null
validUntil?: FindOperator<Date> | null
} = {
userId,
}
if (!filters.withRedeemed) where.redeemedBy = null
if (!filters.withExpired) where.validUntil = MoreThan(new Date())
const [transactionLinks, count] = await dbTransactionLink.findAndCount({
where,
withDeleted: filters.withDeleted,
order: {
createdAt: order,
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
linkCount: count,
linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))),
}
}
}
interface CreationMap {

View File

@ -5,36 +5,74 @@ import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
import { Balance } from '@model/Balance'
import { calculateDecay } from '@/util/decay'
import { RIGHTS } from '@/auth/RIGHTS'
import { Transaction } from '@entity/Transaction'
import { Transaction as dbTransaction } from '@entity/Transaction'
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()
export class BalanceResolver {
@Authorized([RIGHTS.BALANCE])
@Query(() => Balance)
async balance(@Ctx() context: any): Promise<Balance> {
// load user and balance
const { user } = context
const now = new Date()
const lastTransaction = await Transaction.findOne(
{ userId: user.id },
{ order: { balanceDate: 'DESC' } },
)
const gdtResolver = new GdtResolver()
const balanceGDT = await gdtResolver.gdtBalance(context)
const lastTransaction = context.lastTransaction
? context.lastTransaction
: await dbTransaction.findOne({ userId: user.id }, { order: { balanceDate: 'DESC' } })
// No balance found
if (!lastTransaction) {
return new Balance({
balance: 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({
balance: lastTransaction.balance,
decay: calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance,
decay_date: now.toString(),
balance: calculatedDecay.balance.toDecimalPlaces(2, Decimal.ROUND_DOWN), // round towards zero
decay: calculatedDecay.decay.toDecimalPlaces(2, Decimal.ROUND_FLOOR), // round towards - infinity
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 { GdtEntryList } from '@model/GdtEntryList'
import Paginated from '@arg/Paginated'
import { apiGet } from '@/apis/HttpRequest'
import { apiGet, apiPost } from '@/apis/HttpRequest'
import { Order } from '@enum/Order'
import { RIGHTS } from '@/auth/RIGHTS'
@ -13,13 +13,11 @@ import { RIGHTS } from '@/auth/RIGHTS'
export class GdtResolver {
@Authorized([RIGHTS.LIST_GDT_ENTRIES])
@Query(() => GdtEntryList)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async listGDTEntries(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Ctx() context: any,
): Promise<GdtEntryList> {
// load user
const userEntity = context.user
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])
@Query(() => Number)
// 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 { getCustomRepository, getConnection } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
import { Transaction } from '@model/Transaction'
@ -24,7 +23,6 @@ import { User as dbUser } from '@entity/User'
import { Transaction as dbTransaction } from '@entity/Transaction'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { apiPost } from '@/apis/HttpRequest'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { calculateBalance, isHexPublicKey } from '@/util/validate'
import { RIGHTS } from '@/auth/RIGHTS'
@ -32,7 +30,11 @@ import { User } from '@model/User'
import { communityUser } from '@/util/communityUser'
import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions'
import Decimal from 'decimal.js-light'
import { calculateDecay } from '@/util/decay'
import { BalanceResolver } from './BalanceResolver'
const MEMO_MAX_CHARS = 255
const MEMO_MIN_CHARS = 5
export const executeTransaction = async (
amount: Decimal,
@ -45,6 +47,14 @@ export const executeTransaction = async (
throw new Error('Sender and Recipient are the same.')
}
if (memo.length > MEMO_MAX_CHARS) {
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
// validate amount
const receivedCallDate = new Date()
const sendBalance = await calculateBalance(sender.id, amount.mul(-1), receivedCallDate)
@ -143,23 +153,11 @@ export class TransactionResolver {
{ order: { balanceDate: 'DESC' } },
)
// get GDT
let balanceGDT = null
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)
}
const balanceResolver = new BalanceResolver()
context.lastTransaction = lastTransaction
if (!lastTransaction) {
return new TransactionList(new Decimal(0), [], 0, 0, balanceGDT)
return new TransactionList(await balanceResolver.balance(context), [])
}
// find transactions
@ -172,6 +170,7 @@ export class TransactionResolver {
offset,
order,
)
context.transactionCount = userTransactionsCount
// find involved users; I am involved
const involvedUserIds: number[] = [user.id]
@ -194,6 +193,8 @@ export class TransactionResolver {
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, transactionLinkcount } =
await transactionLinkRepository.summary(user.id, now)
context.linkCount = transactionLinkcount
context.sumHoldAvailableAmount = sumHoldAvailableAmount
// decay & link transactions
if (currentPage === 1 && order === Order.DESC) {
@ -226,15 +227,7 @@ export class TransactionResolver {
})
// Construct Result
return new TransactionList(
calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance.minus(
sumHoldAvailableAmount.toString(),
),
transactions,
userTransactionsCount,
transactionLinkcount,
balanceGDT,
)
return new TransactionList(await balanceResolver.balance(context), transactions)
}
@Authorized([RIGHTS.SEND_COINS])

View File

@ -11,6 +11,7 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User'
import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { printTimeDuration } from './UserResolver'
// import { klicktippSignIn } from '@/apis/KlicktippController'
@ -133,6 +134,7 @@ describe('UserResolver', () => {
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
duration: expect.any(String),
})
})
})
@ -220,10 +222,6 @@ describe('UserResolver', () => {
expect(newUser[0].password).toEqual('3917921995996627700')
})
it('removes the optin', async () => {
await expect(LoginEmailOptIn.find()).resolves.toHaveLength(0)
})
/*
it('calls the klicktipp API', () => {
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 { 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 { User } from '@model/User'
import { User as DbUser } from '@entity/User'
@ -15,8 +15,9 @@ import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { UserSettingRepository } from '@repository/UserSettingRepository'
import { Setting } from '@enum/Setting'
import { OptInType } from '@enum/OptInType'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
@ -24,9 +25,6 @@ import { ROLE_ADMIN } from '@/auth/ROLES'
import { hasElopageBuys } from '@/util/hasElopageBuys'
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
const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires
@ -148,57 +146,47 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
return message
}
const createEmailOptIn = async (
loginUserId: number,
queryRunner: QueryRunner,
): 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.userId = loginUserId
emailOptIn.emailOptInTypeId = 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')
})
const newEmailOptIn = (userId: number): LoginEmailOptIn => {
const emailOptIn = new LoginEmailOptIn()
emailOptIn.verificationCode = random(64)
emailOptIn.userId = userId
emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
return emailOptIn
}
const getOptInCode = async (loginUserId: number): Promise<LoginEmailOptIn> => {
let optInCode = await LoginEmailOptIn.findOne({
userId: loginUserId,
emailOptInTypeId: EMAIL_OPT_IN_RESET_PASSWORD,
})
// Check for `CONFIG.EMAIL_CODE_VALID_TIME` minute delay
// needed by AdminResolver
// checks if given code exists and can be resent
// if optIn does not exits, it is created
export const checkOptInCode = async (
optInCode: LoginEmailOptIn | undefined,
userId: number,
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<LoginEmailOptIn> => {
if (optInCode) {
if (isOptInCodeValid(optInCode)) {
throw new Error(`email already sent less than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`)
if (!canResendOptIn(optInCode)) {
throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
}
optInCode.updatedAt = new Date()
optInCode.resendCount++
} else {
optInCode = new LoginEmailOptIn()
optInCode.verificationCode = random(64)
optInCode.userId = loginUserId
optInCode.emailOptInTypeId = EMAIL_OPT_IN_RESET_PASSWORD
optInCode = newEmailOptIn(userId)
}
await LoginEmailOptIn.save(optInCode)
optInCode.emailOptInTypeId = optInType
await LoginEmailOptIn.save(optInCode).catch(() => {
throw new Error('Unable to save optin code.')
})
return optInCode
}
export const activationLink = (optInCode: LoginEmailOptIn): string => {
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString())
}
@Resolver()
export class UserResolver {
@Authorized([RIGHTS.VERIFY_LOGIN])
@ -363,9 +351,12 @@ export class UserResolver {
throw new Error('error saving user')
})
// Store EmailOptIn in DB
// TODO: this has duplicate code with sendResetPasswordEmail
const emailOptIn = await createEmailOptIn(dbUser.id, queryRunner)
const emailOptIn = newEmailOptIn(dbUser.id)
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')
})
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
@ -378,6 +369,7 @@ export class UserResolver {
firstName,
lastName,
email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
})
/* uncomment this, when you need the activation link on the console
@ -398,70 +390,26 @@ export class UserResolver {
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])
@Query(() => Boolean)
async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> {
// TODO: this has duplicate code with createUser
email = email.trim().toLowerCase()
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(
/{optin}/g,
optInCode.verificationCode.toString(),
)
optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmail({
link,
const emailSent = await sendResetPasswordEmailMailer({
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
@ -494,8 +442,10 @@ export class UserResolver {
})
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInCodeValid(optInCode)) {
throw new Error(`email already more than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`)
if (!isOptInValid(optInCode)) {
throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
}
// load user
@ -538,11 +488,6 @@ export class UserResolver {
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()
@ -553,7 +498,7 @@ export class UserResolver {
// Sign into Klicktipp
// 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 {
await klicktippSignIn(user.email, user.language, user.firstName, user.lastName)
} catch {
@ -573,8 +518,10 @@ export class UserResolver {
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn })
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInCodeValid(optInCode)) {
throw new Error(`email was sent more than $(CONFIG.EMAIL_CODE_VALID_TIME} minutes ago`)
if (!isOptInValid(optInCode)) {
throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
}
return true
}
@ -680,7 +627,34 @@ export class UserResolver {
return hasElopageBuys(userEntity.email)
}
}
function isOptInCodeValid(optInCode: LoginEmailOptIn) {
const timeElapsed = Date.now() - new Date(optInCode.updatedAt).getTime()
return timeElapsed <= CONFIG.EMAIL_CODE_VALID_TIME * 60 * 1000
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
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',
lastName: 'Lustig',
email: 'peter@lustig.de',
duration: '23 hours and 30 minutes',
})
})
@ -23,7 +24,9 @@ describe('sendAccountActivationEmail', () => {
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: E-Mail Überprüfung',
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 { accountActivation } from './text/accountActivation'
import CONFIG from '@/config'
export const sendAccountActivationEmail = (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
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',
lastName: 'Lustig',
email: 'peter@lustig.de',
duration: '23 hours and 30 minutes',
})
})
@ -22,7 +23,10 @@ describe('sendResetPasswordEmail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
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 { resetPassword } from './text/resetPassword'
import CONFIG from '@/config'
export const sendResetPasswordEmail = (data: {
link: string
firstName: string
lastName: string
email: string
duration: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
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 = {
de: {
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},
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}
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,
dein Gradido-Team`,
},

View File

@ -1,13 +1,29 @@
export const resetPassword = {
de: {
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},
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}
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,
dein Gradido-Team`,
},

View File

@ -18,7 +18,7 @@ WEBHOOK_GITHUB_SECRET=secret
WEBHOOK_GITHUB_BRANCH=master
# backend
BACKEND_CONFIG_VERSION=v1.2022-03-18
BACKEND_CONFIG_VERSION=v3.2022-03-29
EMAIL=true
EMAIL_USERNAME=peter@lustig.de
@ -49,6 +49,8 @@ KLICKTIPP_APIKEY_EN=
DATABASE_CONFIG_VERSION=v1.2022-03-18
# frontend
FRONTEND_CONFIG_VERSION=v1.2022-03-18
GRAPHQL_URI=https://stage1.gradido.net/graphql
ADMIN_AUTH_URL=https://stage1.gradido.net/admin/authenticate?token={token}
@ -62,5 +64,7 @@ META_KEYWORDS_EN="Basic Income, Currency, Gratitude, Gift Economy, Natural Econo
META_AUTHOR="Bernd Hückstädt - Gradido-Akademie"
# admin
ADMIN_CONFIG_VERSION=v1.2022-03-18
WALLET_AUTH_URL=https://stage1.gradido.net/authenticate?token={token}
WALLET_URL=https://stage1.gradido.net/login

View File

@ -78,8 +78,8 @@
> git clone https://github.com/gradido/gradido.git
# Timezone
# Note: This is not needed - UTC(default) is REQUIRED for production data
# > sudo timedatectl set-timezone UTC
# Note: This is needed - since there is Summer-Time included in the default server Setup - UTC is REQUIRED for production data
> sudo timedatectl set-timezone UTC
# > sudo timedatectl set-ntp on
# > sudo apt purge ntp
# > sudo systemctl start systemd-timesyncd

View File

@ -67,7 +67,7 @@ BRANCH=${1:-master}
echo "Starting with git pull - branch:$BRANCH" >> $UPDATE_HTML
cd $PROJECT_ROOT
# TODO: this overfetches alot, but ensures we can use start.sh with tags
git fetch origin --all
git fetch --all
git checkout $BRANCH
git pull
export BUILD_COMMIT="$(git rev-parse HEAD)"

View File

@ -62,11 +62,8 @@ describe('TransactionForm', () => {
})
})
describe('is selected: "send"', () => {
describe('send GDD', () => {
beforeEach(async () => {
// await wrapper.setData({
// selected: 'send',
// })
await wrapper.findAll('input[type="radio"]').at(0).setChecked()
})
@ -78,15 +75,18 @@ describe('TransactionForm', () => {
beforeEach(() => {
wrapper.setProps({ balance: 100.0 })
})
describe('transaction form show because balance 100,0 GDD', () => {
it('has no warning message ', () => {
expect(wrapper.find('.errors').exists()).toBe(false)
})
it('has a reset button', () => {
expect(wrapper.find('.test-buttons').findAll('button').at(0).attributes('type')).toBe(
'reset',
)
})
it('has a submit button', () => {
expect(wrapper.find('.test-buttons').findAll('button').at(1).attributes('type')).toBe(
'submit',
@ -121,6 +121,12 @@ describe('TransactionForm', () => {
expect(wrapper.find('span.errors').text()).toBe('validations.messages.email')
})
it('flushes an error message when email is the email of logged in user', async () => {
await wrapper.find('#input-group-1').find('input').setValue('user@example.org')
await flushPromises()
expect(wrapper.find('span.errors').text()).toBe('form.validation.is-not')
})
it('trims the email after blur', async () => {
await wrapper.find('#input-group-1').find('input').setValue(' valid@email.com ')
await wrapper.find('#input-group-1').find('input').trigger('blur')
@ -195,6 +201,41 @@ describe('TransactionForm', () => {
expect(wrapper.find('span.errors').text()).toBe('validations.messages.min')
})
it('flushes an error message when memo is more than 255 characters', async () => {
await wrapper.find('#input-group-3').find('textarea').setValue(`
Es ist ein König in Thule, der trinkt
Champagner, es geht ihm nichts drüber;
Und wenn er seinen Champagner trinkt,
Dann gehen die Augen ihm über.
Die Ritter sitzen um ihn her,
Die ganze Historische Schule;
Ihm aber wird die Zunge schwer,
Es lallt der König von Thule:
Als Alexander, der Griechenheld,
Mit seinem kleinen Haufen
Erobert hatte die ganze Welt,
Da gab er sich ans Saufen.
Ihn hatten so durstig gemacht der Krieg
Und die Schlachten, die er geschlagen;
Er soff sich zu Tode nach dem Sieg,
Er konnte nicht viel vertragen.
Ich aber bin ein stärkerer Mann
Und habe mich klüger besonnen:
Wie jener endete, fang ich an,
Ich hab mit dem Trinken begonnen.
Im Rausche wird der Heldenzug
Mir später weit besser gelingen;
Dann werde ich, taumelnd von Krug zu Krug,
Die ganze Welt bezwingen.`)
await flushPromises()
expect(wrapper.find('span.errors').text()).toBe('validations.messages.max')
})
it('flushes no error message when memo is valid', async () => {
await wrapper.find('#input-group-3').find('textarea').setValue('Long enough')
await flushPromises()

View File

@ -99,7 +99,7 @@
:rules="{
required: true,
min: 5,
max: 150,
max: 255,
}"
:name="$t('form.message')"
v-slot="{ errors }"

View File

@ -10,9 +10,20 @@
</b-col>
<b-col cols="7">
<div class="gdd-transaction-list-item-name">
<b-link v-if="linkedUser && linkedUser.email" @click.stop="tunnelEmail">
{{ itemText }}
</b-link>
<div v-if="linkedUser && linkedUser.email">
<b-link @click.stop="tunnelEmail">
{{ itemText }}
</b-link>
<span v-if="transactionLinkId">
{{ $t('via_link') }}
<b-icon
icon="link45deg"
variant="muted"
class="m-mb-1"
:title="$t('gdd_per_link.redeemed-title')"
/>
</span>
</div>
<span v-else>{{ itemText }}</span>
</div>
</b-col>
@ -35,6 +46,11 @@ export default {
type: String,
required: false,
},
transactionLinkId: {
type: Number,
required: false,
default: null,
},
},
methods: {
tunnelEmail() {

View File

@ -2,7 +2,9 @@
<div class="date-row">
<b-row>
<b-col cols="5">
<div class="text-right">{{ diffNow ? $t('gdd_per_link.expired') : $t('form.date') }}</div>
<div class="text-right">
{{ diffNow ? $t('gdd_per_link.valid_until') : $t('form.date') }}
</div>
</b-col>
<b-col cols="7">
<div class="gdd-transaction-list-item-date">

View File

@ -13,7 +13,12 @@
<b-col cols="11">
<!-- Amount / Name || Text -->
<amount-and-name-row :amount="amount" :linkedUser="linkedUser" v-on="$listeners" />
<amount-and-name-row
v-on="$listeners"
:amount="amount"
:linkedUser="linkedUser"
:transactionLinkId="transactionLinkId"
/>
<!-- Nachricht Memo -->
<memo-row :memo="memo" />
@ -86,6 +91,10 @@ export default {
type: Date,
required: true,
},
transactionLinkId: {
type: Number,
required: false,
},
},
data() {
return {

View File

@ -13,7 +13,12 @@
<b-col cols="11">
<!-- Amount / Name -->
<amount-and-name-row :amount="amount" :linkedUser="linkedUser" v-on="$listeners" />
<amount-and-name-row
v-on="$listeners"
:amount="amount"
:linkedUser="linkedUser"
:transactionLinkId="transactionLinkId"
/>
<!-- Memo -->
<memo-row :memo="memo" />
@ -87,6 +92,10 @@ export default {
type: Date,
required: true,
},
transactionLinkId: {
type: Number,
required: false,
},
},
data() {
return {

View File

@ -45,11 +45,16 @@ export const logout = gql`
export const transactionsQuery = gql`
query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
balanceGDT
count
linkCount
balance
decayStartBlock
balance {
balance
decay
lastBookedBalance
balanceGDT
count
linkCount
decayStartBlock
lastBookedDate
}
transactions {
id
typeId
@ -70,6 +75,7 @@ export const transactionsQuery = gql`
linkedUser {
email
}
transactionLinkId
}
}
}

View File

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

View File

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

View File

@ -100,7 +100,6 @@
"decay-14-day": "Vergänglichkeit für 14 Tage",
"delete-the-link": "Den Link löschen?",
"deleted": "Der Link wurde gelöscht!",
"expired": "Abgelaufen",
"has-account": "Du besitzt bereits ein Gradido Konto",
"header": "Gradidos versenden per Link",
"link-copied": "Link wurde in die Zwischenablage kopiert",
@ -116,8 +115,10 @@
"redeem-text": "Willst du den Betrag jetzt einlösen?",
"redeemed": "Erfolgreich eingelöst! Deinem Konto wurden {n} GDD gutgeschrieben.",
"redeemed-at": "Der Link wurde bereits am {date} eingelöst.",
"redeemed-title": "eingelöst",
"to-login": "Log dich ein",
"to-register": "Registriere ein neues Konto"
"to-register": "Registriere ein neues Konto",
"valid_until": "Gültig bis"
},
"gdt": {
"calculation": "Berechnung der GradidoTransform",
@ -249,5 +250,6 @@
},
"transaction-link": {
"send_you": "sendet dir"
}
},
"via_link": "über einen Link"
}

View File

@ -100,7 +100,6 @@
"decay-14-day": "Decay for 14 days",
"delete-the-link": "Delete the link?",
"deleted": "The link was deleted!",
"expired": "Expired",
"has-account": "You already have a Gradido account",
"header": "Send Gradidos via link",
"link-copied": "Link copied to clipboard",
@ -116,8 +115,10 @@
"redeem-text": "Do you want to redeem the amount now?",
"redeemed": "Successfully redeemed! Your account has been credited with {n} GDD.",
"redeemed-at": "The link was already redeemed on {date}.",
"redeemed-title": "redeemed",
"to-login": "Log in",
"to-register": "Register a new account"
"to-register": "Register a new account",
"valid_until": "Valid until"
},
"gdt": {
"calculation": "Calculation of GradidoTransform",
@ -249,5 +250,6 @@
},
"transaction-link": {
"send_you": "wants to send you"
}
},
"via_link": "via Link"
}

View File

@ -150,13 +150,18 @@ describe('ResetPassword', () => {
describe('server response with error code > 10min', () => {
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 flushPromises()
})
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', () => {

View File

@ -82,7 +82,11 @@ export default {
})
.catch((error) => {
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')
})
},

View File

@ -148,6 +148,7 @@ export default {
.then((result) => {
this.$emit('set-tunneled-email', null)
this.code = result.data.createTransactionLink.code
this.transactionData = { ...EMPTY_TRANSACTION_DATA }
this.currentTransactionStep = TRANSACTION_STEPS.transactionResultLink
this.updateTransactions({})
})