resolve conflicts

This commit is contained in:
ogerly 2022-11-25 13:17:17 +01:00
commit a89f809315
100 changed files with 3219 additions and 515 deletions

View File

@ -4,8 +4,44 @@ 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). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.14.1](https://github.com/gradido/gradido/compare/1.14.0...1.14.1)
- fix(frontend): load contributionMessages is fixed [`#2390`](https://github.com/gradido/gradido/pull/2390)
#### [1.14.0](https://github.com/gradido/gradido/compare/1.13.3...1.14.0)
> 14 November 2022
- chore(release): version 1.14.0 [`#2389`](https://github.com/gradido/gradido/pull/2389)
- fix(frontend): close all open collapse by change tabs in community [`#2388`](https://github.com/gradido/gradido/pull/2388)
- fix(backend): corrected E-Mail texts [`#2386`](https://github.com/gradido/gradido/pull/2386)
- fix(frontend): better history messages [`#2381`](https://github.com/gradido/gradido/pull/2381)
- fix(frontend): mailto link [`#2383`](https://github.com/gradido/gradido/pull/2383)
- fix(admin): fix text in admin area to uppercase [`#2365`](https://github.com/gradido/gradido/pull/2365)
- feat(frontend): move the information about gradido being free to the auth layout [`#2349`](https://github.com/gradido/gradido/pull/2349)
- fix(admin): load error fixed for contribution link [`#2364`](https://github.com/gradido/gradido/pull/2364)
- fix(admin): edit contribution link does not take old values [`#2362`](https://github.com/gradido/gradido/pull/2362)
- fix(other): corrected dockerfile descriptions [`#2346`](https://github.com/gradido/gradido/pull/2346)
- feat(backend): 🍰 Send email for rejected contributions [`#2340`](https://github.com/gradido/gradido/pull/2340)
- feat(admin): edit automatic contribution link [`#2309`](https://github.com/gradido/gradido/pull/2309)
- refactor(backend): fix logger mocks [`#2308`](https://github.com/gradido/gradido/pull/2308)
- fix(admin): update contribution list after admin updates contribution [`#2330`](https://github.com/gradido/gradido/pull/2330)
- fix(frontend): inconsistent labeling on login register [`#2350`](https://github.com/gradido/gradido/pull/2350)
- feat(backend): setup hyperswarm [`#1874`](https://github.com/gradido/gradido/pull/1874)
- feat(other): lint pull request workflow [`#2338`](https://github.com/gradido/gradido/pull/2338)
- Feature: 🍰 add updated at to contributions [`#2237`](https://github.com/gradido/gradido/pull/2237)
- Refactor: GitHub test workflow - disable video recording and reduce wait time [`#2336`](https://github.com/gradido/gradido/pull/2336)
- 2274 feature concept manuel user registration for admins [`#2289`](https://github.com/gradido/gradido/pull/2289)
- 1574 concept to introduce gradidoID and change password encryption [`#2252`](https://github.com/gradido/gradido/pull/2252)
- contributionlink stage-2 and stage-3 of capturing and activation [`#2241`](https://github.com/gradido/gradido/pull/2241)
- Github workflow: update actions to the current API version using Node v 16 [`#2323`](https://github.com/gradido/gradido/pull/2323)
- feature: Fullstack tests in GitHub workflow [`#2319`](https://github.com/gradido/gradido/pull/2319)
#### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3) #### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3)
> 1 November 2022
- release: Version 1.13.3 [`#2322`](https://github.com/gradido/gradido/pull/2322)
- 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312) - 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312)
- fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302) - fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302)
- fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320) - fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320)

View File

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

View File

@ -1,39 +0,0 @@
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic'
const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
}
const propsData = {
value: {
totalUsers: '123',
activeUsers: '100',
deletedUsers: '5',
totalGradidoCreated: '2500',
totalGradidoDecayed: '200',
totalGradidoAvailable: '500',
totalGradidoUnbookedDecayed: '111',
},
}
describe('CommunityStatistic', () => {
let wrapper
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
})
})
})

View File

@ -1,59 +0,0 @@
<template>
<div class="community-statistic">
<div>
<b-jumbotron bg-variant="info" text-variant="white" border-variant="dark">
<template #header>{{ $t('statistic.name') }}</template>
<hr class="my-4" />
<div>
{{ $t('statistic.totalUsers') }}{{ $t('math.colon') }}
<b>{{ value.totalUsers }}</b>
</div>
<div>
{{ $t('statistic.activeUsers') }}{{ $t('math.colon') }}
<b>{{ value.activeUsers }}</b>
</div>
<div>
{{ $t('statistic.deletedUsers') }}{{ $t('math.colon') }}
<b>{{ value.deletedUsers }}</b>
</div>
<div>
{{ $t('statistic.totalGradidoCreated') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoCreated }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoDecayed') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoDecayed }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoAvailable') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoAvailable }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoUnbookedDecayed') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoUnbookedDecayed }}</small>
</div>
</b-jumbotron>
</div>
</div>
</template>
<script>
import CONFIG from '@/config'
export default {
name: 'CommunityStatistic',
props: {
value: { type: Object },
},
data() {
return {
CONFIG,
}
},
}
</script>

View File

@ -34,6 +34,7 @@
:items="items" :items="items"
@editContributionLinkData="editContributionLinkData" @editContributionLinkData="editContributionLinkData"
@get-contribution-links="$emit('get-contribution-links')" @get-contribution-links="$emit('get-contribution-links')"
@closeContributionForm="closeContributionForm"
/> />
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div> <div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
</b-card-text> </b-card-text>

View File

@ -197,6 +197,7 @@ export default {
}, },
onReset() { onReset() {
this.$refs.contributionLinkForm.reset() this.$refs.contributionLinkForm.reset()
this.form = {}
this.form.validFrom = null this.form.validFrom = null
this.form.validTo = null this.form.validTo = null
}, },

View File

@ -108,6 +108,7 @@ export default {
}) })
.then(() => { .then(() => {
this.toastSuccess(this.$t('contributionLink.deleted')) this.toastSuccess(this.$t('contributionLink.deleted'))
this.$emit('closeContributionForm')
this.$emit('get-contribution-links') this.$emit('get-contribution-links')
}) })
.catch((err) => { .catch((err) => {

View File

@ -1,7 +1,15 @@
<template> <template>
<div class="mt-2"> <div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index"> <span v-for="({ type, text }, index) in parsedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link> <b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else-if="type === 'date'">
{{ $d(new Date(text), 'short') }}
<br />
</span>
<span v-else-if="type === 'amount'">
<br />
{{ `${$n(Number(text), 'decimal')} GDD` }}
</span>
<span v-else>{{ text }}</span> <span v-else>{{ text }}</span>
</span> </span>
</div> </div>
@ -12,17 +20,28 @@ const LINK_REGEX_PATTERN =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default { export default {
name: 'LinkifyMessage', name: 'ParseMessage',
props: { props: {
message: { message: {
type: String, type: String,
required: true, required: true,
}, },
type: {
type: String,
reuired: true,
},
}, },
computed: { computed: {
linkifiedMessage() { parsedMessage() {
const linkified = []
let string = this.message let string = this.message
const linkified = []
let amount
if (this.type === 'HISTORY') {
const split = string.split(/\n\s*---\n\s*/)
string = split[1]
linkified.push({ type: 'date', text: split[0].trim() })
amount = split[2].trim()
}
let match let match
while ((match = string.match(LINK_REGEX_PATTERN))) { while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0) if (match.index > 0)
@ -31,6 +50,7 @@ export default {
string = string.substring(match.index + match[0].length) string = string.substring(match.index + match[0].length)
} }
if (string.length > 0) linkified.push({ type: 'text', text: string }) if (string.length > 0) linkified.push({ type: 'text', text: string })
if (amount) linkified.push({ type: 'amount', text: amount })
return linkified return linkified
}, },
}, },

View File

@ -3,12 +3,16 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue const localVue = global.localVue
const dateMock = jest.fn((d) => d)
const numberMock = jest.fn((n) => n)
describe('ContributionMessagesListItem', () => { describe('ContributionMessagesListItem', () => {
let wrapper let wrapper
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: dateMock,
$n: numberMock,
} }
describe('if message author has moderator role', () => { describe('if message author has moderator role', () => {
@ -189,4 +193,64 @@ and here is the link to the repository: https://github.com/gradido/gradido`)
}) })
}) })
}) })
describe('contribution message type HISTORY', () => {
const propsData = {
message: {
id: 111,
message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)
---
This message also contains a link: https://gradido.net/de/
---
350.00`,
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'HISTORY',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const itemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('render HISTORY message', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = itemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)')
})
it('renders the date', () => {
expect(dateMock).toBeCalledWith(
new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'),
'short',
)
})
it('renders the amount', () => {
expect(numberMock).toBeCalledWith(350, 'decimal')
expect(messageField.text()).toContain('350 GDD')
})
it('contains the link as text', () => {
expect(messageField.text()).toContain(
'This message also contains a link: https://gradido.net/de/',
)
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
})
}) })

View File

@ -5,23 +5,23 @@
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('moderator') }}</small> <small class="ml-4 text-success">{{ $t('moderator') }}</small>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
<div v-else class="text-left is-not-moderator"> <div v-else class="text-left is-not-moderator">
<b-avatar variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue' import ParseMessage from '@/components/ContributionMessages/ParseMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: { components: {
LinkifyMessage, ParseMessage,
}, },
props: { props: {
message: { message: {

View File

@ -49,28 +49,36 @@ describe('NavBar', () => {
it('has a link to overview', () => { it('has a link to overview', () => {
expect(wrapper.findAll('.nav-item').at(0).find('a').attributes('href')).toBe('/') expect(wrapper.findAll('.nav-item').at(0).find('a').attributes('href')).toBe('/')
}) })
it('has a link to /user', () => { it('has a link to /user', () => {
expect(wrapper.findAll('.nav-item').at(1).find('a').attributes('href')).toBe('/user') expect(wrapper.findAll('.nav-item').at(1).find('a').attributes('href')).toBe('/user')
}) })
it('has a link to /creation', () => { it('has a link to /creation', () => {
expect(wrapper.findAll('.nav-item').at(2).find('a').attributes('href')).toBe('/creation') expect(wrapper.findAll('.nav-item').at(2).find('a').attributes('href')).toBe('/creation')
}) })
it('has a link to /creation-confirm', () => { it('has a link to /creation-confirm', () => {
expect(wrapper.findAll('.nav-item').at(3).find('a').attributes('href')).toBe( expect(wrapper.findAll('.nav-item').at(3).find('a').attributes('href')).toBe(
'/creation-confirm', '/creation-confirm',
) )
}) })
it('has a link to /contribution-links', () => { it('has a link to /contribution-links', () => {
expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe( expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe(
'/contribution-links', '/contribution-links',
) )
}) })
it('has a link to /statistic', () => {
expect(wrapper.findAll('.nav-item').at(5).find('a').attributes('href')).toBe('/statistic')
})
}) })
describe('wallet', () => { describe('wallet', () => {
const assignLocationSpy = jest.fn() const assignLocationSpy = jest.fn()
beforeEach(async () => { beforeEach(async () => {
await wrapper.findAll('.nav-item').at(5).find('a').trigger('click') await wrapper.findAll('.nav-item').at(6).find('a').trigger('click')
}) })
it.skip('changes window location to wallet', () => { it.skip('changes window location to wallet', () => {
@ -89,7 +97,7 @@ describe('NavBar', () => {
window.location = { window.location = {
assign: windowLocationMock, assign: windowLocationMock,
} }
await wrapper.findAll('.nav-item').at(6).find('a').trigger('click') await wrapper.findAll('.nav-item').at(7).find('a').trigger('click')
}) })
it('redirects to /logout', () => { it('redirects to /logout', () => {

View File

@ -22,6 +22,7 @@
<b-nav-item to="/contribution-links"> <b-nav-item to="/contribution-links">
{{ $t('navbar.automaticContributions') }} {{ $t('navbar.automaticContributions') }}
</b-nav-item> </b-nav-item>
<b-nav-item to="/statistic">{{ $t('navbar.statistic') }}</b-nav-item>
<b-nav-item @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item> <b-nav-item @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item>
<b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item> <b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item>
</b-navbar-nav> </b-navbar-nav>

View File

@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils'
import StatisticTable from './StatisticTable.vue'
const localVue = global.localVue
const propsData = {
value: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
$d: jest.fn((d) => d),
}
describe('StatisticTable', () => {
let wrapper
const Wrapper = () => {
return mount(StatisticTable, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class .statistic-table', () => {
expect(wrapper.find('div.statistic-table').exists()).toBe(true)
})
describe('renders the table', () => {
it('with three colunms', () => {
expect(wrapper.findAll('thead > tr > th')).toHaveLength(3)
})
it('with seven rows', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(7)
})
})
})
})

View File

@ -0,0 +1,84 @@
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="statistic-table">
<b-table-simple style="width: auto" class="mt-5" striped stacked="md">
<b-thead>
<b-tr>
<b-th></b-th>
<b-th class="text-right">{{ $t('statistic.count') }}</b-th>
<b-th class="text-right">{{ $t('statistic.details') }}</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.totalUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.activeUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.activeUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.deletedUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.deletedUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoCreated') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoCreated }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoDecayed') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoDecayed }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoAvailable') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoAvailable }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoUnbookedDecayed') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoUnbookedDecayed }}</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</div>
</template>
<script>
export default {
name: 'StatisticTable',
props: {
value: {
type: Object,
required: true,
},
},
}
</script>

View File

@ -85,7 +85,6 @@
"hide_details": "Details verbergen", "hide_details": "Details verbergen",
"lastname": "Nachname", "lastname": "Nachname",
"math": { "math": {
"colon": ":",
"equals": "=", "equals": "=",
"exclaim": "!", "exclaim": "!",
"pipe": "|", "pipe": "|",
@ -98,12 +97,13 @@
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.", "multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
"name": "Name", "name": "Name",
"navbar": { "navbar": {
"automaticContributions": "automatische Beiträge", "automaticContributions": "Automatische Beiträge",
"logout": "Abmelden", "logout": "Abmelden",
"multi_creation": "Mehrfachschöpfung", "multi_creation": "Mehrfachschöpfung",
"my-account": "Mein Konto", "my-account": "Mein Konto",
"open_creation": "Offene Schöpfungen", "open_creation": "Offene Schöpfungen",
"overview": "Übersicht", "overview": "Übersicht",
"statistic": "Statistik",
"user_search": "Nutzersuche" "user_search": "Nutzersuche"
}, },
"not_open_creations": "Keine offenen Schöpfungen", "not_open_creations": "Keine offenen Schöpfungen",
@ -125,8 +125,9 @@
"save": "Speichern", "save": "Speichern",
"statistic": { "statistic": {
"activeUsers": "Aktive Mitglieder", "activeUsers": "Aktive Mitglieder",
"count": "Menge",
"deletedUsers": "Gelöschte Mitglieder", "deletedUsers": "Gelöschte Mitglieder",
"name": "Statistik", "details": "Details",
"totalGradidoAvailable": "GDD insgesamt im Umlauf", "totalGradidoAvailable": "GDD insgesamt im Umlauf",
"totalGradidoCreated": "GDD insgesamt geschöpft", "totalGradidoCreated": "GDD insgesamt geschöpft",
"totalGradidoDecayed": "GDD insgesamt verfallen", "totalGradidoDecayed": "GDD insgesamt verfallen",

View File

@ -85,7 +85,6 @@
"hide_details": "Hide details", "hide_details": "Hide details",
"lastname": "Lastname", "lastname": "Lastname",
"math": { "math": {
"colon": ":",
"equals": "=", "equals": "=",
"exclaim": "!", "exclaim": "!",
"pipe": "|", "pipe": "|",
@ -104,6 +103,7 @@
"my-account": "My Account", "my-account": "My Account",
"open_creation": "Open creations", "open_creation": "Open creations",
"overview": "Overview", "overview": "Overview",
"statistic": "Statistic",
"user_search": "User search" "user_search": "User search"
}, },
"not_open_creations": "No open creations", "not_open_creations": "No open creations",
@ -125,8 +125,9 @@
"save": "Speichern", "save": "Speichern",
"statistic": { "statistic": {
"activeUsers": "Active members", "activeUsers": "Active members",
"count": "Count",
"deletedUsers": "Deleted members", "deletedUsers": "Deleted members",
"name": "Statistic", "details": "Details",
"totalGradidoAvailable": "Total GDD in circulation", "totalGradidoAvailable": "Total GDD in circulation",
"totalGradidoCreated": "Total created GDD", "totalGradidoCreated": "Total created GDD",
"totalGradidoDecayed": "Total GDD decay", "totalGradidoDecayed": "Total GDD decay",

View File

@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic.vue'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { toastErrorSpy } from '../../test/testSetup'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
const localVue = global.localVue
localVue.use(VueApollo)
const defaultData = () => {
return {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
}
describe('CommunityStatistic', () => {
let wrapper
const communityStatisticsMock = jest.fn()
mockClient.setRequestHandler(
communityStatistics,
communityStatisticsMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
})
describe('server response for get statistics is an error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
describe('server response for getting statistics is success', () => {
it('renders the data correctly', () => {
expect(wrapper.findAll('tr').at(1).findAll('td').at(1).text()).toEqual('3113')
expect(wrapper.findAll('tr').at(2).findAll('td').at(1).text()).toEqual('1057')
expect(wrapper.findAll('tr').at(3).findAll('td').at(1).text()).toEqual('35')
expect(wrapper.findAll('tr').at(4).findAll('td').at(1).text()).toEqual(
'4083774.05000000000000000000 GDD',
)
expect(wrapper.findAll('tr').at(4).findAll('td').at(2).text()).toEqual(
'4083774.05000000000000000000',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(1).text()).toEqual(
'-1062639.13634129622923372197 GDD',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(2).text()).toEqual(
'-1062639.13634129622923372197',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(1).text()).toEqual(
'2513565.869444365732411569 GDD',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(2).text()).toEqual(
'2513565.869444365732411569',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(1).text()).toEqual(
'-500474.6738366222166261272 GDD',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(2).text()).toEqual(
'-500474.6738366222166261272',
)
})
})
})
})

View File

@ -0,0 +1,42 @@
<template>
<div class="community-statistic">
<statistic-table v-model="statistics" />
</div>
</template>
<script>
import { communityStatistics } from '@/graphql/communityStatistics.js'
import StatisticTable from '../components/Tables/StatisticTable.vue'
export default {
name: 'CommunityStatistic',
components: {
StatisticTable,
},
data() {
return {
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
apollo: {
CommunityStatistics: {
query() {
return communityStatistics
},
update({ communityStatistics }) {
this.statistics = communityStatistics
},
error({ message }) {
this.toastError(message)
},
},
},
}
</script>

View File

@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import Overview from './Overview.vue' import Overview from './Overview.vue'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
const localVue = global.localVue const localVue = global.localVue
@ -22,19 +21,6 @@ const apolloQueryMock = jest
], ],
}, },
}) })
.mockResolvedValueOnce({
data: {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
},
})
.mockResolvedValue({ .mockResolvedValue({
data: { data: {
listUnconfirmedContributions: [ listUnconfirmedContributions: [
@ -88,14 +74,6 @@ describe('Overview', () => {
) )
}) })
it('calls communityStatistics', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: communityStatistics,
}),
)
})
it('commits three pending creations to store', () => { it('commits three pending creations to store', () => {
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3) expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
}) })

View File

@ -28,32 +28,13 @@
</b-link> </b-link>
</b-card-text> </b-card-text>
</b-card> </b-card>
<community-statistic class="mt-5" v-model="statistics" />
</div> </div>
</template> </template>
<script> <script>
import { communityStatistics } from '@/graphql/communityStatistics.js'
import CommunityStatistic from '../components/CommunityStatistic.vue'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
export default { export default {
name: 'overview', name: 'overview',
components: {
CommunityStatistic,
},
data() {
return {
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
methods: { methods: {
getPendingCreations() { getPendingCreations() {
this.$apollo this.$apollo
@ -65,30 +46,9 @@ export default {
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length) this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
}) })
}, },
getCommunityStatistics() {
this.$apollo
.query({
query: communityStatistics,
})
.then((result) => {
this.statistics.totalUsers = result.data.communityStatistics.totalUsers
this.statistics.activeUsers = result.data.communityStatistics.activeUsers
this.statistics.deletedUsers = result.data.communityStatistics.deletedUsers
this.statistics.totalGradidoCreated = result.data.communityStatistics.totalGradidoCreated
this.statistics.totalGradidoDecayed = result.data.communityStatistics.totalGradidoDecayed
this.statistics.totalGradidoAvailable =
result.data.communityStatistics.totalGradidoAvailable
this.statistics.totalGradidoUnbookedDecayed =
result.data.communityStatistics.totalGradidoUnbookedDecayed
})
.catch(() => {
this.toastError('communityStatistics has no result, use default data')
})
},
}, },
created() { created() {
this.getPendingCreations() this.getPendingCreations()
this.getCommunityStatistics()
}, },
} }
</script> </script>

View File

@ -10,7 +10,7 @@ const authLink = new ApolloLink((operation, forward) => {
operation.setContext({ operation.setContext({
headers: { headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '', Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
clientRequestTime: new Date().toString(), clientTimezoneOffset: new Date().getTimezoneOffset(),
}, },
}) })
return forward(operation).map((response) => { return forward(operation).map((response) => {

View File

@ -94,7 +94,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: 'Bearer some-token', Authorization: 'Bearer some-token',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })
@ -110,7 +110,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: '', Authorization: '',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })

View File

@ -44,8 +44,8 @@ describe('router', () => {
}) })
describe('routes', () => { describe('routes', () => {
it('has seven routes defined', () => { it('has nine routes defined', () => {
expect(routes).toHaveLength(8) expect(routes).toHaveLength(9)
}) })
it('has "/overview" as default', async () => { it('has "/overview" as default', async () => {
@ -82,12 +82,19 @@ describe('router', () => {
}) })
describe('contribution-links', () => { describe('contribution-links', () => {
it('loads the "ContributionLinks" component', async () => { it('loads the "ContributionLinks" page', async () => {
const component = await routes.find((r) => r.path === '/contribution-links').component() const component = await routes.find((r) => r.path === '/contribution-links').component()
expect(component.default.name).toBe('ContributionLinks') expect(component.default.name).toBe('ContributionLinks')
}) })
}) })
describe('statistics', () => {
it('loads the "CommunityStatistic" page', async () => {
const component = await routes.find((r) => r.path === '/statistic').component()
expect(component.default.name).toBe('CommunityStatistic')
})
})
describe('not found page', () => { describe('not found page', () => {
it('renders the "NotFound" component', async () => { it('renders the "NotFound" component', async () => {
const component = await routes.find((r) => r.path === '*').component() const component = await routes.find((r) => r.path === '*').component()

View File

@ -6,6 +6,10 @@ const routes = [
path: '/', path: '/',
component: () => import('@/pages/Overview.vue'), component: () => import('@/pages/Overview.vue'),
}, },
{
path: '/statistic',
component: () => import('@/pages/CommunityStatistic.vue'),
},
{ {
// TODO: Implement a "You are logged out"-Page // TODO: Implement a "You are logged out"-Page
path: '/logout', path: '/logout',

View File

@ -1,7 +1,7 @@
################################################################################## ##################################################################################
# BASE ########################################################################### # BASE ###########################################################################
################################################################################## ##################################################################################
FROM node:12.19.0-alpine3.10 as base FROM node:18.7.0-alpine3.16 as base
# ENVs (available in production aswell, can be overwritten by commandline or env file) # ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-backend", "name": "gradido-backend",
"version": "1.13.3", "version": "1.14.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions", "description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend", "repository": "https://github.com/gradido/gradido/backend",
@ -19,6 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@hyperswarm/dht": "^6.2.0", "@hyperswarm/dht": "^6.2.0",
"@types/email-templates": "^10.0.1",
"@types/i18n": "^0.13.4",
"@types/jest": "^27.0.2", "@types/jest": "^27.0.2",
"@types/lodash.clonedeep": "^4.5.6", "@types/lodash.clonedeep": "^4.5.6",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
@ -30,14 +32,17 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"decimal.js-light": "^2.5.1", "decimal.js-light": "^2.5.1",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"email-templates": "^10.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"graphql": "^15.5.1", "graphql": "^15.5.1",
"i18n": "^0.15.1",
"jest": "^27.2.4", "jest": "^27.2.4",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"log4js": "^6.4.6", "log4js": "^6.4.6",
"mysql2": "^2.3.0", "mysql2": "^2.3.0",
"nodemailer": "^6.6.5", "nodemailer": "^6.6.5",
"pug": "^3.0.2",
"random-bigint": "^0.0.1", "random-bigint": "^0.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0", "sodium-native": "^3.3.0",

View File

@ -10,7 +10,7 @@ Decimal.set({
}) })
const constants = { const constants = {
DB_VERSION: '0052-add_updated_at_to_contributions', DB_VERSION: '0053-change_password_encryption',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json', LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info // default log level on production should be info

View File

@ -0,0 +1,50 @@
# Using `forwardemail``email-templates` With `pug` Package
You'll find the GitHub repository of the `email-templates` package and the `pug` package here:
- [email-templates](https://github.com/forwardemail/email-templates)
- [pug](https://www.npmjs.com/package/pug)
## `pug` Documentation
The full `pug` documentation you'll find here:
- [pugjs.org](https://pugjs.org/)
### Caching Possibility
In case we are sending many emails in the future there is the possibility to cache the `pug` templates:
- [cache-pug-templates](https://github.com/ladjs/cache-pug-templates)
## Testing
To test your send emails you have different possibilities:
### In General
To send emails to yourself while developing set in `.env` the value `EMAIL_TEST_MODUS=true` and `EMAIL_TEST_RECEIVER` to your preferred email address.
### Unit Or Integration Tests
To change the behavior to show previews etc. you have the following options to be set in `sendEmailTranslated.ts` on creating the email object:
```js
const email = new Email({
// send emails in development/test env:
send: true,
// to open send emails in the browser
preview: true,
// or
// to open send emails in a specific the browser
preview: {
open: {
app: 'firefox',
wait: false,
},
},
})
```

View File

@ -0,0 +1,22 @@
doctype html
html(lang=locale)
head
title= t('emails.accountMultiRegistration.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.helloName', { firstName, lastName })
p= t('emails.accountMultiRegistration.emailReused')
br
span= t('emails.accountMultiRegistration.emailExists')
p= t('emails.accountMultiRegistration.onForgottenPasswordClickLink')
br
a(href=resendLink) #{resendLink}
br
span= t('emails.accountMultiRegistration.onForgottenPasswordCopyLink')
p= t('emails.accountMultiRegistration.ifYouAreNotTheOne')
br
a(href='https://gradido.net/de/contact/') https://gradido.net/de/contact/
p(style='margin-top: 24px;')= t('emails.accountMultiRegistration.sincerelyYours')
br
span= t('emails.accountMultiRegistration.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.accountMultiRegistration.subject')

View File

@ -0,0 +1,110 @@
import { createTransport } from 'nodemailer'
import { logger, i18n } from '@test/testSetup'
import CONFIG from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
CONFIG.EMAIL = false
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = '1234'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
jest.mock('nodemailer', () => {
return {
__esModule: true,
createTransport: jest.fn(() => {
return {
sendMail: jest.fn(() => {
return {
messageId: 'message',
}
}),
}
}),
}
})
describe('sendEmailTranslated', () => {
let result: Record<string, unknown> | null
describe('config email is false', () => {
beforeEach(async () => {
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
it('logs warning', () => {
expect(logger.info).toBeCalledWith('Emails are disabled via config...')
})
it('returns false', () => {
expect(result).toBeFalsy()
})
})
describe('config email is true', () => {
beforeEach(async () => {
CONFIG.EMAIL = true
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
it('calls the transporter', () => {
expect(createTransport).toBeCalledWith({
host: 'EMAIL_SMTP_URL',
port: 1234,
secure: false,
requireTLS: true,
auth: {
user: 'user',
pass: 'pwd',
},
})
})
describe('call of "sendEmailTranslated"', () => {
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['receiver@mail.org', 'support@gradido.net'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
from: 'Gradido (nicht antworten) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html: expect.stringContaining('Gradido: Try To Register Again With Your Email'),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
})
})
it.skip('calls "i18n.setLocale" with "en"', () => {
expect(i18n.setLocale).toBeCalledWith('en')
})
it.skip('calls "i18n.__" for translation', () => {
expect(i18n.__).toBeCalled()
})
})
})

View File

@ -0,0 +1,85 @@
import { backendLogger as logger } from '@/server/logger'
import path from 'path'
import { createTransport } from 'nodemailer'
import Email from 'email-templates'
import i18n from 'i18n'
import CONFIG from '@/config'
export const sendEmailTranslated = async (params: {
receiver: {
to: string
cc?: string
}
template: string
locals: Record<string, string>
}): Promise<Record<string, unknown> | null> => {
let resultSend: Record<string, unknown> | null = null
// TODO: test the calling order of 'i18n.setLocale' for example: language of logging 'en', language of email receiver 'es', reset language of current user 'de'
// because language of receiver can differ from language of current user who triggers the sending
const rememberLocaleToRestore = i18n.getLocale()
i18n.setLocale('en') // for logging
logger.info(
`send Email: language=${params.locals.locale} to=${params.receiver.to}` +
(params.receiver.cc ? `, cc=${params.receiver.cc}` : '') +
`, subject=${i18n.__('emails.' + params.template + '.subject')}`,
)
if (!CONFIG.EMAIL) {
logger.info(`Emails are disabled via config...`)
return null
}
// because 'CONFIG.EMAIL_TEST_MODUS' can be boolean 'true' or string '`false`'
if (CONFIG.EMAIL_TEST_MODUS === true) {
logger.info(
`Testmodus=ON: change receiver from ${params.receiver.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
)
params.receiver.to = CONFIG.EMAIL_TEST_RECEIVER
}
const transport = createTransport({
host: CONFIG.EMAIL_SMTP_URL,
port: Number(CONFIG.EMAIL_SMTP_PORT),
secure: false, // true for 465, false for other ports
requireTLS: true,
auth: {
user: CONFIG.EMAIL_USERNAME,
pass: CONFIG.EMAIL_PASSWORD,
},
})
i18n.setLocale(params.locals.locale) // for email
// TESTING: see 'README.md'
const email = new Email({
message: {
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
},
transport,
preview: false,
// i18n, // is only needed if you don't install i18n
})
// ATTENTION: await is needed, because otherwise on send the email gets send in the language of the current user, because below the language gets reset
await email
.send({
template: path.join(__dirname, params.template),
message: params.receiver,
locals: params.locals, // the 'locale' in here seems not to be used by 'email-template', because it doesn't work if the language isn't set before by 'i18n.setLocale'
})
.then((result: Record<string, unknown>) => {
resultSend = result
logger.info('Send email successfully !!!')
logger.info('Result: ', result)
})
.catch((error: unknown) => {
logger.error('Error sending notification email: ', error)
throw new Error('Error sending notification email!')
})
i18n.setLocale(rememberLocaleToRestore)
return resultSend
}

View File

@ -0,0 +1,88 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendEmailVariants'
import { sendEmailTranslated } from './sendEmailTranslated'
CONFIG.EMAIL = true
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = '1234'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
jest.mock('./sendEmailTranslated', () => {
const originalModule = jest.requireActual('./sendEmailTranslated')
return {
__esModule: true,
sendEmailTranslated: jest.fn((a) => originalModule.sendEmailTranslated(a)),
}
})
describe('sendEmailVariants', () => {
let result: Record<string, unknown> | null
describe('sendAccountMultiRegistrationEmail', () => {
beforeAll(async () => {
result = await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'accountMultiRegistration',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (nicht antworten) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html:
expect.stringContaining(
'<title>Gradido: Try To Register Again With Your Email</title>',
) &&
expect.stringContaining('>Gradido: Try To Register Again With Your Email</h1>') &&
expect.stringContaining(
'Your email address has just been used again to register an account with Gradido.',
) &&
expect.stringContaining(
'However, an account already exists for your email address.',
) &&
expect.stringContaining(
'Please click on the following link if you have forgotten your password:',
) &&
expect.stringContaining(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
) &&
expect.stringContaining('or copy the link above into your browser window.') &&
expect.stringContaining(
'If you are not the one who tried to register again, please contact our support:',
) &&
expect.stringContaining('Sincerely yours,<br><span>your Gradido team'),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
})
})
})
})

View File

@ -0,0 +1,20 @@
import CONFIG from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
email: string
language: string
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'accountMultiRegistration',
locals: {
locale: data.language,
firstName: data.firstName,
lastName: data.lastName,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
}

View File

@ -0,0 +1,12 @@
import { registerEnumType } from 'type-graphql'
export enum PasswordEncryptionType {
NO_PASSWORD = 0,
EMAIL = 1,
GRADIDO_ID = 2,
}
registerEnumType(PasswordEncryptionType, {
name: 'PasswordEncryptionType', // this one is mandatory
description: 'Type of the password encryption', // this one is optional
})

View File

@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { objectValuesToArray } from '@/util/utilities' import { objectValuesToArray } from '@/util/utilities'
import { testEnvironment, resetToken, cleanDB } from '@test/helpers' import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
import { creationFactory } from '@/seeds/factory/creation' import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index' import { creations } from '@/seeds/creation/index'
@ -83,6 +83,12 @@ let user: User
let creation: Contribution | void let creation: Contribution | void
let result: any let result: any
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})
describe('AdminResolver', () => { describe('AdminResolver', () => {
describe('set user role', () => { describe('set user role', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
@ -751,7 +757,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -861,7 +867,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -936,19 +942,25 @@ describe('AdminResolver', () => {
}) })
describe('adminCreateContribution', () => { describe('adminCreateContribution', () => {
beforeAll(async () => {
const now = new Date() const now = new Date()
beforeAll(async () => {
creation = await creationFactory(testEnv, { creation = await creationFactory(testEnv, {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 400, amount: 400,
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Herzlich Willkommen bei Gradido!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
}) })
}) })
describe('user to create for does not exist', () => { describe('user to create for does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -969,6 +981,9 @@ describe('AdminResolver', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, stephenHawking) user = await userFactory(testEnv, stephenHawking)
variables.email = 'stephen@hawking.uk' variables.email = 'stephen@hawking.uk'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
}) })
it('throws an error', async () => { it('throws an error', async () => {
@ -995,6 +1010,9 @@ describe('AdminResolver', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, garrickOllivander) user = await userFactory(testEnv, garrickOllivander)
variables.email = 'garrick@ollivander.com' variables.email = 'garrick@ollivander.com'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
}) })
it('throws an error', async () => { it('throws an error', async () => {
@ -1021,6 +1039,7 @@ describe('AdminResolver', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg) user = await userFactory(testEnv, bibiBloxberg)
variables.email = 'bibi@bloxberg.de' variables.email = 'bibi@bloxberg.de'
variables.creationDate = 'invalid-date'
}) })
describe('date of creation is not a date string', () => { describe('date of creation is not a date string', () => {
@ -1030,30 +1049,22 @@ describe('AdminResolver', () => {
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)],
new GraphQLError('No information for available creations for the given date'),
],
}), }),
) )
}) })
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`)
'No information for available creations with the given creationDate=',
'Invalid Date',
)
}) })
}) })
describe('date of creation is four months ago', () => { describe('date of creation is four months ago', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const now = new Date() variables.creationDate = contributionDateFormatter(
variables.creationDate = new Date( new Date(now.getFullYear(), now.getMonth() - 4, 1),
now.getFullYear(), )
now.getMonth() - 4,
1,
).toString()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1068,7 +1079,7 @@ describe('AdminResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations with the given creationDate=',
variables.creationDate, new Date(variables.creationDate).toString(),
) )
}) })
}) })
@ -1076,12 +1087,9 @@ describe('AdminResolver', () => {
describe('date of creation is in the future', () => { describe('date of creation is in the future', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const now = new Date() variables.creationDate = contributionDateFormatter(
variables.creationDate = new Date( new Date(now.getFullYear(), now.getMonth() + 4, 1),
now.getFullYear(), )
now.getMonth() + 4,
1,
).toString()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1096,7 +1104,7 @@ describe('AdminResolver', () => {
it('logs the error thrown', () => { it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations with the given creationDate=',
variables.creationDate, new Date(variables.creationDate).toString(),
) )
}) })
}) })
@ -1104,7 +1112,7 @@ describe('AdminResolver', () => {
describe('amount of creation is too high', () => { describe('amount of creation is too high', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
variables.creationDate = new Date().toString() variables.creationDate = contributionDateFormatter(now)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1192,7 +1200,7 @@ describe('AdminResolver', () => {
email, email,
amount: new Decimal(500), amount: new Decimal(500),
memo: 'Grundeinkommen', memo: 'Grundeinkommen',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
} }
}) })
@ -1238,7 +1246,7 @@ describe('AdminResolver', () => {
email: 'bob@baumeister.de', email: 'bob@baumeister.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1268,7 +1276,7 @@ describe('AdminResolver', () => {
email: 'stephen@hawking.uk', email: 'stephen@hawking.uk',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1294,7 +1302,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1321,8 +1329,8 @@ describe('AdminResolver', () => {
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1356,8 +1364,8 @@ describe('AdminResolver', () => {
amount: new Decimal(1900), amount: new Decimal(1900),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1390,8 +1398,8 @@ describe('AdminResolver', () => {
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1430,8 +1438,8 @@ describe('AdminResolver', () => {
amount: new Decimal(200), amount: new Decimal(200),
memo: 'Das war leider zu Viel!', memo: 'Das war leider zu Viel!',
creationDate: creation creationDate: creation
? creation.contributionDate.toString() ? contributionDateFormatter(creation.contributionDate)
: new Date().toString(), : contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1554,7 +1562,7 @@ describe('AdminResolver', () => {
variables: { variables: {
amount: 100.0, amount: 100.0,
memo: 'Test env contribution', memo: 'Test env contribution',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}) })
}) })
@ -1633,7 +1641,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 400, amount: 400,
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Herzlich Willkommen bei Gradido!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
}) })
}) })
@ -1664,7 +1674,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 450, amount: 450,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
}) })
@ -1735,13 +1747,17 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 50, amount: 50,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
c2 = await creationFactory(testEnv, { c2 = await creationFactory(testEnv, {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 50, amount: 50,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
}) })

View File

@ -1,4 +1,4 @@
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql'
import { import {
@ -49,6 +49,7 @@ import {
validateContribution, validateContribution,
isStartEndDateValid, isStartEndDateValid,
updateCreations, updateCreations,
isValidDateString,
} from './util/creations' } from './util/creations'
import { import {
CONTRIBUTIONLINK_NAME_MAX_CHARS, CONTRIBUTIONLINK_NAME_MAX_CHARS,
@ -86,7 +87,9 @@ export class AdminResolver {
async searchUsers( async searchUsers(
@Args() @Args()
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
@Ctx() context: Context,
): Promise<SearchUsersResult> { ): Promise<SearchUsersResult> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
const userFields = [ const userFields = [
'id', 'id',
@ -114,7 +117,10 @@ export class AdminResolver {
} }
} }
const creations = await getUserCreations(users.map((u) => u.id)) const creations = await getUserCreations(
users.map((u) => u.id),
clientTimezoneOffset,
)
const adminUsers = await Promise.all( const adminUsers = await Promise.all(
users.map(async (user) => { users.map(async (user) => {
@ -237,6 +243,11 @@ export class AdminResolver {
logger.info( logger.info(
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
) )
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (!isValidDateString(creationDate)) {
logger.error(`invalid Date for creationDate=${creationDate}`)
throw new Error(`invalid Date for creationDate=${creationDate}`)
}
const emailContact = await UserContact.findOne({ const emailContact = await UserContact.findOne({
where: { email }, where: { email },
withDeleted: true, withDeleted: true,
@ -262,11 +273,11 @@ export class AdminResolver {
const event = new Event() const event = new Event()
const moderator = getUser(context) const moderator = getUser(context)
logger.trace('moderator: ', moderator.id) logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId) const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset)
logger.trace('creations:', creations) logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj) logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = DbContribution.create() const contribution = DbContribution.create()
contribution.userId = emailContact.userId contribution.userId = emailContact.userId
contribution.amount = amount contribution.amount = amount
@ -289,7 +300,7 @@ export class AdminResolver {
event.setEventAdminContributionCreate(eventAdminCreateContribution), event.setEventAdminContributionCreate(eventAdminCreateContribution),
) )
return getUserCreation(emailContact.userId) return getUserCreation(emailContact.userId, clientTimezoneOffset)
} }
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@ -325,6 +336,7 @@ export class AdminResolver {
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<AdminUpdateContribution> { ): Promise<AdminUpdateContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const emailContact = await UserContact.findOne({ const emailContact = await UserContact.findOne({
where: { email }, where: { email },
withDeleted: true, withDeleted: true,
@ -365,17 +377,17 @@ export class AdminResolver {
} }
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else { } else {
logger.error('Currently the month of the contribution cannot change.') logger.error('Currently the month of the contribution cannot change.')
throw new Error('Currently the month of the contribution cannot change.') throw new Error('Currently the month of the contribution cannot change.')
} }
// all possible cases not to be true are thrown in this function // all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
contributionToUpdate.amount = amount contributionToUpdate.amount = amount
contributionToUpdate.memo = memo contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionDate = new Date(creationDate)
@ -389,7 +401,7 @@ export class AdminResolver {
result.memo = contributionToUpdate.memo result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate result.date = contributionToUpdate.contributionDate
result.creation = await getUserCreation(user.id) result.creation = await getUserCreation(user.id, clientTimezoneOffset)
const event = new Event() const event = new Event()
const eventAdminContributionUpdate = new EventAdminContributionUpdate() const eventAdminContributionUpdate = new EventAdminContributionUpdate()
@ -405,7 +417,8 @@ export class AdminResolver {
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution]) @Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> { async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contributions = await getConnection() const contributions = await getConnection()
.createQueryBuilder() .createQueryBuilder()
.select('c') .select('c')
@ -419,7 +432,7 @@ export class AdminResolver {
} }
const userIds = contributions.map((p) => p.userId) const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds) const userCreations = await getUserCreations(userIds, clientTimezoneOffset)
const users = await dbUser.find({ const users = await dbUser.find({
where: { id: In(userIds) }, where: { id: In(userIds) },
withDeleted: true, withDeleted: true,
@ -493,6 +506,7 @@ export class AdminResolver {
@Arg('id', () => Int) id: number, @Arg('id', () => Int) id: number,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contribution = await DbContribution.findOne(id) const contribution = await DbContribution.findOne(id)
if (!contribution) { if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`) logger.error(`Contribution not found for given id: ${id}`)
@ -511,8 +525,13 @@ export class AdminResolver {
logger.error('This user was deleted. Cannot confirm a contribution.') logger.error('This user was deleted. Cannot confirm a contribution.')
throw new Error('This user was deleted. Cannot confirm a contribution.') throw new Error('This user was deleted. Cannot confirm a contribution.')
} }
const creations = await getUserCreation(contribution.userId, false) const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false)
validateContribution(creations, contribution.amount, contribution.contributionDate) validateContribution(
creations,
contribution.amount,
contribution.contributionDate,
clientTimezoneOffset,
)
const receivedCallDate = new Date() const receivedCallDate = new Date()

View File

@ -1,5 +1,5 @@
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Contribution as dbContribution } from '@entity/Contribution' import { Contribution as dbContribution } from '@entity/Contribution'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
@ -31,6 +31,7 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs, @Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (memo.length > MEMO_MAX_CHARS) { if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
@ -44,10 +45,10 @@ export class ContributionResolver {
const event = new Event() const event = new Event()
const user = getUser(context) const user = getUser(context)
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.trace('creations', creations) logger.trace('creations', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = dbContribution.create() const contribution = dbContribution.create()
contribution.userId = user.id contribution.userId = user.id
@ -171,6 +172,7 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs, @Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (memo.length > MEMO_MAX_CHARS) { if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
@ -206,16 +208,16 @@ export class ContributionResolver {
) )
} }
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else { } else {
logger.error('Currently the month of the contribution cannot change.') logger.error('Currently the month of the contribution cannot change.')
throw new Error('Currently the month of the contribution cannot change.') throw new Error('Currently the month of the contribution cannot change.')
} }
// all possible cases not to be true are thrown in this function // all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contributionMessage = ContributionMessage.create() const contributionMessage = ContributionMessage.create()
contributionMessage.contributionId = contributionId contributionMessage.contributionId = contributionId

View File

@ -1,5 +1,5 @@
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { getConnection } from '@dbTools/typeorm' import { getConnection } from '@dbTools/typeorm'
import { import {
Resolver, Resolver,
@ -169,6 +169,7 @@ export class TransactionLinkResolver {
@Arg('code', () => String) code: string, @Arg('code', () => String) code: string,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const user = getUser(context) const user = getUser(context)
const now = new Date() const now = new Date()
@ -258,9 +259,9 @@ export class TransactionLinkResolver {
} }
} }
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.info('open creations', creations) logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now) validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
const contribution = new DbContribution() const contribution = new DbContribution()
contribution.userId = user.id contribution.userId = user.id
contribution.createdAt = now contribution.createdAt = now

View File

@ -19,7 +19,7 @@ import { GraphQLError } from 'graphql'
import { User } from '@entity/User' import { User } from '@entity/User'
import CONFIG from '@/config' import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver' import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
@ -29,13 +29,16 @@ import { TransactionLink } from '@entity/TransactionLink'
import { EventProtocolType } from '@/event/EventProtocolType' import { EventProtocolType } from '@/event/EventProtocolType'
import { EventProtocol } from '@entity/EventProtocol' import { EventProtocol } from '@entity/EventProtocol'
import { logger } from '@test/testSetup' import { logger, i18n as localization } from '@test/testSetup'
import { validate as validateUUID, version as versionUUID } from 'uuid' import { validate as validateUUID, version as versionUUID } from 'uuid'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { UserContact } from '@entity/UserContact' import { UserContact } from '@entity/UserContact'
import { OptInType } from '../enum/OptInType' import { OptInType } from '../enum/OptInType'
import { UserContactType } from '../enum/UserContactType' import { UserContactType } from '../enum/UserContactType'
import { bobBaumeister } from '@/seeds/users/bob-baumeister' import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { encryptPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
// import { klicktippSignIn } from '@/apis/KlicktippController' // import { klicktippSignIn } from '@/apis/KlicktippController'
@ -46,7 +49,7 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
} }
}) })
jest.mock('@/mailer/sendAccountMultiRegistrationEmail', () => { jest.mock('@/emails/sendEmailVariants', () => {
return { return {
__esModule: true, __esModule: true,
sendAccountMultiRegistrationEmail: jest.fn(), sendAccountMultiRegistrationEmail: jest.fn(),
@ -73,7 +76,7 @@ let mutate: any, query: any, con: any
let testEnv: any let testEnv: any
beforeAll(async () => { beforeAll(async () => {
testEnv = await testEnvironment(logger) testEnv = await testEnvironment(logger, localization)
mutate = testEnv.mutate mutate = testEnv.mutate
query = testEnv.query query = testEnv.query
con = testEnv.con con = testEnv.con
@ -146,6 +149,7 @@ describe('UserResolver', () => {
publisherId: 1234, publisherId: 1234,
referrerId: null, referrerId: null,
contributionLinkId: null, contributionLinkId: null,
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
}, },
]) ])
const valUUID = validateUUID(user[0].gradidoID) const valUUID = validateUUID(user[0].gradidoID)
@ -213,6 +217,7 @@ describe('UserResolver', () => {
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
language: 'de',
}) })
}) })
@ -490,7 +495,8 @@ describe('UserResolver', () => {
}) })
it('updates the password', () => { it('updates the password', () => {
expect(newUser.password).toEqual('3917921995996627700') const encryptedPass = encryptPassword(newUser, 'Aa12345_')
expect(newUser.password.toString()).toEqual(encryptedPass.toString())
}) })
/* /*
@ -1158,6 +1164,93 @@ describe('UserResolver', () => {
}) })
}) })
}) })
describe('password encryption type', () => {
describe('user just registered', () => {
let bibi: User
it('has password type gradido id', async () => {
const users = await User.find()
bibi = users[1]
expect(bibi).toEqual(
expect.objectContaining({
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
})
})
describe('user has encryption type email', () => {
const variables = {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
publisherId: 1234,
}
let bibi: User
beforeAll(async () => {
const usercontact = await UserContact.findOneOrFail(
{ email: 'bibi@bloxberg.de' },
{ relations: ['user'] },
)
bibi = usercontact.user
bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL
bibi.password = SecretKeyCryptographyCreateKey(
'bibi@bloxberg.de',
'Aa12345_',
)[0].readBigUInt64LE()
await bibi.save()
})
it('changes to gradidoID on login', async () => {
await mutate({ mutation: login, variables: variables })
const usercontact = await UserContact.findOneOrFail(
{ email: 'bibi@bloxberg.de' },
{ relations: ['user'] },
)
bibi = usercontact.user
expect(bibi).toEqual(
expect.objectContaining({
firstName: 'Bibi',
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
})
it('can login after password change', async () => {
resetToken()
expect(await mutate({ mutation: login, variables: variables })).toEqual(
expect.objectContaining({
data: {
login: {
email: 'bibi@bloxberg.de',
firstName: 'Bibi',
hasElopage: false,
id: expect.any(Number),
isAdmin: null,
klickTipp: {
newsletterState: false,
},
language: 'de',
lastName: 'Bloxberg',
publisherId: 1234,
},
},
}),
)
})
})
})
}) })
describe('printTimeDuration', () => { describe('printTimeDuration', () => {

View File

@ -1,6 +1,7 @@
import fs from 'fs' import fs from 'fs'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' import i18n from 'i18n'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config' import CONFIG from '@/config'
@ -18,7 +19,7 @@ import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddle
import { OptInType } from '@enum/OptInType' import { OptInType } from '@enum/OptInType'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { klicktippSignIn } from '@/apis/KlicktippController' import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys' import { hasElopageBuys } from '@/util/hasElopageBuys'
@ -39,17 +40,15 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated' import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native') const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const random = require('random-bigint') const random = require('random-bigint')
// We will reuse this for changePassword
const isPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl'] const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
const DEFAULT_LANGUAGE = 'de' const DEFAULT_LANGUAGE = 'de'
const isLanguage = (language: string): boolean => { const isLanguage = (language: string): boolean => {
@ -106,48 +105,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
return [pubKey, privKey] return [pubKey, privKey]
} }
const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
throw new Error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
sodium.crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
logger.debug(
`SecretKeyCryptographyCreateKey...successful: encryptionKeyHash= ${encryptionKeyHash}, encryptionKey= ${encryptionKey}`,
)
return [encryptionKeyHash, encryptionKey]
}
/* /*
const getEmailHash = (email: string): Buffer => { const getEmailHash = (email: string): Buffer => {
logger.trace('getEmailHash...') logger.trace('getEmailHash...')
@ -305,8 +262,9 @@ export class UserResolver {
async verifyLogin(@Ctx() context: Context): Promise<User> { async verifyLogin(@Ctx() context: Context): Promise<User> {
logger.info('verifyLogin...') logger.info('verifyLogin...')
// TODO refactor and do not have duplicate code with login(see below) // TODO refactor and do not have duplicate code with login(see below)
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userEntity = getUser(context) const userEntity = getUser(context)
const user = new User(userEntity, await getUserCreation(userEntity.id)) const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset))
// user.pubkey = userEntity.pubKey.toString('hex') // user.pubkey = userEntity.pubKey.toString('hex')
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context) user.hasElopage = await this.hasElopage(context)
@ -323,6 +281,7 @@ export class UserResolver {
@Ctx() context: Context, @Ctx() context: Context,
): Promise<User> { ): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`) logger.info(`login with ${email}, ***, ${publisherId} ...`)
const clientTimezoneOffset = getClientTimezoneOffset(context)
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email) const dbUser = await findUserByEmail(email)
if (dbUser.deletedAt) { if (dbUser.deletedAt) {
@ -343,19 +302,26 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code // TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new Error('User has no private or publicKey') throw new Error('User has no private or publicKey')
} }
const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
const loginUserPassword = BigInt(dbUser.password.toString()) if (!verifyPassword(dbUser, password)) {
if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
logger.error('The User has no valid credentials.') logger.error('The User has no valid credentials.')
throw new Error('No user with this credentials') throw new Error('No user with this credentials')
} }
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
dbUser.password = encryptPassword(dbUser, password)
await dbUser.save()
}
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message // add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
logger.addContext('user', dbUser.id) logger.addContext('user', dbUser.id)
logger.debug('validation of login credentials successful...') logger.debug('validation of login credentials successful...')
const user = new User(dbUser, await getUserCreation(dbUser.id)) const user = new User(dbUser, await getUserCreation(dbUser.id, clientTimezoneOffset))
logger.debug(`user= ${JSON.stringify(user, null, 2)}`) logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
i18n.setLocale(user.language)
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
logger.info('user.hasElopage=' + user.hasElopage) logger.info('user.hasElopage=' + user.hasElopage)
@ -408,6 +374,7 @@ export class UserResolver {
if (!language || !isLanguage(language)) { if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE language = DEFAULT_LANGUAGE
} }
i18n.setLocale(language)
// check if user with email still exists? // check if user with email still exists?
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
@ -416,8 +383,11 @@ export class UserResolver {
logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`) logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`)
if (foundUser) { if (foundUser) {
// ATTENTION: this logger-message will be exactly expected during tests // ATTENTION: this logger-message will be exactly expected during tests, next line
logger.info(`User already exists with this email=${email}`) logger.info(`User already exists with this email=${email}`)
logger.info(
`Specified username when trying to register multiple times with this email: firstName=${firstName}, lastName=${lastName}`,
)
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
const user = new User(communityDbUser) const user = new User(communityDbUser)
@ -430,18 +400,20 @@ export class UserResolver {
user.publisherId = publisherId user.publisherId = publisherId
logger.debug('partly faked user=' + user) logger.debug('partly faked user=' + user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountMultiRegistrationEmail({ const emailSent = await sendAccountMultiRegistrationEmail({
firstName, firstName: foundUser.firstName, // this is the real name of the email owner, but just "firstName" would be the name of the new registrant which shall not be passed to the outside
lastName, lastName: foundUser.lastName, // this is the real name of the email owner, but just "lastName" would be the name of the new registrant which shall not be passed to the outside
email, email,
language: foundUser.language, // use language of the emails owner for sending
}) })
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail() const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
eventSendAccountMultiRegistrationEmail.userId = foundUser.id eventSendAccountMultiRegistrationEmail.userId = foundUser.id
eventProtocol.writeEvent( eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail), event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
) )
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) logger.info(
`sendAccountMultiRegistrationEmail by ${firstName} ${lastName} to ${foundUser.firstName} ${foundUser.lastName} <${email}>`,
)
/* uncomment this, when you need the activation link on the console */ /* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user // In case EMails are disabled log the activation link for the user
if (!emailSent) { if (!emailSent) {
@ -470,6 +442,7 @@ export class UserResolver {
dbUser.lastName = lastName dbUser.lastName = lastName
dbUser.language = language dbUser.language = language
dbUser.publisherId = publisherId dbUser.publisherId = publisherId
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
dbUser.passphrase = passphrase.join(' ') dbUser.passphrase = passphrase.join(' ')
logger.debug('new dbUser=' + dbUser) logger.debug('new dbUser=' + dbUser)
if (redeemCode) { if (redeemCode) {
@ -623,7 +596,7 @@ export class UserResolver {
): Promise<boolean> { ): Promise<boolean> {
logger.info(`setPassword(${code}, ***)...`) logger.info(`setPassword(${code}, ***)...`)
// Validate Password // Validate Password
if (!isPassword(password)) { if (!isValidPassword(password)) {
logger.error('Password entered is lexically invalid') logger.error('Password entered is lexically invalid')
throw new Error( throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
@ -681,10 +654,11 @@ export class UserResolver {
userContact.emailChecked = true userContact.emailChecked = true
// Update Password // Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash user.password = encryptPassword(user, password)
user.pubKey = keyPair[0] user.pubKey = keyPair[0]
user.privKey = encryptedPrivkey user.privKey = encryptedPrivkey
logger.debug('User credentials updated ...') logger.debug('User credentials updated ...')
@ -785,11 +759,12 @@ export class UserResolver {
throw new Error(`"${language}" isn't a valid language`) throw new Error(`"${language}" isn't a valid language`)
} }
userEntity.language = language userEntity.language = language
i18n.setLocale(language)
} }
if (password && passwordNew) { if (password && passwordNew) {
// Validate Password // Validate Password
if (!isPassword(passwordNew)) { if (!isValidPassword(passwordNew)) {
logger.error('newPassword does not fullfil the rules') logger.error('newPassword does not fullfil the rules')
throw new Error( throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
@ -801,7 +776,7 @@ export class UserResolver {
userEntity.emailContact.email, userEntity.emailContact.email,
password, password,
) )
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`) logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`) throw new Error(`Old password is invalid`)
} }
@ -817,7 +792,8 @@ export class UserResolver {
logger.debug('PrivateKey encrypted...') logger.debug('PrivateKey encrypted...')
// Save new password hash and newly encrypted private key // Save new password hash and newly encrypted private key
userEntity.password = newPasswordHash[0].readBigUInt64LE() userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
userEntity.password = encryptPassword(userEntity, passwordNew)
userEntity.privKey = encryptedPrivkey userEntity.privKey = encryptedPrivkey
} }

View File

@ -0,0 +1,266 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, cleanDB, contributionDateFormatter } from '@test/helpers'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { User } from '@entity/User'
import { Contribution } from '@entity/Contribution'
import { userFactory } from '@/seeds/factory/user'
import { login, createContribution, adminCreateContribution } from '@/seeds/graphql/mutations'
import { getUserCreation } from './creations'
let mutate: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
const setZeroHours = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
describe('util/creation', () => {
let user: User
let admin: User
const now = new Date()
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
admin = await userFactory(testEnv, peterLustig)
})
describe('getUserCreations', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 250.0,
memo: 'Admin contribution for this month',
creationDate: contributionDateFormatter(now),
},
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 160.0,
memo: 'Admin contribution for the last month',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
},
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 450.0,
memo: 'Admin contribution for two months ago',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()),
),
},
})
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContribution,
variables: {
amount: 400.0,
memo: 'Contribution for this month',
creationDate: contributionDateFormatter(now),
},
})
await mutate({
mutation: createContribution,
variables: {
amount: 500.0,
memo: 'Contribution for the last month',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
},
})
})
it('has the correct data setup', async () => {
await expect(Contribution.find()).resolves.toEqual([
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(now),
amount: expect.decimalEqual(250),
memo: 'Admin contribution for this month',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
amount: expect.decimalEqual(160),
memo: 'Admin contribution for the last month',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()),
),
amount: expect.decimalEqual(450),
memo: 'Admin contribution for two months ago',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(now),
amount: expect.decimalEqual(400),
memo: 'Contribution for this month',
moderatorId: null,
contributionType: 'USER',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
amount: expect.decimalEqual(500),
memo: 'Contribution for the last month',
moderatorId: null,
contributionType: 'USER',
contributionStatus: 'PENDING',
}),
])
})
describe('call getUserCreation now', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
describe('run forward in time one hour before next month', () => {
const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 0, 0)
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), targetDate.getTime() - now.getTime())
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('has the clock set correctly', () => {
expect(new Date().toISOString()).toContain(
`${targetDate.getFullYear()}-${targetDate.getMonth() + 1}-${targetDate.getDate()}T23:`,
)
})
describe('call getUserCreation with UTC', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
describe('call getUserCreation with JST (GMT+0900)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with PST (GMT-0800)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 480, true)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
describe('run two hours forward to be in the next month in UTC', () => {
const nextMonthTargetDate = new Date()
nextMonthTargetDate.setTime(targetDate.getTime() + 2 * 60 * 60 * 1000)
beforeAll(() => {
setTimeout(jest.fn(), 2 * 60 * 60 * 1000)
jest.runAllTimers()
})
it('has the clock set correctly', () => {
expect(new Date().toISOString()).toContain(
`${nextMonthTargetDate.getFullYear()}-${nextMonthTargetDate.getMonth() + 1}-01T01:`,
)
})
describe('call getUserCreation with UTC', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with JST (GMT+0900)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with PST (GMT-0800)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 450, true)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
})
})
})
})
})

View File

@ -13,9 +13,10 @@ export const validateContribution = (
creations: Decimal[], creations: Decimal[],
amount: Decimal, amount: Decimal,
creationDate: Date, creationDate: Date,
timezoneOffset: number,
): void => { ): void => {
logger.trace('isContributionValid: ', creations, amount, creationDate) logger.trace('isContributionValid: ', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth()) const index = getCreationIndex(creationDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
logger.error( logger.error(
@ -37,10 +38,11 @@ export const validateContribution = (
export const getUserCreations = async ( export const getUserCreations = async (
ids: number[], ids: number[],
timezoneOffset: number,
includePending = true, includePending = true,
): Promise<CreationMap[]> => { ): Promise<CreationMap[]> => {
logger.trace('getUserCreations:', ids, includePending) logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths() const months = getCreationMonths(timezoneOffset)
logger.trace('getUserCreations months', months) logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
@ -87,24 +89,29 @@ export const getUserCreations = async (
}) })
} }
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => { export const getUserCreation = async (
logger.trace('getUserCreation', id, includePending) id: number,
const creations = await getUserCreations([id], includePending) timezoneOffset: number,
includePending = true,
): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending, timezoneOffset)
const creations = await getUserCreations([id], timezoneOffset, includePending)
logger.trace('getUserCreation creations=', creations) logger.trace('getUserCreation creations=', creations)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
} }
export const getCreationMonths = (): number[] => { const getCreationMonths = (timezoneOffset: number): number[] => {
const now = new Date(Date.now()) const clientNow = new Date()
clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000)
return [ return [
now.getMonth() + 1, new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, clientNow.getMonth() + 1,
].reverse() ]
} }
export const getCreationIndex = (month: number): number => { const getCreationIndex = (month: number, timezoneOffset: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1) return getCreationMonths(timezoneOffset).findIndex((el) => el === month + 1)
} }
export const isStartEndDateValid = ( export const isStartEndDateValid = (
@ -128,8 +135,12 @@ export const isStartEndDateValid = (
} }
} }
export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => { export const updateCreations = (
const index = getCreationIndex(contribution.contributionDate.getMonth()) creations: Decimal[],
contribution: Contribution,
timezoneOffset: number,
): Decimal[] => {
const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
throw new Error('You cannot create GDD for a month older than the last three months.') throw new Error('You cannot create GDD for a month older than the last three months.')
@ -137,3 +148,7 @@ export const updateCreations = (creations: Decimal[], contribution: Contribution
creations[index] = creations[index].plus(contribution.amount.toString()) creations[index] = creations[index].plus(contribution.amount.toString())
return creations return creations
} }
export const isValidDateString = (dateString: string): boolean => {
return new Date(dateString).toString() !== 'Invalid Date'
}

View File

@ -0,0 +1,15 @@
{
"emails": {
"accountMultiRegistration": {
"emailExists": "Es existiert jedoch zu deiner E-Mail-Adresse schon ein Konto.",
"emailReused": "Deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.",
"helloName": "Hallo {firstName} {lastName}",
"ifYouAreNotTheOne": "Wenn du nicht derjenige bist, der sich versucht hat erneut zu registrieren, wende dich bitte an unseren support:",
"onForgottenPasswordClickLink": "Klicke bitte auf den folgenden Link, falls du dein Passwort vergessen haben solltest:",
"onForgottenPasswordCopyLink": "oder kopiere den obigen Link in dein Browserfenster.",
"sincerelyYours": "Mit freundlichen Grüßen,",
"subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail",
"yourGradidoTeam": "dein Gradido-Team"
}
}
}

View File

@ -0,0 +1,15 @@
{
"emails": {
"accountMultiRegistration": {
"emailExists": "However, an account already exists for your email address.",
"emailReused": "Your email address has just been used again to register an account with Gradido.",
"helloName": "Hello {firstName} {lastName}",
"ifYouAreNotTheOne": "If you are not the one who tried to register again, please contact our support:",
"onForgottenPasswordClickLink": "Please click on the following link if you have forgotten your password:",
"onForgottenPasswordCopyLink": "or copy the link above into your browser window.",
"sincerelyYours": "Sincerely yours,",
"subject": "Gradido: Try To Register Again With Your Email",
"yourGradidoTeam": "your Gradido team"
}
}
}

View File

@ -1,31 +0,0 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendAccountMultiRegistrationEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAccountMultiRegistrationEmail', () => {
beforeEach(async () => {
await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining(CONFIG.EMAIL_LINK_FORGOTPASSWORD) &&
expect.stringContaining('https://gradido.net/de/contact/'),
})
})
})

View File

@ -1,18 +0,0 @@
import { sendEMail } from './sendEMail'
import { accountMultiRegistration } from './text/accountMultiRegistration'
import CONFIG from '@/config'
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
email: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: accountMultiRegistration.de.subject,
text: accountMultiRegistration.de.text({
...data,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
}),
})
}

View File

@ -26,12 +26,12 @@ describe('sendAddedContributionMessageEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`, to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') && expect.stringContaining('Peter Lustig') &&
expect.stringContaining( expect.stringContaining(
'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.', 'du hast zu deinem Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Nachricht von Peter Lustig erhalten.',
) && ) &&
expect.stringContaining('Was für ein Besen ist es geworden?') && expect.stringContaining('Was für ein Besen ist es geworden?') &&
expect.stringContaining('http://localhost/overview'), expect.stringContaining('http://localhost/overview'),

View File

@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>', to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde bestätigt', subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining( expect.stringContaining(
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.', 'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig bestätigt und in deinem Gradido-Konto gutgeschrieben.',
) && ) &&
expect.stringContaining('Betrag: 200,00 GDD') && expect.stringContaining('Betrag: 200,00 GDD') &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),

View File

@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>', to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde abgelehnt', subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining( expect.stringContaining(
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig abgelehnt.', 'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde von Peter Lustig abgelehnt.',
) && ) &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),
}) })

View File

@ -38,7 +38,7 @@ describe('sendEMail', () => {
}) })
}) })
it('logs warining', () => { it('logs warning', () => {
expect(logger.info).toBeCalledWith('Emails are disabled via config...') expect(logger.info).toBeCalledWith('Emails are disabled via config...')
}) })

View File

@ -26,7 +26,7 @@ describe('sendTransactionReceivedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`, to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido Überweisung', subject: 'Du hast Gradidos erhalten',
text: text:
expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('42,00 GDD') && expect.stringContaining('42,00 GDD') &&

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const contributionConfirmed = { export const contributionConfirmed = {
de: { de: {
subject: 'Schöpfung wurde bestätigt', subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,18 +14,17 @@ export const contributionConfirmed = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `Hallo ${data.recipientFirstName} ${data.recipientLastName},
Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${ dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${
data.senderFirstName data.senderLastName
} ${data.senderLastName} bestätigt. } bestätigt und in deinem Gradido-Konto gutgeschrieben.
Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team dein Gradido-Team`,
Link zu deinem Konto: ${data.overviewURL}`,
}, },
} }

View File

@ -1,6 +1,6 @@
export const contributionMessageReceived = { export const contributionMessageReceived = {
de: { de: {
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,15 +14,15 @@ export const contributionMessageReceived = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `Hallo ${data.recipientFirstName} ${data.recipientLastName},
du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten. du hast zu deinem Gemeinwohl-Beitrag "${data.contributionMemo}" eine Nachricht von ${data.senderFirstName} ${data.senderLastName} erhalten.
Bitte beantworte die Rückfrage in deinem Gradido-Konto im Menü "Gemeinschaft" im Tab "Meine Beiträge zum Gemeinwohl"! Um die Nachricht zu sehen und darauf zu antworten, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"!
Link zu deinem Konto: ${data.overviewURL} Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const contributionRejected = { export const contributionRejected = {
de: { de: {
subject: 'Schöpfung wurde abgelehnt', subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,14 +14,15 @@ export const contributionRejected = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `Hallo ${data.recipientFirstName} ${data.recipientLastName},
Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${data.senderLastName} abgelehnt. dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde von ${data.senderFirstName} ${data.senderLastName} abgelehnt.
Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"!
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team dein Gradido-Team`,
Link zu deinem Konto: ${data.overviewURL}`,
}, },
} }

View File

@ -14,7 +14,7 @@ export const transactionLinkRedeemed = {
memo: string memo: string
overviewURL: string overviewURL: string
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName} `Hallo ${data.recipientFirstName} ${data.recipientLastName},
${data.senderFirstName} ${data.senderLastName} (${ ${data.senderFirstName} ${data.senderLastName} (${
data.senderEmail data.senderEmail
@ -27,7 +27,7 @@ export const transactionLinkRedeemed = {
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const transactionReceived = { export const transactionReceived = {
de: { de: {
subject: 'Gradido Überweisung', subject: 'Du hast Gradidos erhalten',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -13,9 +13,9 @@ export const transactionReceived = {
amount: Decimal amount: Decimal
overviewURL: string overviewURL: string
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName} `Hallo ${data.recipientFirstName} ${data.recipientLastName},
Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${ du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${
data.senderLastName data.senderLastName
} (${data.senderEmail}) erhalten. } (${data.senderEmail}) erhalten.
@ -23,7 +23,7 @@ Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -0,0 +1,71 @@
import CONFIG from '@/config'
import { backendLogger as logger } from '@/server/logger'
import { User } from '@entity/User'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
// We will reuse this for changePassword
export const isValidPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
export const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
throw new Error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
sodium.crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return [encryptionKeyHash, encryptionKey]
}
export const getUserCryptographicSalt = (dbUser: User): string => {
switch (dbUser.passwordEncryptionType) {
case PasswordEncryptionType.NO_PASSWORD: {
logger.error('Password not set for user ' + dbUser.id)
throw new Error('Password not set for user ' + dbUser.id) // user has no password
}
case PasswordEncryptionType.EMAIL: {
return dbUser.emailContact.email
break
}
case PasswordEncryptionType.GRADIDO_ID: {
return dbUser.gradidoID
break
}
default:
logger.error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
throw new Error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
}
}

View File

@ -0,0 +1,14 @@
import { User } from '@entity/User'
// import { logger } from '@test/testSetup' getting error "jest is not defined"
import { getUserCryptographicSalt, SecretKeyCryptographyCreateKey } from './EncryptorUtils'
export const encryptPassword = (dbUser: User, password: string): bigint => {
const salt = getUserCryptographicSalt(dbUser)
const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash
const passwordHash = keyBuffer[0].readBigUInt64LE()
return passwordHash
}
export const verifyPassword = (dbUser: User, password: string): boolean => {
return dbUser.password.toString() === encryptPassword(dbUser, password).toString()
}

View File

@ -29,6 +29,7 @@ const context = {
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
forEach: (): void => {}, forEach: (): void => {},
}, },
clientTimezoneOffset: 0,
} }
export const cleanDB = async () => { export const cleanDB = async () => {

View File

@ -9,7 +9,7 @@ export interface Context {
setHeaders: { key: string; value: string }[] setHeaders: { key: string; value: string }[]
role?: Role role?: Role
user?: dbUser user?: dbUser
clientRequestTime?: string clientTimezoneOffset?: number
// hack to use less DB calls for Balance Resolver // hack to use less DB calls for Balance Resolver
lastTransaction?: dbTransaction lastTransaction?: dbTransaction
transactionCount?: number transactionCount?: number
@ -19,7 +19,7 @@ export interface Context {
const context = (args: ExpressContext): Context => { const context = (args: ExpressContext): Context => {
const authorization = args.req.headers.authorization const authorization = args.req.headers.authorization
const clientRequestTime = args.req.headers.clientrequesttime const clientTimezoneOffset = args.req.headers.clienttimezoneoffset
const context: Context = { const context: Context = {
token: null, token: null,
setHeaders: [], setHeaders: [],
@ -27,8 +27,8 @@ const context = (args: ExpressContext): Context => {
if (authorization) { if (authorization) {
context.token = authorization.replace(/^Bearer /, '') context.token = authorization.replace(/^Bearer /, '')
} }
if (clientRequestTime && typeof clientRequestTime === 'string') { if (clientTimezoneOffset && typeof clientTimezoneOffset === 'string') {
context.clientRequestTime = clientRequestTime context.clientTimezoneOffset = Number(clientTimezoneOffset)
} }
return context return context
} }
@ -38,4 +38,14 @@ export const getUser = (context: Context): dbUser => {
throw new Error('No user given in context!') throw new Error('No user given in context!')
} }
export const getClientTimezoneOffset = (context: Context): number => {
if (
(context.clientTimezoneOffset || context.clientTimezoneOffset === 0) &&
Math.abs(context.clientTimezoneOffset) <= 27 * 60
) {
return context.clientTimezoneOffset
}
throw new Error('No valid client time zone offset in context!')
}
export default context export default context

View File

@ -25,6 +25,9 @@ import { Connection } from '@dbTools/typeorm'
import { apolloLogger } from './logger' import { apolloLogger } from './logger'
import { Logger } from 'log4js' import { Logger } from 'log4js'
// i18n
import { i18n } from './localization'
// TODO implement // TODO implement
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity"; // import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
@ -34,6 +37,7 @@ const createServer = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any = serverContext, context: any = serverContext,
logger: Logger = apolloLogger, logger: Logger = apolloLogger,
localization: i18n.I18n = i18n,
): Promise<ServerDef> => { ): Promise<ServerDef> => {
logger.addContext('user', 'unknown') logger.addContext('user', 'unknown')
logger.debug('createServer...') logger.debug('createServer...')
@ -63,6 +67,9 @@ const createServer = async (
// bodyparser urlencoded for elopage // bodyparser urlencoded for elopage
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
// i18n
app.use(localization.init)
// Elopage Webhook // Elopage Webhook
app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook) app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook)
@ -80,6 +87,7 @@ const createServer = async (
`running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`, `running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`,
) )
logger.debug('createServer...successful') logger.debug('createServer...successful')
return { apollo, app, con } return { apollo, app, con }
} }

View File

@ -0,0 +1,28 @@
import path from 'path'
import { backendLogger } from './logger'
import i18n from 'i18n'
i18n.configure({
locales: ['en', 'de'],
defaultLocale: 'en',
retryInDefaultLocale: false,
directory: path.join(__dirname, '..', 'locales'),
// autoReload: true, // if this is activated the seeding hangs at the very end
updateFiles: false,
objectNotation: true,
logDebugFn: (msg) => backendLogger.debug(msg),
logWarnFn: (msg) => backendLogger.info(msg),
logErrorFn: (msg) => backendLogger.error(msg),
// this api is needed for email-template pug files
api: {
__: 't', // now req.__ becomes req.t
__n: 'tn', // and req.__n can be called as req.tn
},
register: global,
mustacheConfig: {
tags: ['{', '}'],
disable: false,
},
})
export { i18n }

View File

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { PasswordEncryptionType } from '@/graphql/enum/PasswordEncryptionType'
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User' import { User as dbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact' import { UserContact } from '@entity/UserContact'
@ -26,6 +27,8 @@ const communityDbUser: dbUser = {
isAdmin: null, isAdmin: null,
publisherId: 0, publisherId: 0,
passphrase: '', passphrase: '',
// default password encryption type
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
hasId: function (): boolean { hasId: function (): boolean {
throw new Error('Function not implemented.') throw new Error('Function not implemented.')
}, },

View File

@ -16,6 +16,7 @@ const context = {
push: headerPushMock, push: headerPushMock,
forEach: jest.fn(), forEach: jest.fn(),
}, },
clientTimezoneOffset: 0,
} }
export const cleanDB = async () => { export const cleanDB = async () => {
@ -25,8 +26,8 @@ export const cleanDB = async () => {
} }
} }
export const testEnvironment = async (logger?: any) => { export const testEnvironment = async (logger?: any, localization?: any) => {
const server = await createServer(context, logger) const server = await createServer(context, logger, localization)
const con = server.con const con = server.con
const testClient = createTestClient(server.apollo) const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate const mutate = testClient.mutate
@ -46,3 +47,12 @@ export const resetEntity = async (entity: any) => {
export const resetToken = () => { export const resetToken = () => {
context.token = '' context.token = ''
} }
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}

View File

@ -1,4 +1,5 @@
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { i18n } from '@/server/localization'
jest.setTimeout(1000000) jest.setTimeout(1000000)
@ -19,4 +20,18 @@ jest.mock('@/server/logger', () => {
} }
}) })
export { logger } jest.mock('@/server/localization', () => {
const originalModule = jest.requireActual('@/server/localization')
return {
__esModule: true,
...originalModule,
i18n: {
init: jest.fn(),
// configure: jest.fn(),
// __: jest.fn(),
// setLocale: jest.fn(),
},
}
})
export { logger, i18n }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,127 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'alias',
length: 20,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
pubKey: Buffer
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
privKey: Buffer
@Column({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
isAdmin: Date | null
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
}

View File

@ -0,0 +1,60 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToOne,
} from 'typeorm'
import { User } from './User'
@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class UserContact extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'type',
length: 100,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
type: string
@OneToOne(() => User, (user) => user.emailContact)
user: User
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
userId: number
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true })
emailVerificationCode: BigInt
@Column({ name: 'email_opt_in_type_id' })
emailOptInTypeId: number
@Column({ name: 'email_resend_count' })
emailResendCount: number
// @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
// emailHash: Buffer
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' })
phone: string
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' })
updatedAt: Date | null
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
}

View File

@ -1 +1 @@
export { User } from './0049-add_user_contacts_table/User' export { User } from './0053-change_password_encryption/User'

View File

@ -1 +1 @@
export { UserContact } from './0049-add_user_contacts_table/UserContact' export { UserContact } from './0053-change_password_encryption/UserContact'

View File

@ -0,0 +1,24 @@
/* MIGRATION TO ADD ENCRYPTION TYPE TO PASSWORDS
*
* This migration adds and renames columns in the table `users`
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE users RENAME COLUMN created TO created_at;')
await queryFn('ALTER TABLE users RENAME COLUMN deletedAt TO deleted_at;')
// alter table emp rename column emp_name to name
await queryFn(
'ALTER TABLE users ADD COLUMN password_encryption_type int(10) NOT NULL DEFAULT 0 AFTER password;',
)
await queryFn(`UPDATE users SET password_encryption_type = 1 WHERE id IN
(SELECT user_id FROM user_contacts WHERE email_checked = 1)`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE users RENAME COLUMN created_at TO created;')
await queryFn('ALTER TABLE users RENAME COLUMN deleted_at TO deletedAt;')
await queryFn('ALTER TABLE users DROP COLUMN password_encryption_type;')
}

View File

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

167
docu/RoadMap_2022-2023.md Normal file
View File

@ -0,0 +1,167 @@
# Roadmap 2022 / 2023
## unsortierte Sammlung von Themen
1. backend access layer
- Refactoring der Resolver-Klassen
- Daten-Zugriffschicht zur Kapselung der DB-Schicht
- Transfer-Datenmodel zum Austausch von Daten zwischen den Schichten
- technisches Transaktion-Handling und Lösung von Deadlocks
- Konzept in Arbeit
2. capturing alias
- Konzept fertig
- Änderungen in Register- und Login-Prozess
3. Passwort-Verschlüsselung: Refactoring
- Konzept aufteilen in Ausbaustufen
- Altlasten entsorgen
- Versionierung/Typisierung der verwendeten Verschlüsselungslogik notwendig
- DB-Migration auf encryptionType=EMAIL
4. Passwort-Verschlüsselung: Login mit impliziter Neuverschlüsselung
* Logik der Passwortverschlüsselung auf GradidoID einführen
* bei Login mit encryptionType=Email oder OneTime triggern einer Neuverschlüsselung per GradidoID
* Unabhängigkeit von Email erzeugen
* Änderung der User-Email ermöglichen
5. Contribution-Categories
- Bewertung und Kategorisierung von Schöpfungen: Was hat Wer für Wen geleistet?
- Regeln auf Categories ermöglichen
- Konzept in Arbeit
6. Statistics / Analysen
7. Contribution-Link editieren
8. User-Tagging
- Eine UserTag dient zur einfachen Gruppierung gleichgesinnter oder örtlich gebundener User
- Motivation des User-Taggings: bilden kleinerer lokaler User-Gruppen und jeder kennt jeden
- Einführung einer UserTaggings-Tabelle und eine User-UserTaggings-Zuordnungs-Tabelle
- Ein Moderator kann im AdminInterface die Liste der UserTags pflegen
- neues TAG anlegen
- vorhandenes TAG umbenennen
- ein TAG löschen, sofern kein User mehr diesem TAG zugeordnet ist
- Will ein User ein TAG zugeordnet werden, so kann dies nur ein Moderator im AdminInterface tun
- Ein Moderator kann im AdminInterface
- ein TAG einem User zuordnen
- ein TAG von einem User entfernen
- wichtige UseCases:
- Zuordnung eines Users zu einem TAG durch einen Moderator
- TAG spezifische Schöpfung
- User muss für seinen Beitrag ein TAG auswählen können, dem er zuvor zugeordnet wurde
- TAG-Moderator kann den Beitrag bestätigen, weil er den User mit dem TAG (persönlich) kennt
9. User-Beziehungen und Favoritenverwaltung
- User-User-Zuordnung
- aus Tx-Liste die aktuellen Favoriten ermitteln
- Verwaltung von Zuordnungen
- Auswahl
- Berechtigungen
- Gruppierung
- Community-übergreifend
- User-Beziehungen
10. technische Ablösung der Email und Ersatz durch GradidoID
* APIs / Links / etc mit Email anpassen, so dass keine Email mehr verwendet wird
* Email soll aber im Aussen für User optional noch verwendbar bleiben
* Intern erfolgt aber auf jedenfall ein Mapping auf GradidoID egal ob per Email oder Alias angefragt wird
11. Zeitzone
- User sieht immer seine Locale-Zeit und Monate
- Admin sieht immer UTC-Zeit und Monate
- wichtiges Kriterium für Schöpfung ist das TargetDate ( heißt in DB contributionDate)
- Berechnung der möglichen Schöpfungen muss somit auf dem TargetDate der Schöpfung ermittelt werden! **(Ist-Zustand)**
- Kann es vorkommen, dass das TargetDate der Contribution vor dem CreationDate der TX liegt? Ja
- Beispiel: User in Tokyo Locale mit Offest +09:00
- aktiviert Contribution-Link mit Locale: 01.11.2022 07:00:00+09:00 = TargetDate = Zieldatum der Schöpfung
- die Contribution wird gespeichert mit
- creationDate=31.10.2022 22:00:00 UTC
- contributionDate=01.11.2022 07:00:00
- (neu) clientRequestTime=01.11.2022 07:00:00+09:00
- durch automatische Bestätigung und sofortiger Transaktion wird die TX gespeichert mit
- creationDate=31.10.2022 22:00:00 UTC
- **zwingende Prüfung aller Requeste: auf -12h <= ClientRequestTime <= +12h**
- Prüfung auf Sommerzeiten und exotische Länder beachten
-
- zur Analyse und Problemverfolgung von Contributions immer original ClientRequestTime mit Offset in DB speichern
- Beispiel für täglichen Contribution-Link während des Monats:
- 17.10.2022 22:00 +09:00 => 17.10.2022 UTC: 17.10.2022 13:00 UTC => 17.10.2022
- 18.10.2022 02:00 +09:00 => 18.10.2022 UTC: 17.10.2022 17:00 UTC => 17.10.2022 !!!! darf nicht weil gleicher Tag !!!
- Beispiel für täglichen Contribution-Link am Monatswechsel:
- 31.10.2022 22:00 +09:00 => 31.10.2022 UTC: 31.10.2022 15:00 UTC => 31.10.2022
- 01.11.2022 07:00 +09:00 => 01.11.2022 UTC: 31.10.2022 22:00 UTC => 31.10.2022 !!!! darf nicht weil gleicher Tag !!!
12. Layout
13. Lastschriften-Link
14. Registrierung mit Redeem-Link:
* bei inaktivem Konto, sprich bisher noch keine Email-Bestätigung, keine Buchung möglich
* somit speichern des Links zusammen mit OptIn-Code
* damit kann in einem Resend der ConfirmationEmail der Link auch korrekt wieder mitgeliefert werden
15. Manuelle User-Registrierung für Admin
- soll am 10.12.2022 für den Tag bei den Galliern produktiv sein
16. Dezentralisierung / Federation
- Hyperswarm
- funktioniert schon im Prototyp
- alle Instanzen finden sich gegenseitig
- ToDo:
- Infos aus HyperSwarm in der Community speichern
- Prüfung ob neue mir noch unbekannte Community hinzugekommen ist?
- Triggern der Authentifizierungs- und Autorisierungs-Handshake für neue Community
- Authentifizierungs- und Autorisierungs-Handshake
- Inter-Community-Communication
- **ToDos**:
- DB-Migration für Community-Tabelle, User-Community-Zuordnungen, UserRights-Tabelle
- Berechtigungen für Communities
- Register- und Login-Prozess für Community-Anmeldung anpassen
- Auswahl-Box einer Community
- createUser mit Zuordnung zur ausgewählten Community
- Schöpfungsprozess auf angemeldete Community anpassen
- "Beitrag einreichen"-Dialog auf angemeldete Community anpassen
- "meine Beiträge zum Gemeinwohl" mit Filter auf angemeldete Community anpassen
- "Gemeinschaft"-Dialog auf angemeldete Community anpassen
- "Mein Profil"-Dialog auf Communities anpassen
- Umzug-Service in andere Community
- Löschen der Mitgliedschaft zu angemeldeter Community (Deaktivierung der Zuordnung "User-Community")
- "Senden"-Dialog mit Community-Auswahl
- "Transaktion"-Dialog mit Filter auf angemeldeter Community
- AdminInterface auf angemeldete Community anpassen
- "Übersicht"-Dialog mit Filter auf angemeldete Community
- "Nutzersuche"-Dialog mit Filter auf angemeldete Community
- "Mehrfachschöpfung"-Dialog mit Filter auf angemeldete Comunity
- Subject/Texte/Footer/... der Email-Benachrichtigungen auf angemeldete Community anpassen
## Priorisierung
1. Contribution-Link editieren (vlt schon im vorherigen Bugfix-Release Ende Okt. 2022 fertig)
2. Passwort-Verschlüsselung: Refactoring **Konzeption fertig!!**!
3. Manuelle User-Registrierung für Admin (10.12.2022) **Konzeption ongoing!!**!
4. Passwort-Verschlüsselung: implizite Login-Neuverschlüsselung **Konzeption fertig!!**!
5. Layout
6. Zeitzone
7. Dezentralisierung / Federation
8. capturing alias **Konzeption fertig!!**!
9. Registrierung mit Redeem-Link: bei inaktivem Konto keine Buchung möglich
10. Subgruppierung / User-Tagging (einfacher Ansatz)
11. backend access layer
12. technische Ablösung der Email und Ersatz durch GradidoID
13. User-Beziehungen und Favoritenverwaltung
14. Lastschriften-Link
15. Contribution-Categories
16. Statistics / Analysen

View File

@ -0,0 +1,60 @@
<mxfile host="65bd71144e">
<diagram id="CdUoMVivL2xThNJutTjM" name="Seite-1">
<mxGraphModel dx="1022" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2336" pageHeight="1654" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="14" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="2" target="7">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="160" y="100"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="2" value="capturing alias" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="Manuelle User-Registrierung für Admin (10.12.2022)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="200" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="4" value="Zeitzone" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="280" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="5" value="User-Beziehungen und Favoritenverwaltung" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="360" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="6" value="Layout" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="440" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="15" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="7" target="12">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="440" y="140"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="7" value="Passwort-Verschlüsselung" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="320" y="80" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="8" value="Subgruppierung / Subcommunities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="520" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="9" value="Contribution-Categories" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="600" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="10" value="backend access layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="680" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="11" value="Statistics / Analysen" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="760" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="12" value="Ablösung der Email und Ersatz durch GradidoID" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="600" y="120" width="360" height="40" as="geometry"/>
</mxCell>
<mxCell id="13" value="Dezentralisierung / Federation" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="840" width="440" height="40" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -1,6 +1,6 @@
{ {
"name": "bootstrap-vue-gradido-wallet", "name": "bootstrap-vue-gradido-wallet",
"version": "1.13.3", "version": "1.14.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node run/server.js", "start": "node run/server.js",

View File

@ -67,9 +67,9 @@ describe('ContributionMessagesFormular', () => {
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
}) })
it('emitted "get-list-contribution-messages" with data', async () => { it('emitted "get-list-contribution-messages" with false', async () => {
expect(wrapper.emitted('get-list-contribution-messages')).toEqual( expect(wrapper.emitted('get-list-contribution-messages')).toEqual(
expect.arrayContaining([expect.arrayContaining([42])]), expect.arrayContaining([expect.arrayContaining([false])]),
) )
}) })

View File

@ -51,7 +51,7 @@ export default {
}, },
}) })
.then((result) => { .then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId) this.$emit('get-list-contribution-messages', false)
this.$emit('update-state', this.contributionId) this.$emit('update-state', this.contributionId)
this.form.text = '' this.form.text = ''
this.toastSuccess(this.$t('message.reply')) this.toastSuccess(this.$t('message.reply'))

View File

@ -40,16 +40,6 @@ describe('ContributionMessagesList', () => {
expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true)
}) })
describe('get List Contribution Messages', () => {
beforeEach(() => {
wrapper.vm.getListContributionMessages()
})
it('emits getListContributionMessages', async () => {
expect(wrapper.vm.$emit('get-list-contribution-messages')).toBeTruthy()
})
})
describe('update State', () => { describe('update State', () => {
beforeEach(() => { beforeEach(() => {
wrapper.vm.updateState() wrapper.vm.updateState()

View File

@ -9,7 +9,7 @@
<contribution-messages-formular <contribution-messages-formular
v-if="['PENDING', 'IN_PROGRESS'].includes(state)" v-if="['PENDING', 'IN_PROGRESS'].includes(state)"
:contributionId="contributionId" :contributionId="contributionId"
@get-list-contribution-messages="getListContributionMessages" v-on="$listeners"
@update-state="updateState" @update-state="updateState"
/> />
</b-container> </b-container>
@ -50,9 +50,6 @@ export default {
}, },
}, },
methods: { methods: {
getListContributionMessages() {
this.$emit('get-list-contribution-messages', this.contributionId)
},
updateState(id) { updateState(id) {
this.$emit('update-state', id) this.$emit('update-state', id)
}, },

View File

@ -5,9 +5,11 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue const localVue = global.localVue
let wrapper let wrapper
const dateMock = jest.fn((d) => d)
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: dateMock,
$store: { $store: {
state: { state: {
firstName: 'Peter', firstName: 'Peter',
@ -239,4 +241,63 @@ and here is the link to the repository: https://github.com/gradido/gradido`)
}) })
}) })
}) })
describe('contribution message type HISTORY', () => {
const propsData = {
message: {
id: 111,
message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time)
---
This message also contains a link: https://gradido.net/de/
---
350.00`,
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'HISTORY',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const itemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('render HISTORY message', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = itemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)')
})
it('renders the date', () => {
expect(dateMock).toBeCalledWith(
new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'),
'short',
)
})
it('renders the amount', () => {
expect(messageField.text()).toContain('350.00 GDD')
})
it('contains the link as text', () => {
expect(messageField.text()).toContain(
'This message also contains a link: https://gradido.net/de/',
)
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
})
}) })

View File

@ -4,25 +4,25 @@
<b-avatar variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
<div v-else class="is-moderator text-left"> <div v-else class="is-moderator text-left">
<b-avatar square variant="warning"></b-avatar> <b-avatar square variant="warning"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('community.moderator') }}</small> <small class="ml-4 text-success">{{ $t('community.moderator') }}</small>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue' import ParseMessage from '@/components/ContributionMessages/ParseMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: { components: {
LinkifyMessage, ParseMessage,
}, },
props: { props: {
message: { message: {

View File

@ -1,7 +1,15 @@
<template> <template>
<div class="mt-2"> <div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index"> <span v-for="({ type, text }, index) in parsedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link> <b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else-if="type === 'date'">
{{ $d(new Date(text), 'short') }}
<br />
</span>
<span v-else-if="type === 'amount'">
<br />
{{ text | GDD }}
</span>
<span v-else>{{ text }}</span> <span v-else>{{ text }}</span>
</span> </span>
</div> </div>
@ -11,17 +19,28 @@
const LINK_REGEX_PATTERN = /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i const LINK_REGEX_PATTERN = /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default { export default {
name: 'LinkifyMessage', name: 'ParseMessage',
props: { props: {
message: { message: {
type: String, type: String,
required: true, required: true,
}, },
type: {
type: String,
reuired: true,
},
}, },
computed: { computed: {
linkifiedMessage() { parsedMessage() {
const linkified = []
let string = this.message let string = this.message
const linkified = []
let amount
if (this.type === 'HISTORY') {
const split = string.split(/\n\s*---\n\s*/)
string = split[1]
linkified.push({ type: 'date', text: split[0].trim() })
amount = split[2].trim()
}
let match let match
while ((match = string.match(LINK_REGEX_PATTERN))) { while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0) if (match.index > 0)
@ -30,6 +49,7 @@ export default {
string = string.substring(match.index + match[0].length) string = string.substring(match.index + match[0].length)
} }
if (string.length > 0) linkified.push({ type: 'text', text: string }) if (string.length > 0) linkified.push({ type: 'text', text: string })
if (amount) linkified.push({ type: 'amount', text: amount })
return linkified return linkified
}, },
}, },

View File

@ -3,6 +3,7 @@
<div class="mb-5" v-for="item in items" :key="item.id"> <div class="mb-5" v-for="item in items" :key="item.id">
<contribution-list-item <contribution-list-item
v-bind="item" v-bind="item"
@closeAllOpenCollapse="$emit('closeAllOpenCollapse')"
:contributionId="item.id" :contributionId="item.id"
:allContribution="allContribution" :allContribution="allContribution"
@update-contribution-form="updateContributionForm" @update-contribution-form="updateContributionForm"

View File

@ -9,6 +9,7 @@ describe('ContributionListItem', () => {
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: jest.fn((d) => d),
$apollo: { query: jest.fn().mockResolvedValue() },
} }
const propsData = { const propsData = {
@ -132,6 +133,27 @@ describe('ContributionListItem', () => {
expect(wrapper.emitted('delete-contribution')).toBeFalsy() expect(wrapper.emitted('delete-contribution')).toBeFalsy()
}) })
}) })
describe('updateState', () => {
beforeEach(async () => {
await wrapper.vm.updateState()
})
it('emit update-state', () => {
expect(wrapper.vm.$emit('update-state')).toBeTruthy()
})
})
})
describe('getListContributionMessages', () => {
beforeEach(() => {
wrapper
.findComponent({ name: 'ContributionMessagesList' })
.vm.$emit('get-list-contribution-messages')
})
it('emits closeAllOpenCollapse', () => {
expect(wrapper.emitted('closeAllOpenCollapse')).toBeTruthy()
})
}) })
}) })
}) })

View File

@ -94,6 +94,7 @@
v-if="!['CONFIRMED', 'DELETED'].includes(state) && !allContribution" v-if="!['CONFIRMED', 'DELETED'].includes(state) && !allContribution"
class="pointer ml-5" class="pointer ml-5"
@click=" @click="
$emit('closeAllOpenCollapse'),
$emit('update-contribution-form', { $emit('update-contribution-form', {
id: id, id: id,
contributionDate: contributionDate, contributionDate: contributionDate,
@ -256,8 +257,10 @@ export default {
if (value) this.$emit('delete-contribution', item) if (value) this.$emit('delete-contribution', item)
}) })
}, },
getListContributionMessages() { getListContributionMessages(closeCollapse = true) {
// console.log('getListContributionMessages', this.contributionId) if (closeCollapse) {
this.$emit('closeAllOpenCollapse')
}
this.$apollo this.$apollo
.query({ .query({
query: listContributionMessages, query: listContributionMessages,

View File

@ -13,7 +13,6 @@
<b-button variant="primary" :to="register"> <b-button variant="primary" :to="register">
{{ $t('gdd_per_link.to-register') }} {{ $t('gdd_per_link.to-register') }}
</b-button> </b-button>
<p class="mt-3">{{ $t('gdd_per_link.isFree') }}</p>
</b-col> </b-col>
<b-col sm="12" md="6" class="mt-xs-6 mt-sm-6 mt-md-0"> <b-col sm="12" md="6" class="mt-xs-6 mt-sm-6 mt-md-0">
<p>{{ $t('gdd_per_link.has-account') }}</p> <p>{{ $t('gdd_per_link.has-account') }}</p>

View File

@ -296,16 +296,12 @@
"forgotPassword": { "forgotPassword": {
"heading": "Bitte gib deine E-Mail an mit der du bei Gradido angemeldet bist." "heading": "Bitte gib deine E-Mail an mit der du bei Gradido angemeldet bist."
}, },
"login": {
"heading": "Melde dich mit deinen Zugangsdaten an. Bewahre sie stets sicher auf!"
},
"resetPassword": { "resetPassword": {
"heading": "Trage bitte dein Passwort ein und wiederhole es." "heading": "Trage bitte dein Passwort ein und wiederhole es."
}, },
"signup": { "signup": {
"agree": "Ich stimme der <a href='https://gradido.net/de/datenschutz/' target='_blank' >Datenschutzerklärung</a> zu.", "agree": "Ich stimme der <a href='https://gradido.net/de/datenschutz/' target='_blank' >Datenschutzerklärung</a> zu.",
"dont_match": "Die Passwörter stimmen nicht überein.", "dont_match": "Die Passwörter stimmen nicht überein.",
"heading": "Registriere dich indem du alle Daten vollständig und in die richtigen Felder eingibst.",
"lowercase": "Ein Kleinbuchstabe erforderlich.", "lowercase": "Ein Kleinbuchstabe erforderlich.",
"minimum": "Mindestens 8 Zeichen.", "minimum": "Mindestens 8 Zeichen.",
"no-whitespace": "Keine Leerzeichen und Tabulatoren", "no-whitespace": "Keine Leerzeichen und Tabulatoren",

View File

@ -296,16 +296,12 @@
"forgotPassword": { "forgotPassword": {
"heading": "Please enter the email address by which you're registered here." "heading": "Please enter the email address by which you're registered here."
}, },
"login": {
"heading": "Sign in with your access data. Keep them safe!"
},
"resetPassword": { "resetPassword": {
"heading": "Please enter your password and repeat it." "heading": "Please enter your password and repeat it."
}, },
"signup": { "signup": {
"agree": "I agree to the <a href='https://gradido.net/en/datenschutz/' target='_blank' > privacy policy</a>.", "agree": "I agree to the <a href='https://gradido.net/en/datenschutz/' target='_blank' > privacy policy</a>.",
"dont_match": "Passwords don't match.", "dont_match": "Passwords don't match.",
"heading": "Register by entering all data completely and in the correct fields.",
"lowercase": "One lowercase letter required.", "lowercase": "One lowercase letter required.",
"minimum": "8 characters minimum.", "minimum": "8 characters minimum.",
"no-whitespace": "No white spaces and tabs", "no-whitespace": "No white spaces and tabs",

View File

@ -288,16 +288,12 @@
"forgotPassword": { "forgotPassword": {
"heading": "Por favor, introduce la dirección de correo electrónico con la que estas registrado en Gradido." "heading": "Por favor, introduce la dirección de correo electrónico con la que estas registrado en Gradido."
}, },
"login": {
"heading": "Inicia sesión con tus datos de acceso. Manténlos seguros en todo momento!"
},
"resetPassword": { "resetPassword": {
"heading": "Por favor, introduce tu contraseña y repítela." "heading": "Por favor, introduce tu contraseña y repítela."
}, },
"signup": { "signup": {
"agree": "Acepto la <a href='https://gradido.net/de/datenschutz/' target='_blank' >Política de privacidad</a>.", "agree": "Acepto la <a href='https://gradido.net/de/datenschutz/' target='_blank' >Política de privacidad</a>.",
"dont_match": "Las contraseñas no coinciden.", "dont_match": "Las contraseñas no coinciden.",
"heading": "Regístrate introduciendo todos los datos completos y en los campos correctos.",
"lowercase": "Se requiere una letra minúscula.", "lowercase": "Se requiere una letra minúscula.",
"minimum": "Al menos 8 caracteres.", "minimum": "Al menos 8 caracteres.",
"no-whitespace": "Sin espacios ni tabulaciones.", "no-whitespace": "Sin espacios ni tabulaciones.",

View File

@ -288,16 +288,12 @@
"forgotPassword": { "forgotPassword": {
"heading": "Veuillez entrer l´adresse email sous laquelle vous êtes enregistré ici svp." "heading": "Veuillez entrer l´adresse email sous laquelle vous êtes enregistré ici svp."
}, },
"login": {
"heading": "Vous connecter avec vos données d´accès. Gardez les en sécurité!"
},
"resetPassword": { "resetPassword": {
"heading": "Entrez votre mot de passe et répétez l´action svp." "heading": "Entrez votre mot de passe et répétez l´action svp."
}, },
"signup": { "signup": {
"agree": "J´accepte le <a href='https://gradido.net/en/datenschutz/' target='_blank' > politique de confidentialité </a>.", "agree": "J´accepte le <a href='https://gradido.net/en/datenschutz/' target='_blank' > politique de confidentialité </a>.",
"dont_match": "Les mots de passe ne correspondent pas.", "dont_match": "Les mots de passe ne correspondent pas.",
"heading": "Vous enregistrer en entrant toutes les données demandées dans les champs requis.",
"lowercase": "Une lettre minuscule est requise.", "lowercase": "Une lettre minuscule est requise.",
"minimum": "8 caractères minimum.", "minimum": "8 caractères minimum.",
"no-whitespace": "Pas d´espace ni d´onglet", "no-whitespace": "Pas d´espace ni d´onglet",

View File

@ -288,16 +288,12 @@
"forgotPassword": { "forgotPassword": {
"heading": "Geef alsjeblieft jouw email, waarmee je bij Gradido aangemeld bent." "heading": "Geef alsjeblieft jouw email, waarmee je bij Gradido aangemeld bent."
}, },
"login": {
"heading": "Meld je met jouw inloggegevens aan. Sla deze altijd veilig op!"
},
"resetPassword": { "resetPassword": {
"heading": "Vul alsjeblieft jouw wachtwoord in, en herhaal het." "heading": "Vul alsjeblieft jouw wachtwoord in, en herhaal het."
}, },
"signup": { "signup": {
"agree": "Ik ga akkoord met <a href='https://gradido.net/de/datenschutz/' target='_blank' >Datenschutzerklärung</a>.", "agree": "Ik ga akkoord met <a href='https://gradido.net/de/datenschutz/' target='_blank' >Datenschutzerklärung</a>.",
"dont_match": "De wachtwoorden zijn niet gelijk.", "dont_match": "De wachtwoorden zijn niet gelijk.",
"heading": "Schrijf je in door alle gegevens volledig en in de juiste velden in te vullen.",
"lowercase": "Een kleine letter is noodzakelijk.", "lowercase": "Een kleine letter is noodzakelijk.",
"minimum": "Minstens 8 tekens.", "minimum": "Minstens 8 tekens.",
"no-whitespace": "Geen spaties en tabs", "no-whitespace": "Geen spaties en tabs",

View File

@ -22,6 +22,7 @@
<b-tab> <b-tab>
<gradido-notification list="my" /> <gradido-notification list="my" />
<contribution-list <contribution-list
@closeAllOpenCollapse="closeAllOpenCollapse"
:items="items" :items="items"
@update-list-contributions="updateListContributions" @update-list-contributions="updateListContributions"
@update-contribution-form="updateContributionForm" @update-contribution-form="updateContributionForm"
@ -96,6 +97,7 @@ export default {
$route(to, from) { $route(to, from) {
this.tabIndex = this.tabLinkHashes.findIndex((hashLink) => hashLink === to.hash) this.tabIndex = this.tabLinkHashes.findIndex((hashLink) => hashLink === to.hash)
this.hashLink = to.hash this.hashLink = to.hash
this.closeAllOpenCollapse()
}, },
}, },
computed: { computed: {
@ -124,6 +126,11 @@ export default {
}, },
}, },
methods: { methods: {
closeAllOpenCollapse() {
this.$el.querySelectorAll('.collapse.show').forEach((value) => {
this.$root.$emit('bv::toggle::collapse', value.id)
})
},
setContribution(data) { setContribution(data) {
this.$apollo this.$apollo
.mutate({ .mutate({

View File

@ -47,7 +47,7 @@
</b-container> </b-container>
<b-container> <b-container>
<div class="h3">{{ $t('contact') }}</div> <div class="h3">{{ $t('contact') }}</div>
<b-link href="mailto: abc@example.com">{{ supportMail }}</b-link> <b-link :href="`mailto:${supportMail}`">{{ supportMail }}</b-link>
</b-container> </b-container>
<!-- <!--
<hr /> <hr />

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="login-form"> <div class="login-form">
<b-container v-if="enterData"> <b-container v-if="enterData">
<div class="pb-5">{{ $t('site.login.heading') }}</div> <div class="pb-5" align="center">{{ $t('gdd_per_link.isFree') }}</div>
<validation-observer ref="observer" v-slot="{ handleSubmit }"> <validation-observer ref="observer" v-slot="{ handleSubmit }">
<b-form @submit.stop.prevent="handleSubmit(onSubmit)"> <b-form @submit.stop.prevent="handleSubmit(onSubmit)">
<b-row> <b-row>

View File

@ -1,9 +1,7 @@
<template> <template>
<div id="registerform"> <div id="registerform">
<b-container v-if="enterData"> <b-container v-if="enterData">
<div class="pb-5"> <div class="pb-5" align="center">{{ $t('gdd_per_link.isFree') }}</div>
{{ $t('site.signup.heading') }}
</div>
<validation-observer ref="observer" v-slot="{ handleSubmit }"> <validation-observer ref="observer" v-slot="{ handleSubmit }">
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)"> <b-form role="form" @submit.prevent="handleSubmit(onSubmit)">
<b-row> <b-row>

View File

@ -12,7 +12,7 @@ const authLink = new ApolloLink((operation, forward) => {
operation.setContext({ operation.setContext({
headers: { headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '', Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
clientRequestTime: new Date().toString(), clientTimezoneOffset: new Date().getTimezoneOffset(),
}, },
}) })
return forward(operation).map((response) => { return forward(operation).map((response) => {

View File

@ -98,7 +98,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: 'Bearer some-token', Authorization: 'Bearer some-token',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })
@ -114,7 +114,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: '', Authorization: '',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })

View File

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