Merge branch 'master' into fix_clear_yarn_cache

This commit is contained in:
einhornimmond 2025-02-04 19:52:47 +01:00 committed by GitHub
commit f16b7fe391
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
246 changed files with 38692 additions and 6285 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/node_modules
**/build
**/coverage

View File

@ -38,7 +38,7 @@ jobs:
docker build --target production -t "gradido/frontend:latest" -t "gradido/frontend:production" -t "gradido/frontend:${VERSION}" -t "gradido/frontend:${BUILD_VERSION}" frontend/
docker save "gradido/frontend" > /tmp/frontend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: docker-frontend-production
path: /tmp/frontend.tar
@ -75,7 +75,7 @@ jobs:
docker build -f ./backend/Dockerfile --target production -t "gradido/backend:latest" -t "gradido/backend:production" -t "gradido/backend:${VERSION}" -t "gradido/backend:${BUILD_VERSION}" .
docker save "gradido/backend" > /tmp/backend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: docker-backend-production
path: /tmp/backend.tar
@ -101,7 +101,7 @@ jobs:
docker build --target production_up -t "gradido/database:production_up" database/
docker save "gradido/database:production_up" > /tmp/database_up.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: docker-database-production_up
path: /tmp/database_up.tar
@ -138,7 +138,7 @@ jobs:
docker build -t "gradido/mariadb:latest" -t "gradido/mariadb:production" -t "gradido/mariadb:${VERSION}" -t "gradido/mariadb:${BUILD_VERSION}" -f ./mariadb/Dockerfile ./
docker save "gradido/mariadb" > /tmp/mariadb.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: docker-mariadb-production
path: /tmp/mariadb.tar
@ -175,7 +175,7 @@ jobs:
docker build -t "gradido/nginx:latest" -t "gradido/nginx:production" -t "gradido/nginx:${VERSION}" -t "gradido/nginx:${BUILD_VERSION}" nginx/
docker save "gradido/nginx" > /tmp/nginx.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: docker-nginx-production
path: /tmp/nginx.tar
@ -200,35 +200,35 @@ jobs:
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: docker-frontend-production
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/frontend.tar
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: docker-backend-production
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/backend.tar
- name: Download Docker Image (Database)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: docker-database-production_up
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/database_up.tar
- name: Download Docker Image (MariaDB)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: docker-mariadb-production
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/mariadb.tar
- name: Download Docker Image (Nginx)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: docker-nginx-production
path: /tmp

View File

@ -33,7 +33,7 @@ jobs:
uses: actions/checkout@v3
- name: Admin Interface | Build 'test' image
run: docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test"
run: docker build -f ./admin/Dockerfile --target test -t "gradido/admin:test" --build-arg NODE_ENV="test" .
unit_test:
if: needs.files-changed.outputs.admin == 'true'

View File

@ -54,7 +54,7 @@ jobs:
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Backend | Unit tests
run: cd database && yarn && yarn build && cd ../backend && yarn && yarn test
run: cd database && yarn && yarn build && cd ../config && yarn install && cd ../backend && yarn && yarn test
lint:
if: needs.files-changed.outputs.backend == 'true'
@ -66,7 +66,7 @@ jobs:
uses: actions/checkout@v3
- name: Backend | Lint
run: cd database && yarn && cd ../backend && yarn && yarn run lint
run: cd database && yarn && cd ../config && yarn install && cd ../backend && yarn && yarn run lint
locales:
if: needs.files-changed.outputs.backend == 'true'

View File

@ -36,7 +36,7 @@ jobs:
docker save "gradido/dht-node:test" > /tmp/dht-node.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: docker-dht-node-test
path: /tmp/dht-node.tar
@ -51,7 +51,7 @@ jobs:
uses: actions/checkout@v3
- name: Lint
run: cd database && yarn && cd ../dht-node && yarn && yarn run lint
run: cd database && yarn && cd ../config && yarn install && cd ../dht-node && yarn && yarn run lint
unit_test:
name: Unit Tests - DHT Node
@ -63,7 +63,7 @@ jobs:
uses: actions/checkout@v3
- name: Download Docker Image
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: docker-dht-node-test
path: /tmp

View File

@ -35,7 +35,7 @@ jobs:
docker save "gradido/dlt-connector:test" > /tmp/dlt-connector.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: docker-dlt-connector-test
path: /tmp/dlt-connector.tar

View File

@ -5,7 +5,7 @@ on: push
jobs:
end-to-end-tests:
name: End-to-End Tests
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v3
@ -13,16 +13,6 @@ jobs:
- name: Boot up test system | docker-compose mariadb
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb
- name: Boot up test system | docker-compose database
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Boot up test system | docker-compose backend
run: |
cd backend
cp .env.test_e2e .env
cd ..
docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend
- name: Sleep for 10 seconds
run: sleep 10s
@ -31,14 +21,17 @@ jobs:
sudo chown runner:docker -R *
cd database
yarn && yarn dev_reset
cd ../config
yarn install
cd ../backend
yarn && yarn seed
- name: Boot up test system | docker-compose frontends
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx
- name: Boot up test system | docker-compose mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mailserver
- name: Boot up test system | docker-compose backend, frontend, admin, nginx, mailserver
run: |
cd backend
cp .env.test_e2e .env
cd ..
docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend frontend admin nginx mailserver
- name: End-to-end tests | prepare
run: |
@ -68,7 +61,7 @@ jobs:
- name: End-to-end tests | if tests failed, upload report
id: e2e-report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: cypress-report-pr-#${{ steps.pr.outputs.number }}
path: /home/runner/work/gradido/gradido/e2e-tests/cypress/reports/cucumber_html_report

View File

@ -35,7 +35,7 @@ jobs:
docker save "gradido/federation:test" > /tmp/federation.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: docker-federation-test
path: /tmp/federation.tar
@ -62,7 +62,7 @@ jobs:
uses: actions/checkout@v3
- name: Download Docker Image
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: docker-federation-test
path: /tmp

View File

@ -33,7 +33,7 @@ jobs:
uses: actions/checkout@v3
- name: Frontend | Build 'test' image
run: docker build --target test -t "gradido/frontend:test" frontend/ --build-arg NODE_ENV="test"
run: docker build -f ./frontend/Dockerfile --target test -t "gradido/frontend:test" --build-arg NODE_ENV="test" .
unit_test:
if: needs.files-changed.outputs.frontend == 'true'

View File

@ -1,32 +0,0 @@
name: Gradido MariaDB Test CI
on: push
jobs:
files-changed:
name: Detect File Changes - MariaDB
runs-on: ubuntu-latest
outputs:
mariadb: ${{ steps.changes.outputs.mariadb }}
steps:
- uses: actions/checkout@v3.3.0
- name: Check for frontend file changes
uses: dorny/paths-filter@v2.11.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
list-files: shell
build_test:
if: needs.files-changed.outputs.mariadb == 'true'
name: Docker Build Test - MariaDB
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: MariaDB | Build 'test' image
run: docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./

View File

@ -4,8 +4,95 @@ 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).
#### [2.4.5](https://github.com/gradido/gradido/compare/2.3.1...2.4.5)
- feat(backend): add answer button inside E-Mail [`#3431`](https://github.com/gradido/gradido/pull/3431)
- feat(other): build config in deployment [`#3430`](https://github.com/gradido/gradido/pull/3430)
- fix(dht): and federation using config in bare-metal setup [`#3434`](https://github.com/gradido/gradido/pull/3434)
- fix(frontend): fix meta url from env.template [`#3432`](https://github.com/gradido/gradido/pull/3432)
- fix(frontend): fix problems with dev container [`#3429`](https://github.com/gradido/gradido/pull/3429)
- feat(backend): add config validation with joi [`#3422`](https://github.com/gradido/gradido/pull/3422)
- feat(backend): patch getUsername in PublishNameLogic [`#3428`](https://github.com/gradido/gradido/pull/3428)
- feat(admin): resubmission date localized [`#3426`](https://github.com/gradido/gradido/pull/3426)
- refactor(workflow): increaste artifact version [`#3425`](https://github.com/gradido/gradido/pull/3425)
- feat(backend): make sure, correct user name is used for jwt token [`#3424`](https://github.com/gradido/gradido/pull/3424)
- feat(frontend): speedup frontend build time [`#3423`](https://github.com/gradido/gradido/pull/3423)
- chore(release): v2.4.4 beta [`#3421`](https://github.com/gradido/gradido/pull/3421)
- fix(admin): copy of env variables [`#3420`](https://github.com/gradido/gradido/pull/3420)
- fix(backend): toggle for using worker threads for password encryption [`#3418`](https://github.com/gradido/gradido/pull/3418)
- fix(admin): fix resubmission [`#3416`](https://github.com/gradido/gradido/pull/3416)
- feat(backend): run password encryption in worker thread [`#3410`](https://github.com/gradido/gradido/pull/3410)
- feat(frontend): user location with vue3 in cooperation with montreal [`#3397`](https://github.com/gradido/gradido/pull/3397)
- feat(backend): optimize find user query on login [`#3415`](https://github.com/gradido/gradido/pull/3415)
- feat(frontend): show gdt comments [`#3412`](https://github.com/gradido/gradido/pull/3412)
- fix(backend): humhub use first name instead of username field for initalien based alias [`#3403`](https://github.com/gradido/gradido/pull/3403)
- fix(frontend): repair gdt transaction list pagination [`#3409`](https://github.com/gradido/gradido/pull/3409)
- fix(frontend): updated tests [`#3406`](https://github.com/gradido/gradido/pull/3406)
- feat(backend): speed up gdt request with keep alive connections [`#3392`](https://github.com/gradido/gradido/pull/3392)
- fix(frontend): fix contribution data handling [`#3405`](https://github.com/gradido/gradido/pull/3405)
- feat(database): use the same mariadb version everywhere [`#3396`](https://github.com/gradido/gradido/pull/3396)
- feat(frontend): scale down two img [`#3393`](https://github.com/gradido/gradido/pull/3393)
- feat(other): remove docker autostart [`#3395`](https://github.com/gradido/gradido/pull/3395)
- feat(frontend): add feedback fixes + map feature fixes [`#3400`](https://github.com/gradido/gradido/pull/3400)
- feat(other): increase e2e page load timeout [`#3394`](https://github.com/gradido/gradido/pull/3394)
- chore(release): v2.4.1 beta [`#3388`](https://github.com/gradido/gradido/pull/3388)
- chore(release): v2.4.0 beta [`#3387`](https://github.com/gradido/gradido/pull/3387)
- feat(backend): auto register new user in humhub [`#3386`](https://github.com/gradido/gradido/pull/3386)
- feat(backend): try and catch user sync [`#3385`](https://github.com/gradido/gradido/pull/3385)
- feat(backend): increase initialien count [`#3369`](https://github.com/gradido/gradido/pull/3369)
- feat(frontend): monterail vue3 migration [`#3383`](https://github.com/gradido/gradido/pull/3383)
- fix(frontend): fix postmigration fix [`#3382`](https://github.com/gradido/gradido/pull/3382)
- feat(frontend): update text [`#3373`](https://github.com/gradido/gradido/pull/3373)
- feat(frontend): fix postmigration fix [`#3378`](https://github.com/gradido/gradido/pull/3378)
- feat(frontend): map feature in vue 3 [`#3376`](https://github.com/gradido/gradido/pull/3376)
- feat(frontend): links and emails in messages [`#3377`](https://github.com/gradido/gradido/pull/3377)
- feat(frontend): add transaction link in latest transactions [`#3375`](https://github.com/gradido/gradido/pull/3375)
- fix(frontend): fix logout issue [`#3374`](https://github.com/gradido/gradido/pull/3374)
- fix(frontend): post migration fixes [`#3372`](https://github.com/gradido/gradido/pull/3372)
- feat(frontend): vue3 migration [`#3365`](https://github.com/gradido/gradido/pull/3365)
- fix(frontend): fix index.html [`#3368`](https://github.com/gradido/gradido/pull/3368)
- build(frontend): merged code from master [`#3367`](https://github.com/gradido/gradido/pull/3367)
- fix(frontend): vue3 migration pre deploy setup [`#3366`](https://github.com/gradido/gradido/pull/3366)
- fix(workflow): fix broken tests [`#3363`](https://github.com/gradido/gradido/pull/3363)
- fix(frontend): style fixes, admin fix [`#3364`](https://github.com/gradido/gradido/pull/3364)
- fix(frontend): gdt test [`#3361`](https://github.com/gradido/gradido/pull/3361)
- fix(frontend): style fixes [`#3360`](https://github.com/gradido/gradido/pull/3360)
- fix(frontend): migration feedback fixes [`#3359`](https://github.com/gradido/gradido/pull/3359)
- fix(frontend): scss changes and fixes [`#3358`](https://github.com/gradido/gradido/pull/3358)
- fix(frontend): migration remaining fixes [`#3356`](https://github.com/gradido/gradido/pull/3356)
- fix(admin): fix message update [`#3354`](https://github.com/gradido/gradido/pull/3354)
- fix(admin): fix refetch data in edit creation form [`#3353`](https://github.com/gradido/gradido/pull/3353)
- fix(frontend): fix dropdown in transaction send and link [`#3352`](https://github.com/gradido/gradido/pull/3352)
- fix(frontend): fix newsletter state reactivity [`#3351`](https://github.com/gradido/gradido/pull/3351)
- fix(frontend): fix how community switch is handled [`#3350`](https://github.com/gradido/gradido/pull/3350)
- fix(frontend): fixed after merge [`#3349`](https://github.com/gradido/gradido/pull/3349)
- fix(frontend): fixed logout handler [`#3347`](https://github.com/gradido/gradido/pull/3347)
- chore(frontend): main js cleanup [`#3346`](https://github.com/gradido/gradido/pull/3346)
- feature(frontend): change env config reading [`#3345`](https://github.com/gradido/gradido/pull/3345)
- feature(frontend): bump node in FE .nvmrc [`#3344`](https://github.com/gradido/gradido/pull/3344)
- feat(frontend): migration setup [`#3342`](https://github.com/gradido/gradido/pull/3342)
- fix(admin): Remove "maxAmountPerMonth" from `createContributionLink` gql. [`#3343`](https://github.com/gradido/gradido/pull/3343)
- fix(admin): style fixes [`#3339`](https://github.com/gradido/gradido/pull/3339)
- fix(admin): creation tab disappearing after creating creation [`#3338`](https://github.com/gradido/gradido/pull/3338)
- fix(frontend): show updated gdd amount [`#3337`](https://github.com/gradido/gradido/pull/3337)
- feat(admin): Add remaining fixes [`#3336`](https://github.com/gradido/gradido/pull/3336)
- feat(admin): fix edit creation form [`#3334`](https://github.com/gradido/gradido/pull/3334)
- feat(admin): migration of admin creation components [`#3333`](https://github.com/gradido/gradido/pull/3333)
- feat(admin): automatic contributions updates [`#3332`](https://github.com/gradido/gradido/pull/3332)
- feat(admin): vite config changes [`#3331`](https://github.com/gradido/gradido/pull/3331)
- feat(admin) - fix import in node server [`#3330`](https://github.com/gradido/gradido/pull/3330)
- fix(admin): stylelint fix [`#3329`](https://github.com/gradido/gradido/pull/3329)
- feat(admin): setup migration environment [`#3328`](https://github.com/gradido/gradido/pull/3328)
- fix(admin): fix contribution link [`#3326`](https://github.com/gradido/gradido/pull/3326)
- feat(admin): geo-coordinates for community [`#3323`](https://github.com/gradido/gradido/pull/3323)
- feat(backend): speedup listTransactions [`#3324`](https://github.com/gradido/gradido/pull/3324)
- fix(frontend): link forwarding after using send with url parameters [`#3322`](https://github.com/gradido/gradido/pull/3322)
#### [2.3.1](https://github.com/gradido/gradido/compare/2.3.0...2.3.1)
> 11 June 2024
- chore(release): v2.3.1 beta [`#3321`](https://github.com/gradido/gradido/pull/3321)
- feat(frontend): more compatible humhub auto-login link [`#3319`](https://github.com/gradido/gradido/pull/3319)
- fix(backend): fix test which will only fail at 31. of month, or 30.05 [`#3320`](https://github.com/gradido/gradido/pull/3320)
- feat(frontend): remove automatically logged out message [`#3318`](https://github.com/gradido/gradido/pull/3318)

1
admin/.gitignore vendored
View File

@ -1,6 +1,7 @@
node_modules/
build/
.cache/
.yarn/install-state.gz
/.env
/.env.bak

View File

@ -11,7 +11,7 @@ ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
## We cannot do $(npm run version).${BUILD_NUMBER} here so we default to 0.0.0.0
ENV BUILD_VERSION="0.0.0.0"
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
ENV BUILD_COMMIT="0000000"
ENV BUILD_COMMIT_SHORT="0000000"
## SET NODE_ENV
ARG NODE_ENV="production"
## App relevant Envs
@ -42,6 +42,8 @@ EXPOSE ${PORT}
RUN mkdir -p ${DOCKER_WORKDIR}
WORKDIR ${DOCKER_WORKDIR}
RUN mkdir -p /config
##################################################################################
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
##################################################################################
@ -53,7 +55,7 @@ FROM base as development
# Run command
# (for development we need to execute yarn install since the
# node_modules are on another volume and need updating)
CMD /bin/sh -c "yarn install && yarn run dev"
CMD /bin/sh -c "cd /config && yarn install && cd /app && yarn && yarn run dev"
##################################################################################
# BUILD (Does contain all files and is therefore bloated) ########################
@ -61,9 +63,16 @@ CMD /bin/sh -c "yarn install && yarn run dev"
FROM base as build
# Copy everything
COPY . .
# yarn install
COPY ./admin/ .
# Copy everything from config
COPY ./config/ ../config/
# yarn install and build config
RUN cd ../config && yarn install --production=false --frozen-lockfile --non-interactive && yarn build
# yarn install admin
RUN yarn install --production=false --frozen-lockfile --non-interactive
# yarn build
RUN yarn run build
@ -85,6 +94,7 @@ FROM base as production
# Copy "binary"-files from build image
COPY --from=build ${DOCKER_WORKDIR}/build ./build
COPY --from=build ${DOCKER_WORKDIR}/../config/build ../config/build
# We also copy the node_modules express and serve-static for the run script
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
# Copy static files

View File

@ -3,9 +3,8 @@
"description": "Administration Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "2.3.1",
"version": "2.4.5",
"license": "Apache-2.0",
"private": false,
"scripts": {
"start": "node run/server.js",
"dev": "vite",
@ -37,7 +36,7 @@
"babel-preset-env": "^1.7.0",
"babel-preset-vue": "^2.0.2",
"bootstrap": "^5.3.3",
"bootstrap-vue-next": "^0.23.2",
"bootstrap-vue-next": "0.26.8",
"date-fns": "^2.29.3",
"dotenv-webpack": "^7.0.3",
"express": "^4.17.1",
@ -50,10 +49,11 @@
"sass": "^1.77.8",
"vite": "3.2.10",
"vite-plugin-commonjs": "^0.10.1",
"vue": "3.4.31",
"vue": "3.5.13",
"vue-apollo": "3.1.2",
"vue-i18n": "9.13.1",
"vue-router": "4.4.0",
"vue3-datepicker": "^0.4.0",
"vuex": "4.1.0",
"vuex-persistedstate": "4.1.0"
},
@ -74,6 +74,8 @@
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-promise": "^5.1.1",
"eslint-plugin-vue": "8.7.1",
"gradido-config": "../config",
"joi": "^17.13.3",
"jsdom": "^25.0.0",
"mock-apollo-client": "^1.2.1",
"postcss": "^8.4.8",

7
admin/prepare-and-build.sh Executable file
View File

@ -0,0 +1,7 @@
# TODO this is the quick&dirty solution for the openssl security topic, please see https://stackoverflow.com/questions/69692842/error-message-error0308010cdigital-envelope-routinesunsupported
$env:NODE_OPTIONS = "--openssl-legacy-provider"
nvm use
yarn cache clean
yarn install
yarn build

View File

@ -1,11 +1,12 @@
// Imports
import CONFIG from '../src/config'
const express = require('express')
const path = require('path')
// Host & Port
const hostname = '127.0.0.1'
const port = import.meta.env.PORT || 8080
const hostname = CONFIG.ADMIN_MODULE_HOST // '127.0.0.1'
const port = CONFIG.ADMIN_MODULE_PORT // process.env.PORT || 8080
// Express Server
const app = express()
// Serve files

View File

@ -125,6 +125,7 @@ import { createContributionLink } from '@/graphql/createContributionLink.js'
import { updateContributionLink } from '@/graphql/updateContributionLink.js'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { useDateFormatter } from '@/composables/useDateFormatter'
const props = defineProps({
contributionLinkData: {
@ -138,6 +139,8 @@ const emit = defineEmits(['get-contribution-links', 'close-contribution-form'])
const { t } = useI18n()
const { formatDateFromDateTime } = useDateFormatter()
const contributionLinkForm = ref(null)
const form = ref({
@ -201,11 +204,6 @@ const onSubmit = async () => {
}
}
const formatDateFromDateTime = (datetimeString) => {
if (!datetimeString || !datetimeString?.includes('T')) return datetimeString
return datetimeString.split('T')[0]
}
const onReset = () => {
form.value = { validFrom: null, validTo: null }
}

View File

@ -21,6 +21,7 @@ vi.mock('@vue/apollo-composable', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: { value: 'en' },
t: (key) => key,
}),
}))

View File

@ -8,10 +8,18 @@
</BFormCheckbox>
</BFormGroup>
<BFormGroup v-if="showResubmissionDate">
<BFormInput v-model="resubmissionDate" type="date" :min="now"></BFormInput>
<time-picker v-model="resubmissionTime"></time-picker>
<div class="d-flex my-2">
<Datepicker
v-model="resubmissionDate"
:locale="dateLocale"
input-format="P"
:lower-limit="now"
class="form-control"
/>
<time-picker v-model="resubmissionTime" class="ms-2" />
</div>
</BFormGroup>
<BTabs v-model="tabindex" content-class="mt-3" data-test="message-type-tabs">
<BTabs v-model="tabindex" class="mt-3" content-class="mt-3" data-test="message-type-tabs">
<BTab active>
<template #title>
<span id="message-tab-title">{{ $t('moderator.message') }}</span>
@ -24,7 +32,7 @@
v-model="form.text"
:placeholder="$t('contributionLink.memo')"
rows="3"
></BFormTextarea>
/>
</BTab>
<BTab>
<template #title>
@ -38,7 +46,7 @@
v-model="form.text"
:placeholder="$t('moderator.notice')"
rows="3"
></BFormTextarea>
/>
</BTab>
<BTab>
<template #title>
@ -52,7 +60,7 @@
v-model="form.memo"
:placeholder="$t('contributionLink.memo')"
rows="3"
></BFormTextarea>
/>
</BTab>
</BTabs>
<BRow class="mt-4 mb-6">
@ -78,9 +86,10 @@
<script setup>
import { ref, computed } from 'vue'
import { useDateLocale } from '@/composables/useDateLocale'
import { useMutation } from '@vue/apollo-composable'
import { useI18n } from 'vue-i18n'
import Datepicker from 'vue3-datepicker'
import TimePicker from '@/components/input/TimePicker'
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
@ -114,8 +123,8 @@ const emit = defineEmits([
])
const { t } = useI18n()
const dateLocale = useDateLocale()
const { toastError, toastSuccess } = useAppToast()
const form = ref({
text: '',
memo: props.contributionMemo,
@ -141,14 +150,20 @@ const messageType = {
MODERATOR: 'MODERATOR',
}
const disabled = computed(() => {
return (
(tabindex.value === 0 && form.value.text === '') ||
const isTextTabValid = computed(() => form.value.text !== '')
const isMemoTabValid = computed(() => form.value.memo.length >= 5)
const disabled = computed(
() =>
loading.value ||
(tabindex.value === 1 && form.value.memo.length < 5) ||
(showResubmissionDate.value && !resubmissionDate.value)
)
})
(!(showResubmissionDate.value && resubmissionDate.value) &&
([0, 1].includes(tabindex.value)
? !isTextTabValid.value
: tabindex.value === 2
? !isMemoTabValid.value
: false)),
)
const now = computed(() => new Date())

View File

@ -35,7 +35,10 @@
@reset="resetHomeCommunityEditable"
>
<template #view>
<label>{{ $t('federation.gmsApiKey') }}&nbsp;{{ gmsApiKey }}</label>
<div class="d-flex">
<p style="text-wrap: nowrap">{{ $t('federation.gmsApiKey') }}&nbsp;</p>
<span class="d-block" style="overflow-x: auto">{{ gmsApiKey }}</span>
</div>
<BFormGroup>
{{ $t('federation.coordinates') }}
<span v-if="isValidLocation">
@ -86,9 +89,9 @@
<script setup>
import { ref, computed, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateLocale } from '@/composables/useDateLocale'
import { useMutation } from '@vue/apollo-composable'
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
import EditableGroup from '@/components/input/EditableGroup.vue'
import FederationVisualizeItem from './FederationVisualizeItem.vue'
import { updateHomeCommunity } from '@/graphql/updateHomeCommunity'
@ -96,15 +99,13 @@ import Coordinates from '../input/Coordinates.vue'
import EditableGroupableLabel from '../input/EditableGroupableLabel.vue'
import { useAppToast } from '@/composables/useToast'
const locales = { en, de, es, fr, nl }
const props = defineProps({
item: { type: Object, required: true },
})
const { item } = toRefs(props)
const { t, locale } = useI18n()
const { t } = useI18n()
const { toastSuccess, toastError } = useAppToast()
@ -144,7 +145,7 @@ const lastAnnouncedAt = computed(() => {
return formatDistanceToNow(lastAnnouncedAt, {
includeSecond: true,
addSuffix: true,
locale: locales[locale.value],
locale: useDateLocale(),
})
}
return ''
@ -155,7 +156,7 @@ const createdAt = computed(() => {
return formatDistanceToNow(new Date(item.value.createdAt), {
includeSecond: true,
addSuffix: true,
locale: locales[locale.value],
locale: useDateLocale(),
})
}
return ''
@ -198,131 +199,3 @@ const resetHomeCommunityEditable = () => {
gmsApiKey.value = originalGmsApiKey.value
}
</script>
<!--<script>-->
<!--import { formatDistanceToNow } from 'date-fns'-->
<!--import { de, enUS as en, fr, es, nl } from 'date-fns/locale'-->
<!--import EditableGroup from '@/components/input/EditableGroup'-->
<!--import FederationVisualizeItem from './FederationVisualizeItem.vue'-->
<!--import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'-->
<!--import Coordinates from '../input/Coordinates.vue'-->
<!--import EditableGroupableLabel from '../input/EditableGroupableLabel.vue'-->
<!--const locales = { en, de, es, fr, nl }-->
<!--export default {-->
<!-- name: 'CommunityVisualizeItem',-->
<!-- components: {-->
<!-- Coordinates,-->
<!-- EditableGroup,-->
<!-- FederationVisualizeItem,-->
<!-- EditableGroupableLabel,-->
<!-- },-->
<!-- props: {-->
<!-- item: { type: Object },-->
<!-- },-->
<!-- data() {-->
<!-- return {-->
<!-- formatDistanceToNow,-->
<!-- locale: this.$i18n.locale,-->
<!-- details: false,-->
<!-- gmsApiKey: this.item.gmsApiKey,-->
<!-- location: this.item.location,-->
<!-- originalGmsApiKey: this.item.gmsApiKey,-->
<!-- originalLocation: this.item.location,-->
<!-- }-->
<!-- },-->
<!-- computed: {-->
<!-- verified() {-->
<!-- if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) {-->
<!-- return false-->
<!-- }-->
<!-- return (-->
<!-- this.item.federatedCommunities.filter(-->
<!-- (federatedCommunity) =>-->
<!-- new Date(federatedCommunity.verifiedAt) >= new Date(federatedCommunity.lastAnnouncedAt),-->
<!-- ).length > 0-->
<!-- )-->
<!-- },-->
<!-- icon() {-->
<!-- return this.verified ? 'check' : 'x-circle'-->
<!-- },-->
<!-- variant() {-->
<!-- return this.verified ? 'success' : 'danger'-->
<!-- },-->
<!-- lastAnnouncedAt() {-->
<!-- if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) return ''-->
<!-- const minDate = new Date(0)-->
<!-- const lastAnnouncedAt = this.item.federatedCommunities.reduce(-->
<!-- (lastAnnouncedAt, federateCommunity) => {-->
<!-- if (!federateCommunity.lastAnnouncedAt) return lastAnnouncedAt-->
<!-- const date = new Date(federateCommunity.lastAnnouncedAt)-->
<!-- return date > lastAnnouncedAt ? date : lastAnnouncedAt-->
<!-- },-->
<!-- minDate,-->
<!-- )-->
<!-- if (lastAnnouncedAt !== minDate) {-->
<!-- return formatDistanceToNow(lastAnnouncedAt, {-->
<!-- includeSecond: true,-->
<!-- addSuffix: true,-->
<!-- locale: locales[this.locale],-->
<!-- })-->
<!-- }-->
<!-- return ''-->
<!-- },-->
<!-- createdAt() {-->
<!-- if (this.item.createdAt) {-->
<!-- return formatDistanceToNow(new Date(this.item.createdAt), {-->
<!-- includeSecond: true,-->
<!-- addSuffix: true,-->
<!-- locale: locales[this.locale],-->
<!-- })-->
<!-- }-->
<!-- return ''-->
<!-- },-->
<!-- isLocationChanged() {-->
<!-- return this.originalLocation !== this.location-->
<!-- },-->
<!-- isGMSApiKeyChanged() {-->
<!-- return this.originalGmsApiKey !== this.gmsApiKey-->
<!-- },-->
<!-- isValidLocation() {-->
<!-- return this.location && this.location.latitude && this.location.longitude-->
<!-- },-->
<!-- },-->
<!-- methods: {-->
<!-- toggleDetails() {-->
<!-- this.details = !this.details-->
<!-- },-->
<!-- handleUpdateHomeCommunity() {-->
<!-- this.$apollo-->
<!-- .mutate({-->
<!-- mutation: updateHomeCommunity,-->
<!-- variables: {-->
<!-- uuid: this.item.uuid,-->
<!-- gmsApiKey: this.gmsApiKey,-->
<!-- location: this.location,-->
<!-- },-->
<!-- })-->
<!-- .then(() => {-->
<!-- if (this.isLocationChanged && this.isGMSApiKeyChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsApiKeyAndLocationUpdated'))-->
<!-- } else if (this.isGMSApiKeyChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsApiKeyUpdated'))-->
<!-- } else if (this.isLocationChanged) {-->
<!-- this.toastSuccess(this.$t('federation.toast_gmsLocationUpdated'))-->
<!-- }-->
<!-- this.originalLocation = this.location-->
<!-- this.originalGmsApiKey = this.gmsApiKey-->
<!-- })-->
<!-- .catch((error) => {-->
<!-- this.toastError(error.message)-->
<!-- })-->
<!-- },-->
<!-- resetHomeCommunityEditable() {-->
<!-- this.location = this.originalLocation-->
<!-- this.gmsApiKey = this.originalGmsApiKey-->
<!-- },-->
<!-- },-->
<!--}-->
<!--</script>-->

View File

@ -30,11 +30,9 @@
</template>
<script>
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
import { useDateLocale } from '@/composables/useDateLocale'
import VariantIcon from '@/components/VariantIcon.vue'
const locales = { en, de, es, fr, nl }
export default {
name: 'FederationVisualizeItem',
components: { VariantIcon },
@ -58,7 +56,7 @@ export default {
? formatDistanceToNow(new Date(dateString), {
includeSecond: true,
addSuffix: true,
locale: locales[this.$i18n.locale],
locale: useDateLocale,
})
: ''
},

View File

@ -114,7 +114,7 @@ describe('NavBar', () => {
it('changes window location to wallet and dispatches logout', async () => {
const dispatchSpy = vi.spyOn(store, 'dispatch')
await wrapper.vm.handleWallet()
expect(window.location).toBe(CONFIG.WALLET_AUTH_URL.replace('{token}', 'valid-token'))
expect(window.location).toBe(CONFIG.WALLET_AUTH_URL + 'valid-token')
expect(dispatchSpy).toHaveBeenCalledWith('logout')
})
})

View File

@ -80,7 +80,7 @@ const handleLogout = async () => {
}
const handleWallet = () => {
window.location = CONFIG.WALLET_AUTH_URL.replace('{token}', store.state.token)
window.location = CONFIG.WALLET_AUTH_URL + store.state.token
store.dispatch('logout') // logout without redirect
}

View File

@ -3,33 +3,43 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import Coordinates from './Coordinates.vue'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
const value = {
const modelValue = {
latitude: 56.78,
longitude: 12.34,
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, v) => (key === 'geo-coordinates.format' ? `${v.latitude}, ${v.longitude}` : key),
}),
}))
const mockEditableGroup = {
valueChanged: vi.fn(function () {
this.isValueChanged = true
}),
invalidValues: vi.fn(function () {
this.isValueChanged = false
}),
}
describe('Coordinates', () => {
let wrapper
const createWrapper = (props = {}) => {
return mount(Coordinates, {
props: {
value,
modelValue,
...props,
},
global: {
mocks: {
$t: vi.fn((t, v) => {
if (t === 'geo-coordinates.format') {
return `${v.latitude}, ${v.longitude}`
}
return t
}),
},
stubs: {
BFormGroup,
BFormInput,
},
provide: {
editableGroup: mockEditableGroup,
},
},
})
}
@ -63,19 +73,13 @@ describe('Coordinates', () => {
const latitudeInput = wrapper.find('#home-community-latitude')
const longitudeInput = wrapper.find('#home-community-longitude')
await latitudeInput.setValue(34.56)
await latitudeInput.setValue('34.56')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0][0]).toEqual({
latitude: 34.56,
longitude: 12.34,
})
expect(wrapper.vm.inputValue.latitude).toBe('34.56')
await longitudeInput.setValue('78.90')
await longitudeInput.setValue('78.9')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1][0]).toEqual({
latitude: 34.56,
longitude: '78.90',
})
expect(wrapper.vm.inputValue.longitude).toBe('78.9')
})
it('splits coordinates correctly when entering in latitudeLongitude input', async () => {

View File

@ -1,14 +1,14 @@
<template>
<div>
<div class="mb-4">
<BFormGroup
:label="$t('geo-coordinates.label')"
:invalid-feedback="$t('geo-coordinates.both-or-none')"
:label="t('geo-coordinates.label')"
:invalid-feedback="t('geo-coordinates.both-or-none')"
:state="isValid"
>
<BFormGroup
:label="$t('latitude-longitude-smart')"
:label="t('latitude-longitude-smart')"
label-for="home-community-latitude-longitude-smart"
:description="$t('geo-coordinates.latitude-longitude-smart.describe')"
:description="t('geo-coordinates.latitude-longitude-smart.describe')"
>
<BFormInput
id="home-community-latitude-longitude-smart"
@ -17,7 +17,7 @@
@input="splitCoordinates"
/>
</BFormGroup>
<BFormGroup :label="$t('latitude')" label-for="home-community-latitude">
<BFormGroup :label="t('latitude')" label-for="home-community-latitude">
<BFormInput
id="home-community-latitude"
v-model="inputValue.latitude"
@ -25,7 +25,7 @@
@input="valueUpdated"
/>
</BFormGroup>
<BFormGroup :label="$t('longitude')" label-for="home-community-longitude">
<BFormGroup :label="t('longitude')" label-for="home-community-longitude">
<BFormInput
id="home-community-longitude"
v-model="inputValue.longitude"
@ -37,81 +37,94 @@
</div>
</template>
<script>
export default {
name: 'Coordinates',
props: {
value: {
type: Object,
default: null,
},
<script setup>
import { ref, computed, watch, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
emits: ['input'],
data() {
return {
inputValue: this.value,
originalValue: this.value,
locationString: this.getLatitudeLongitudeString(this.value),
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const editableGroup = inject('editableGroup')
const inputValue = ref(sanitizeLocation(props.modelValue))
const originalValue = ref(props.modelValue)
const locationString = ref(getLatitudeLongitudeString(props.modelValue))
const isValid = computed(() => {
return (
(!isNaN(parseFloat(inputValue.value.longitude)) &&
!isNaN(parseFloat(inputValue.value.latitude))) ||
(inputValue.value.longitude === '' && inputValue.value.latitude === '')
)
})
const isChanged = computed(() => {
return inputValue.value !== originalValue.value
})
function splitCoordinates() {
const parts = locationString.value.split(',').map((part) => part.trim())
if (parts.length === 2) {
const [lat, lon] = parts
if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
inputValue.value.longitude = parseFloat(lon)
inputValue.value.latitude = parseFloat(lat)
}
},
computed: {
isValid() {
return (
(!isNaN(parseFloat(this.inputValue.longitude)) &&
!isNaN(parseFloat(this.inputValue.latitude))) ||
(this.inputValue.longitude === '' && this.inputValue.latitude === '')
)
},
isChanged() {
return this.inputValue !== this.originalValue
},
},
methods: {
splitCoordinates(value) {
// default format for geo-coordinates: 'latitude, longitude'
const parts = this.locationString.split(',').map((part) => part.trim())
if (parts.length === 2) {
const [lat, lon] = parts
if (!isNaN(parseFloat(lon) && !isNaN(parseFloat(lat)))) {
this.inputValue.longitude = parseFloat(lon)
this.inputValue.latitude = parseFloat(lat)
}
}
this.valueUpdated()
},
sanitizeLocation(location) {
if (!location) return { latitude: '', longitude: '' }
const parseNumber = (value) => {
const number = parseFloat(value)
return isNaN(number) ? '' : number
}
return {
latitude: parseNumber(location.latitude),
longitude: parseNumber(location.longitude),
}
},
getLatitudeLongitudeString({ latitude, longitude } = {}) {
return latitude && longitude ? this.$t('geo-coordinates.format', { latitude, longitude }) : ''
},
valueUpdated() {
this.locationString = this.getLatitudeLongitudeString(this.inputValue)
this.inputValue = this.sanitizeLocation(this.inputValue)
if (this.isValid && this.isChanged) {
if (this.$parent.valueChanged) {
this.$parent.valueChanged()
}
} else {
if (this.$parent.invalidValues) {
this.$parent.invalidValues()
}
}
this.$emit('input', this.inputValue)
},
},
}
valueUpdated()
}
function sanitizeLocation(location) {
if (!location) return { latitude: '', longitude: '' }
const parseNumber = (value) => {
const number = parseFloat(value)
return isNaN(number) ? '' : number
}
return {
latitude: parseNumber(location.latitude),
longitude: parseNumber(location.longitude),
}
}
function getLatitudeLongitudeString(locationData) {
return locationData?.latitude && locationData?.longitude
? t('geo-coordinates.format', {
latitude: locationData.latitude,
longitude: locationData.longitude,
})
: ''
}
function valueUpdated() {
locationString.value = getLatitudeLongitudeString(inputValue.value)
inputValue.value = sanitizeLocation(inputValue.value)
if (isValid.value && isChanged.value) {
editableGroup.valueChanged()
} else {
editableGroup.invalidValues()
}
emit('update:modelValue', inputValue.value)
}
watch(
() => props.modelValue,
(newValue) => {
inputValue.value = sanitizeLocation(newValue)
originalValue.value = newValue
locationString.value = getLatitudeLongitudeString(newValue)
},
)
</script>

View File

@ -1,7 +1,7 @@
<template>
<div>
<slot v-if="!isEditing" :is-editing="isEditing" name="view"></slot>
<slot v-else :is-editing="isEditing" name="edit" @input="valueChanged"></slot>
<slot v-if="!isEditing" :is-editing="isEditing" name="view" />
<slot v-else :is-editing="isEditing" name="edit" @update:model-value="valueChanged" />
<BFormGroup v-if="allowEdit && !isEditing">
<BButton :variant="variant" @click="enableEdit">
<IBiPencilFill />
@ -12,7 +12,7 @@
<BButton :variant="variant" :disabled="!isValueChanged" class="save-button" @click="save">
{{ $t('save') }}
</BButton>
<BButton variant="secondary" class="close-button" @click="close">
<BButton variant="secondary" class="close-button ms-2" @click="close">
{{ $t('close') }}
</BButton>
</BFormGroup>
@ -22,6 +22,14 @@
<script>
export default {
name: 'EditableGroup',
provide() {
return {
editableGroup: {
valueChanged: this.valueChanged,
invalidValues: this.invalidValues,
},
}
},
props: {
allowEdit: {
type: Boolean,
@ -58,6 +66,7 @@ export default {
close() {
this.$emit('reset')
this.isEditing = false
this.isValueChanged = false
},
},
}

View File

@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import EditableGroupableLabel from './EditableGroupableLabel.vue'
import { BFormGroup, BFormInput } from 'bootstrap-vue-next'
const value = 'test label value'
const modelValue = 'test label value'
const label = 'Test Label'
const idName = 'test-id-name'
@ -16,7 +16,7 @@ describe('EditableGroupableLabel', () => {
components: {
EditableGroupableLabel,
},
props: ['value', 'label', 'idName'],
props: ['modelValue', 'label', 'idName'],
methods: {
onInput: vi.fn(),
...parentMethods,
@ -24,7 +24,7 @@ describe('EditableGroupableLabel', () => {
}
return mount(Parent, {
props: {
value,
modelValue,
label,
idName,
...props,
@ -55,7 +55,7 @@ describe('EditableGroupableLabel', () => {
it('renders BFormInput with correct props', () => {
const formInput = wrapper.findComponent({ name: 'BFormInput' })
expect(formInput.props('id')).toBe(idName)
expect(formInput.props('modelValue')).toBe(value)
expect(formInput.props('modelValue')).toBe(modelValue)
})
// it('emits input event with the correct value when input changes', async () => {
@ -76,7 +76,7 @@ describe('EditableGroupableLabel', () => {
const newValue = 'new label value'
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', newValue)
await input.vm.$emit('update:model-value', newValue)
expect(valueChangedMock).toHaveBeenCalled()
})
@ -86,8 +86,8 @@ describe('EditableGroupableLabel', () => {
wrapper = createWrapper({}, { invalidValues: invalidValuesMock })
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', 'new label value')
await input.vm.$emit('input', value)
await input.vm.$emit('update:model-value', 'new label value')
await input.vm.$emit('update:model-value', modelValue)
expect(invalidValuesMock).toHaveBeenCalled()
})
@ -97,7 +97,7 @@ describe('EditableGroupableLabel', () => {
wrapper = createWrapper({}, { valueChanged: valueChangedMock })
const input = wrapper.findComponent({ name: 'BFormInput' })
await input.vm.$emit('input', value)
await input.vm.$emit('input', modelValue)
expect(valueChangedMock).not.toHaveBeenCalled()
})

View File

@ -1,6 +1,6 @@
<template>
<BFormGroup :label="label" :label-for="idName">
<BFormInput :id="idName" v-model="inputValue" @input="updateValue" />
<BFormInput :id="idName" :model-value="modelValue" @update:model-value="inputValue = $event" />
</BFormGroup>
</template>
@ -8,7 +8,7 @@
export default {
name: 'EditableGroupableLabel',
props: {
value: {
modelValue: {
type: String,
required: false,
default: null,
@ -22,16 +22,20 @@ export default {
required: true,
},
},
emits: ['input'],
emits: ['update:model-value'],
data() {
return {
inputValue: this.value,
originalValue: this.value,
inputValue: this.modelValue,
originalValue: this.modelValue,
}
},
watch: {
inputValue() {
this.updateValue()
},
},
methods: {
updateValue(value) {
this.inputValue = value
updateValue() {
if (this.inputValue !== this.originalValue) {
if (this.$parent.valueChanged) {
this.$parent.valueChanged()
@ -41,7 +45,7 @@ export default {
this.$parent.invalidValues()
}
}
this.$emit('input', this.inputValue)
this.$emit('update:model-value', this.inputValue)
},
},
}

View File

@ -16,22 +16,26 @@ describe('TimePicker', () => {
await input.setValue('23:45')
// Check if timeValue is updated
expect(wrapper.vm.timeValue).toBe('23:45')
expect(wrapper.vm.timeValue).toBe('23:45') // test for Vue 3 composition state directly, if possible
// Check if update:modelValue event is emitted with updated value
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['23:45'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['23:45'])
})
it('validates and corrects time format on blur', async () => {
const wrapper = mount(TimePicker)
const wrapper = mount(TimePicker, {
props: {
modelValue: '99:99', // Set an invalid value initially
},
})
const input = wrapper.find('input[type="text"]')
// Simulate user input
await input.setValue('99:99')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['99:99'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['99:99'])
// Trigger blur event
await input.trigger('blur')
@ -40,12 +44,16 @@ describe('TimePicker', () => {
expect(wrapper.vm.timeValue).toBe('23:59') // Maximum allowed value for hours and minutes
// Check if update:modelValue event is emitted with corrected value
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1]).toEqual(['23:59'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[1]).toEqual(['23:59'])
})
it('checks handling of empty input', async () => {
const wrapper = mount(TimePicker)
const wrapper = mount(TimePicker, {
props: {
modelValue: '', // Set initial empty value
},
})
const input = wrapper.find('input[type="text"]')
// Simulate user input with empty string
@ -58,7 +66,7 @@ describe('TimePicker', () => {
expect(wrapper.vm.timeValue).toBe('00:00')
// Check if update:modelValue event is emitted with default value
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1]).toEqual(['00:00'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[1]).toEqual(['00:00'])
})
})

View File

@ -2,6 +2,7 @@
<div>
<input
v-model="timeValue"
class="timer-input"
type="text"
placeholder="hh:mm"
@input="updateValues"
@ -11,39 +12,61 @@
</template>
<script>
import { ref, watch } from 'vue'
export default {
// Code written from chatGPT 3.5
name: 'TimePicker',
props: {
value: {
modelValue: {
type: String,
default: '00:00',
},
},
emits: ['input'],
data() {
return {
timeValue: this.value,
}
},
methods: {
updateValues(event) {
emits: ['update:modelValue'],
setup(props, { emit }) {
// reactive state
const timeValue = ref(props.modelValue)
// watch for prop changes
watch(
() => props.modelValue,
(newVal) => {
timeValue.value = newVal
},
)
const updateValues = (event) => {
// Allow only numbers and ":"
const inputValue = event.target.value.replace(/[^0-9:]/g, '')
this.timeValue = inputValue
this.$emit('input', inputValue)
},
validateAndCorrect() {
let [hours, minutes] = this.timeValue.split(':')
timeValue.value = inputValue
emit('update:modelValue', inputValue)
}
const validateAndCorrect = () => {
let [hours, minutes] = timeValue.value.split(':')
// Validate hours and minutes
hours = Math.min(parseInt(hours) || 0, 23)
minutes = Math.min(parseInt(minutes) || 0, 59)
// Update the value with correct format
this.timeValue = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
this.$emit('input', this.timeValue)
},
timeValue.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
emit('update:modelValue', timeValue.value)
}
return {
timeValue,
updateValues,
validateAndCorrect,
}
},
}
</script>
<style scoped>
.timer-input {
border: 1px solid rgb(222 226 230);
border-radius: 6px;
padding: 6px 12px;
}
</style>

View File

@ -0,0 +1,10 @@
export const useDateFormatter = () => {
const formatDateFromDateTime = (datetimeString) => {
if (!datetimeString || !datetimeString?.includes('T')) return datetimeString
return datetimeString.split('T')[0]
}
return {
formatDateFromDateTime,
}
}

View File

@ -0,0 +1,10 @@
import { useI18n } from 'vue-i18n'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
const locales = { en, de, es, fr, nl }
export function useDateLocale() {
const { locale } = useI18n()
const dateLocale = locales[locale.value] || en
return dateLocale
}

View File

@ -1,9 +1,9 @@
import { useI18n } from 'vue-i18n'
import { useToast } from 'bootstrap-vue-next'
import { useToastController } from 'bootstrap-vue-next'
export function useAppToast() {
const { t } = useI18n()
const { show } = useToast()
const { show } = useToastController()
const toastSuccess = (message) => {
toast(message, {
title: t('success'),

View File

@ -4,39 +4,46 @@
// Load Package Details for some default values
const pkg = require('../../package')
const constants = {
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v2.2024-01-04',
CURRENT: '',
},
}
const version = {
ADMIN_MODULE_PROTOCOL: process.env.ADMIN_MODULE_PROTOCOL ?? 'http',
ADMIN_MODULE_HOST: process.env.ADMIN_MODULE_HOST ?? '0.0.0.0',
ADMIN_MODULE_PORT: process.env.ADMIN_MODULE_PORT ?? '8080',
APP_VERSION: pkg.version,
BUILD_COMMIT: process.env.BUILD_COMMIT ?? null,
BUILD_COMMIT: process.env.BUILD_COMMIT ?? undefined,
// self reference of `version.BUILD_COMMIT` is not possible at this point, hence the duplicate code
BUILD_COMMIT_SHORT: (process.env.BUILD_COMMIT ?? '0000000').slice(0, 7),
PORT: process.env.PORT ?? 8080,
}
let ADMIN_MODULE_URL
// in case of hosting the admin module with a nodejs-instance
if (process.env.ADMIN_HOSTING === 'nodejs') {
ADMIN_MODULE_URL =
version.ADMIN_MODULE_PROTOCOL +
'://' +
version.ADMIN_MODULE_HOST +
':' +
version.ADMIN_MODULE_PORT
} else {
// in case of hosting the admin module with a nginx
ADMIN_MODULE_URL = version.ADMIN_MODULE_PROTOCOL + '://' + version.ADMIN_MODULE_HOST
}
const environment = {
NODE_ENV: import.meta.env.NODE_ENV,
DEBUG: import.meta.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: import.meta.env.NODE_ENV === 'production' ?? false,
NODE_ENV: process.env.NODE_ENV,
DEBUG: process.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: process.env.NODE_ENV === 'production' ?? false,
}
const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? undefined
const URL_PROTOCOL = process.env.URL_PROTOCOL ?? 'http'
const COMMUNITY_URL =
COMMUNITY_HOST && URL_PROTOCOL ? URL_PROTOCOL + '://' + COMMUNITY_HOST : undefined
const WALLET_URL = process.env.WALLET_URL ?? COMMUNITY_URL ?? 'http://localhost'
// const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? undefined
// const URL_PROTOCOL = process.env.URL_PROTOCOL ?? 'http'
// const COMMUNITY_URL =
// COMMUNITY_HOST && URL_PROTOCOL ? URL_PROTOCOL + '://' + COMMUNITY_HOST : undefined
const COMMUNITY_URL = process.env.COMMUNITY_URL ?? ADMIN_MODULE_URL
const WALLET_URL = process.env.WALLET_URL ?? COMMUNITY_URL ?? 'http://0.0.0.0'
const endpoints = {
GRAPHQL_URL:
(process.env.GRAPHQL_URL ?? COMMUNITY_URL ?? 'http://localhost:4000') +
process.env.GRAPHQL_PATH ?? '/graphql',
WALLET_AUTH_URL: WALLET_URL + (process.env.WALLET_AUTH_PATH ?? '/authenticate?token={token}'),
GRAPHQL_URI: process.env.GRAPHQL_URL ?? COMMUNITY_URL + (process.env.GRAPHQL_PATH ?? '/graphql'),
WALLET_AUTH_URL: WALLET_URL + (process.env.WALLET_AUTH_PATH ?? '/authenticate?token='),
WALLET_LOGIN_URL: WALLET_URL + (process.env.WALLET_LOGIN_PATH ?? '/login'),
}
@ -44,24 +51,13 @@ const debug = {
DEBUG_DISABLE_AUTH: process.env.DEBUG_DISABLE_AUTH === 'true' ?? false,
}
// Check config version
constants.CONFIG_VERSION.CURRENT = process.env.CONFIG_VERSION ?? constants.CONFIG_VERSION.DEFAULT
if (
![constants.CONFIG_VERSION.EXPECTED, constants.CONFIG_VERSION.DEFAULT].includes(
constants.CONFIG_VERSION.CURRENT,
)
) {
throw new Error(
`Fatal: Config Version incorrect - expected "${constants.CONFIG_VERSION.EXPECTED}" or "${constants.CONFIG_VERSION.DEFAULT}", but found "${constants.CONFIG_VERSION.CURRENT}"`,
)
}
const CONFIG = {
...constants,
...version,
...environment,
...endpoints,
...debug,
ADMIN_MODULE_URL,
COMMUNITY_URL,
}
export default CONFIG
module.exports = CONFIG

106
admin/src/config/schema.js Normal file
View File

@ -0,0 +1,106 @@
const {
APP_VERSION,
BUILD_COMMIT,
BUILD_COMMIT_SHORT,
COMMUNITY_URL,
DEBUG,
GRAPHQL_URI,
NODE_ENV,
PRODUCTION,
} = require('gradido-config/build/src/commonSchema.js')
const Joi = require('joi')
module.exports = Joi.object({
APP_VERSION,
BUILD_COMMIT,
BUILD_COMMIT_SHORT,
COMMUNITY_URL,
DEBUG,
GRAPHQL_URI,
NODE_ENV,
PRODUCTION,
ADMIN_HOSTING: Joi.string()
.valid('nodejs', 'nginx')
.description('set to `nodejs` if admin is hosted by vite with a own nodejs instance')
.optional(),
ADMIN_MODULE_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.when('COMMUNITY_URL', {
is: Joi.exist(),
then: Joi.optional(), // not required if COMMUNITY_URL is provided
otherwise: Joi.required(), // required if COMMUNITY_URL is missing
})
.description("Base Url for reaching admin in browser, only needed if COMMUNITY_URL wasn't set")
.optional(), // optional in general, but conditionally required
ADMIN_MODULE_PROTOCOL: Joi.string()
.when('ADMIN_HOSTING', {
is: Joi.valid('nodejs'),
then: Joi.valid('http').required(),
otherwise: Joi.valid('http', 'https').required(),
})
.description(
`
Protocol for admin module hosting
- it has to be the same as for backend api url and frontend to prevent mixed block errors,
- if admin is served with nodejs:
is have to be http or setup must be updated to include a ssl certificate
`,
)
.default('http')
.required(),
ADMIN_MODULE_HOST: Joi.alternatives()
.try(
Joi.string().valid('localhost').messages({ 'any.invalid': 'Must be localhost' }),
Joi.string()
.ip({ version: ['ipv4'] })
.messages({ 'string.ip': 'Must be a valid IPv4 address' }),
Joi.string().domain().messages({ 'string.domain': 'Must be a valid domain' }),
)
.when('ADMIN_HOSTING', {
is: 'nodejs',
then: Joi.required(),
otherwise: Joi.optional(),
})
.when('COMMUNITY_URL', {
is: null,
then: Joi.required(),
otherwise: Joi.optional(),
})
.description(
'Host (domain, IPv4, or localhost) for the admin, default is 0.0.0.0 for local hosting during develop',
)
.default('0.0.0.0'),
ADMIN_MODULE_PORT: Joi.number()
.integer()
.min(1024)
.max(49151)
.description('Port for hosting Admin with Vite as a Node.js instance, default: 8080')
.default(8080)
.when('ADMIN_HOSTING', {
is: 'nodejs',
then: Joi.required(),
otherwise: Joi.optional(),
}),
WALLET_AUTH_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.description('Extern Url from wallet-frontend for forwarding from admin')
.default('http://0.0.0.0/authenticate?token=')
.required(),
WALLET_LOGIN_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.description('Extern Url from wallet-frontend for forwarding after logout')
.default('http://0.0.0.0/login')
.required(),
DEBUG_DISABLE_AUTH: Joi.boolean()
.description('Flag for disable authorization during development')
.default(false)
.optional(), // true is only allowed in not-production setup
})

View File

@ -6,57 +6,85 @@ import Components from 'unplugin-vue-components/vite'
import IconsResolve from 'unplugin-icons/resolver'
import { BootstrapVueNextResolver } from 'bootstrap-vue-next'
import EnvironmentPlugin from 'vite-plugin-environment'
import schema from './src/config/schema'
import { validate, browserUrls } from 'gradido-config/build/src/index.js'
import dotenv from 'dotenv'
dotenv.config() // load env vars from .env
const CONFIG = require('./src/config')
const path = require('path')
export default defineConfig({
base: '/admin/',
server: {
host: '0.0.0.0',
port: 8080,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
assets: path.join(__dirname, 'src/assets'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
export default defineConfig(({ command }) => {
if (command === 'serve') {
CONFIG.ADMIN_HOSTING = 'nodejs'
} else {
CONFIG.ADMIN_HOSTING = 'nginx'
}
// Check config
validate(schema, CONFIG)
// make sure that all urls used in browser have the same protocol to prevent mixed content errors
validate(browserUrls, [
CONFIG.ADMIN_AUTH_URL,
CONFIG.COMMUNITY_URL,
CONFIG.COMMUNITY_REGISTER_URL,
CONFIG.GRAPHQL_URI,
CONFIG.FRONTEND_MODULE_URL,
])
plugins: [
vue({
template: {
compilerOptions: {
compatConfig: {
MODE: 2,
return {
base: '/admin/',
server: {
host: CONFIG.ADMIN_MODULE_HOST, // '0.0.0.0',
port: CONFIG.ADMIN_MODULE_PORT, // 8080,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
assets: path.join(__dirname, 'src/assets'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
plugins: [
vue({
template: {
compilerOptions: {
compatConfig: {
MODE: 2,
},
},
},
},
}),
Components({
resolvers: [IconsResolve(), BootstrapVueNextResolver()],
dts: true,
}),
Icons({
compiler: 'vue3',
}),
EnvironmentPlugin({
BUILD_COMMIT: null,
PORT: null,
COMMUNITY_HOST: null,
URL_PROTOCOL: null,
WALLET_URL: null,
GRAPHQL_URL: null,
GRAPHQL_PATH: null,
WALLET_AUTH_PATH: null,
WALLET_LOGIN_PATH: null,
DEBUG_DISABLE_AUTH: null,
CONFIG_VERSION: null,
}),
commonjs(),
],
build: {
outDir: path.resolve(__dirname, './build'),
},
publicDir: '/admin',
}),
Components({
resolvers: [IconsResolve(), BootstrapVueNextResolver()],
dts: true,
}),
Icons({
compiler: 'vue3',
}),
EnvironmentPlugin({
BUILD_COMMIT: null,
PORT: CONFIG.ADMIN_MODULE_PORT ?? null, // null,
COMMUNITY_HOST: CONFIG.ADMIN_MODULE_HOST ?? null, // null,
COMMUNITY_URL: CONFIG.COMMUNITY_URL ?? null,
URL_PROTOCOL: CONFIG.ADMIN_MODULE_PROTOCOL ?? null, // null,
WALLET_AUTH_URL: CONFIG.WALLET_AUTH_URL ?? null,
GRAPHQL_URL: CONFIG.GRAPHQL_URI ?? null, // null,
GRAPHQL_PATH: process.env.GRAPHQL_PATH ?? '/graphql', // null,
WALLET_AUTH_PATH: CONFIG.WALLET_AUTH_PATH ?? null,
WALLET_LOGIN_PATH: CONFIG.WALLET_LOGIN_URL ?? null, // null,
DEBUG_DISABLE_AUTH: CONFIG.DEBUG_DISABLE_AUTH ?? null, // null,
// CONFIG_VERSION: CONFIG.CONFIG_VERSION, // null,
}),
commonjs(),
],
build: {
outDir: path.resolve(__dirname, './build'),
chunkSizeWarningLimit: 1600,
},
publicDir: '/admin',
}
})

View File

@ -7,7 +7,7 @@ const CONFIG = require('./src/config')
// vue.config.js
module.exports = {
devServer: {
port: CONFIG.PORT,
port: CONFIG.ADMIN_MODULE_PORT,
},
pluginOptions: {
i18n: {

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ PORT=4000
JWT_SECRET=secret123
JWT_EXPIRES_IN=10m
GRAPHIQL=false
GDT_API_URL=https://gdt.gradido.net
GDT_ACTIVE=false
# Database
DB_HOST=localhost
@ -45,7 +45,7 @@ EMAIL_TEST_RECEIVER=stage1@gradido.net
EMAIL_USERNAME=gradido_email
EMAIL_SENDER=info@gradido.net
EMAIL_PASSWORD=xxx
EMAIL_SMTP_URL=gmail.com
EMAIL_SMTP_HOST=gmail.com
EMAIL_SMTP_PORT=587
EMAIL_LINK_VERIFICATION_PATH=/checkEmail/{optin}{code}
EMAIL_LINK_SETPASSWORD_PATH=/reset-password/{optin}

78
backend/.env.org Normal file
View File

@ -0,0 +1,78 @@
# Server
PORT=4000
JWT_SECRET=secret123
JWT_EXPIRES_IN=10m
GRAPHIQL=false
GDT_API_URL=https://gdt.gradido.net
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=gradido_community
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.backend.log
# Klicktipp
KLICKTIPP=false
KLICKTTIPP_API_URL=https://api.klicktipp.com
KLICKTIPP_USER=gradido_test
KLICKTIPP_PASSWORD=secret321
KLICKTIPP_APIKEY_DE=SomeFakeKeyDE
KLICKTIPP_APIKEY_EN=SomeFakeKeyEN
# DltConnector
DLT_CONNECTOR=true
DLT_CONNECTOR_URL=http://localhost:6010
# Community
COMMUNITY_NAME=Gradido Entwicklung
COMMUNITY_URL=http://localhost
COMMUNITY_REGISTER_PATH=/register
COMMUNITY_REDEEM_PATH=/redeem/{code}
COMMUNITY_REDEEM_CONTRIBUTION_PATH=/redeem/CL-{code}
COMMUNITY_DESCRIPTION=Die lokale Entwicklungsumgebung von Gradido.
COMMUNITY_SUPPORT_MAIL=support@supportmail.com
# Login Server
LOGIN_APP_SECRET=21ffbbc616fe
LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
# EMail
EMAIL=false
EMAIL_TEST_MODUS=false
EMAIL_TEST_RECEIVER=stage1@gradido.net
EMAIL_USERNAME=gradido_email
EMAIL_SENDER=info@gradido.net
EMAIL_PASSWORD=xxx
EMAIL_SMTP_HOST=gmail.com
EMAIL_SMTP_PORT=587
EMAIL_LINK_VERIFICATION_PATH=/checkEmail/{optin}{code}
EMAIL_LINK_SETPASSWORD_PATH=/reset-password/{optin}
EMAIL_LINK_FORGOTPASSWORD_PATH=/forgot-password
EMAIL_LINK_OVERVIEW_PATH=/overview
EMAIL_CODE_VALID_TIME=1440
EMAIL_CODE_REQUEST_TIME=10
# Webhook
WEBHOOK_ELOPAGE_SECRET=secret
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
LOG_LEVEL=INFO
# Federation
FEDERATION_VALIDATE_COMMUNITY_TIMER=60000
FEDERATION_XCOM_SENDCOINS_ENABLED=false
# GMS
# GMS_ACTIVE=true
# Coordinates of Illuminz test instance
#GMS_API_URL=http://54.176.169.179:3071
GMS_API_URL=http://localhost:4044
GMS_DASHBOARD_URL=http://localhost:8080
# HUMHUB
HUMHUB_ACTIVE=true
HUMHUB_API_URL=https://community-test.gradido.net
HUMHUB_JWT_KEY=GwdkIKi-rkRS0mXC4Cg3MYc3ktZh89VFmntDpNKET_dUfcIdjL_957F3nCv3brNtDfbbV81NViKaktUsfExrkH

View File

@ -40,6 +40,7 @@ COMMUNITY_SUPPORT_MAIL=$COMMUNITY_SUPPORT_MAIL
# Login Server
LOGIN_APP_SECRET=21ffbbc616fe
LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
USE_CRYPTO_WORKER=$USE_CRYPTO_WORKER
# EMail
EMAIL=$EMAIL
@ -48,7 +49,7 @@ EMAIL_TEST_RECEIVER=$EMAIL_TEST_RECEIVER
EMAIL_USERNAME=$EMAIL_USERNAME
EMAIL_SENDER=$EMAIL_SENDER
EMAIL_PASSWORD=$EMAIL_PASSWORD
EMAIL_SMTP_URL=$EMAIL_SMTP_URL
EMAIL_SMTP_HOST=$EMAIL_SMTP_HOST
EMAIL_SMTP_PORT=$EMAIL_SMTP_PORT
EMAIL_LINK_VERIFICATION_PATH=$EMAIL_LINK_VERIFICATION_PATH
EMAIL_LINK_SETPASSWORD_PATH=$EMAIL_LINK_SETPASSWORD_PATH

View File

@ -1,6 +1,8 @@
# Server
JWT_EXPIRES_IN=2m
GDT_ACTIVE=false
# Email
EMAIL=true
EMAIL_TEST_MODUS=false

View File

@ -43,6 +43,7 @@ RUN mkdir -p ${DOCKER_WORKDIR}
WORKDIR ${DOCKER_WORKDIR}
RUN mkdir -p /database
RUN mkdir -p /config
##################################################################################
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
@ -55,7 +56,7 @@ FROM base as development
# Run command
# (for development we need to execute yarn install since the
# node_modules are on another volume and need updating)
CMD /bin/sh -c "cd /database && yarn install && yarn build && cd /app && yarn install && yarn run dev"
CMD /bin/sh -c "cd /database && yarn install && yarn build && cd /config && yarn install && cd /app && yarn install && yarn run dev"
##################################################################################
# BUILD (Does contain all files and is therefore bloated) ########################
@ -66,6 +67,11 @@ FROM base as build
COPY ./backend/ ./
# Copy everything from database
COPY ./database/ ../database/
# Copy everything from config
COPY ./config/ ../config/
# yarn install and build config
RUN cd ../config && yarn install --production=false --frozen-lockfile --non-interactive && yarn build
# yarn install backend
RUN yarn install --production=false --frozen-lockfile --non-interactive
@ -74,10 +80,10 @@ RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN cd ../database && yarn install --production=false --frozen-lockfile --non-interactive
# yarn build
RUN yarn run build
RUN yarn build
# yarn build database
RUN cd ../database && yarn run build
RUN cd ../database && yarn build
##################################################################################
# TEST ###########################################################################
@ -95,9 +101,11 @@ FROM base as production
# Copy "binary"-files from build image
COPY --from=build ${DOCKER_WORKDIR}/build ./build
COPY --from=build ${DOCKER_WORKDIR}/../database/build ../database/build
COPY --from=build ${DOCKER_WORKDIR}/../config/build ../config/build
# We also copy the node_modules express and serve-static for the run script
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --from=build ${DOCKER_WORKDIR}/../database/node_modules ../database/node_modules
COPY --from=build ${DOCKER_WORKDIR}/../config/node_modules ../config/node_modules
# Copy static files
# COPY --from=build ${DOCKER_WORKDIR}/public ./public

View File

@ -7,7 +7,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
coverageThreshold: {
global: {
lines: 81,
lines: 80,
},
},
setupFiles: ['<rootDir>/test/testSetup.ts'],
@ -39,5 +39,10 @@ module.exports = {
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1',
'@config/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development'
? '<rootDir>/../config/src/$1'
: '<rootDir>/../config/build/$1',
},
}

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "2.3.1",
"version": "2.4.5",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",
@ -33,12 +33,14 @@
"email-templates": "^10.0.1",
"express": "^4.17.1",
"express-slow-down": "^2.0.1",
"gradido-config": "file:../config",
"gradido-database": "file:../database",
"graphql": "^15.5.1",
"graphql-request": "5.0.0",
"graphql-type-json": "0.3.2",
"helmet": "^5.1.1",
"i18n": "^0.15.1",
"joi": "^17.13.3",
"jose": "^4.14.4",
"lodash.clonedeep": "^4.5.0",
"log4js": "^6.4.6",
@ -50,7 +52,9 @@
"sodium-native": "^3.3.0",
"type-graphql": "^1.1.1",
"typed-rest-client": "^1.8.11",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"workerpool": "^9.2.0",
"xregexp": "^5.1.1"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^3.2.1",
@ -59,8 +63,9 @@
"@types/faker": "^5.5.9",
"@types/i18n": "^0.13.4",
"@types/jest": "^27.0.2",
"@types/joi": "^17.2.3",
"@types/lodash.clonedeep": "^4.5.6",
"@types/node": "^16.10.3",
"@types/node": "^17.0.21",
"@types/nodemailer": "^6.4.4",
"@types/sodium-native": "^2.3.5",
"@types/uuid": "^8.3.4",

View File

@ -0,0 +1,5 @@
import { Agent } from 'http'
import { Agent as HttpsAgent } from 'https'
export const httpAgent = new Agent({ keepAlive: true })
export const httpsAgent = new HttpsAgent({ keepAlive: true })

View File

@ -7,10 +7,12 @@ import axios from 'axios'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { httpAgent, httpsAgent } from './ConnectionAgents'
export const apiPost = async (url: string, payload: unknown): Promise<any> => {
logger.trace('POST', url, payload)
try {
const result = await axios.post(url, payload)
const result = await axios.post(url, payload, { httpAgent, httpsAgent })
logger.trace('POST-Response', result)
if (result.status !== 200) {
throw new LogError('HTTP Status Error', result.status)
@ -27,7 +29,7 @@ export const apiPost = async (url: string, payload: unknown): Promise<any> => {
export const apiGet = async (url: string): Promise<any> => {
logger.trace('GET: url=' + url)
try {
const result = await axios.get(url)
const result = await axios.get(url, { httpAgent, httpsAgent })
logger.trace('GET-Response', result)
if (result.status !== 200) {
throw new LogError('HTTP Status Error', result.status)

View File

@ -4,6 +4,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import axios from 'axios'
import { httpAgent, httpsAgent } from '@/apis/ConnectionAgents'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
@ -126,9 +127,10 @@ export async function createGmsUser(apiKey: string, user: GmsUser): Promise<bool
accept: 'application/json',
language: 'en',
timezone: 'UTC',
connection: 'keep-alive',
authorization: apiKey,
},
httpAgent,
httpsAgent,
}
try {
const result = await axios.post(baseUrl.concat(service), user, config)
@ -160,9 +162,10 @@ export async function updateGmsUser(apiKey: string, user: GmsUser): Promise<bool
accept: 'application/json',
language: 'en',
timezone: 'UTC',
connection: 'keep-alive',
authorization: apiKey,
},
httpAgent,
httpsAgent,
}
try {
const result = await axios.patch(baseUrl.concat(service), user, config)
@ -197,9 +200,10 @@ export async function verifyAuthToken(
accept: 'application/json',
language: 'en',
timezone: 'UTC',
connection: 'keep-alive',
// authorization: apiKey,
},
httpAgent,
httpsAgent,
}
try {
const result = await axios.get(baseUrl.concat(service), config)

View File

@ -28,8 +28,8 @@ export class GmsUser {
status: number
createdAt: Date
updatedAt: Date
firstName: string | undefined
lastName: string | undefined
firstName: string | null | undefined
lastName: string | null | undefined
alias: string | undefined
type: number
address: string | undefined
@ -48,9 +48,19 @@ export class GmsUser {
) {
return user.alias
}
if (
user.gmsAllowed &&
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return (
this.firstUpperCaseSecondLowerCase(user.firstName) +
this.firstUpperCaseSecondLowerCase(user.lastName)
)
}
}
private getGmsFirstName(user: dbUser): string | undefined {
private getGmsFirstName(user: dbUser): string | null | undefined {
if (
user.gmsAllowed &&
(user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST ||
@ -64,22 +74,30 @@ export class GmsUser {
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return user.firstName.substring(0, 1)
// return this.firstUpperCaseSecondLowerCase(user.firstName)
return null // cause to delete firstname in gms
}
}
private getGmsLastName(user: dbUser): string | undefined {
private getGmsLastName(user: dbUser): string | null | undefined {
if (user.gmsAllowed && user.gmsPublishName === PublishNameType.PUBLISH_NAME_FULL) {
return user.lastName
}
if (user.gmsAllowed && user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST_INITIAL) {
return this.firstUpperCaseSecondLowerCase(user.lastName)
}
return null // cause to delete lastname in gms
/*
if (
user.gmsAllowed &&
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST_INITIAL ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return user.lastName.substring(0, 1)
return this.firstUpperCaseSecondLowerCase(user.lastName)
}
*/
}
private getGmsEmail(user: dbUser): string | undefined {
@ -106,4 +124,11 @@ export class GmsUser {
return user.emailContact.phone
}
}
private firstUpperCaseSecondLowerCase(name: string) {
if (name && name.length >= 2) {
return name.charAt(0).toUpperCase() + name.charAt(1).toLocaleLowerCase()
}
return name
}
}

View File

@ -9,9 +9,11 @@ import { checkDBVersion } from '@/typeorm/DBVersion'
import { HumHubClient } from './HumHubClient'
import { GetUser } from './model/GetUser'
import { UsersResponse } from './model/UsersResponse'
import { ExecutedHumhubAction, syncUser } from './syncUser'
const USER_BULK_SIZE = 20
const HUMHUB_BULK_SIZE = 50
function getUsersPage(page: number, limit: number): Promise<[User[], number]> {
return User.findAndCount({
@ -29,24 +31,28 @@ function getUsersPage(page: number, limit: number): Promise<[User[], number]> {
async function loadUsersFromHumHub(client: HumHubClient): Promise<Map<string, GetUser>> {
const start = new Date().getTime()
const humhubUsers = new Map<string, GetUser>()
const firstPage = await client.users(0, 50)
if (!firstPage) {
throw new LogError('not a single user found on humhub, please check config and setup')
}
firstPage.results.forEach((user) => {
humhubUsers.set(user.account.email.trim(), user)
})
let page = 1
while (humhubUsers.size < firstPage.total) {
const usersPage = await client.users(page, 50)
let page = 0
let skippedUsersCount = 0
let usersPage: UsersResponse | null = null
do {
usersPage = await client.users(page, HUMHUB_BULK_SIZE)
if (!usersPage) {
throw new LogError('error requesting next users page from humhub')
}
usersPage.results.forEach((user) => {
humhubUsers.set(user.account.email.trim(), user)
// deleted users have empty emails
if (user.account.email) {
humhubUsers.set(user.account.email.trim(), user)
} else {
skippedUsersCount++
}
})
page++
}
process.stdout.write(
`load users from humhub: ${humhubUsers.size}/${usersPage.total}, skipped: ${skippedUsersCount}\r`,
)
} while (usersPage && usersPage.results.length === HUMHUB_BULK_SIZE)
const elapsed = new Date().getTime() - start
logger.info('load users from humhub', {
total: humhubUsers.size,

View File

@ -20,7 +20,9 @@ export class HumHubClient {
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {
this.restClient = new RestClient('gradido-backend', CONFIG.HUMHUB_API_URL)
this.restClient = new RestClient('gradido-backend', CONFIG.HUMHUB_API_URL, undefined, {
keepAlive: true,
})
logger.info('create rest client for', CONFIG.HUMHUB_API_URL)
}

View File

@ -0,0 +1,23 @@
import { User } from '@entity/User'
import { PublishNameLogic } from '@/data/PublishName.logic'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { Account } from './Account'
import { Profile } from './Profile'
export abstract class AbstractUser {
public constructor(user: User) {
this.account = new Account(user)
this.profile = new Profile(user)
// temp fix for prevent double usernames in humhub, if the username ist created from initials
const publishNameLogic = new PublishNameLogic(user)
if (publishNameLogic.isUsernameFromInitials(user.humhubPublishName as PublishNameType)) {
this.profile.firstname = this.account.username
this.account.username = user.gradidoID
}
}
account: Account
profile: Profile
}

View File

@ -2,15 +2,13 @@
import { User } from '@entity/User'
import { convertGradidoLanguageToHumhub } from '@/apis/humhub/convertLanguage'
import { PublishNameLogic } from '@/data/PublishName.logic'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
export class Account {
public constructor(user: User) {
if (user.alias && user.alias.length > 2) {
this.username = user.alias
} else {
this.username = user.gradidoID
}
const publishNameLogic = new PublishNameLogic(user)
this.username = publishNameLogic.getUsername(user.humhubPublishName as PublishNameType)
this.email = user.emailContact.email
this.language = convertGradidoLanguageToHumhub(user.language)
this.status = 1

View File

@ -1,19 +1,15 @@
import { User } from '@entity/User'
import { Account } from './Account'
import { Profile } from './Profile'
import { AbstractUser } from './AbstractUser'
export class GetUser {
export class GetUser extends AbstractUser {
public constructor(user: User, id: number) {
super(user)
this.id = id
this.account = new Account(user)
this.profile = new Profile(user)
}
id: number
guid: string
// eslint-disable-next-line camelcase
display_name: string
account: Account
profile: Profile
}

View File

@ -1,16 +1,7 @@
import { User } from '@entity/User'
import { Account } from './Account'
import { AbstractUser } from './AbstractUser'
import { Password } from './Password'
import { Profile } from './Profile'
export class PostUser {
public constructor(user: User) {
this.account = new Account(user)
this.profile = new Profile(user)
}
account: Account
profile: Profile
// only add password as filed, rest the same as AbstractUser
export class PostUser extends AbstractUser {
password: Password
}

View File

@ -10,7 +10,10 @@ export class Profile {
const publishNameLogic = new PublishNameLogic(user)
this.firstname = publishNameLogic.getFirstName(user.humhubPublishName as PublishNameType)
this.lastname = publishNameLogic.getLastName(user.humhubPublishName as PublishNameType)
this.gradido_address = CONFIG.COMMUNITY_NAME + '/' + user.gradidoID
this.gradido_address = `${CONFIG.COMMUNITY_NAME}/${
publishNameLogic.hasAlias() ? user.alias : user.gradidoID
}`
}
firstname: string

View File

@ -1,9 +1,14 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
/* eslint-disable n/no-process-env */
// eslint-disable-next-line import/no-unresolved
import { validate } from '@config/index'
import { latestDbVersion } from '@dbTools/config/detectLastDBVersion'
import { Decimal } from 'decimal.js-light'
import dotenv from 'dotenv'
import { schema } from './schema'
dotenv.config()
Decimal.set({
@ -12,16 +17,10 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0086-add_community_location',
// DB_VERSION: '0087-add_index_on_user_roles',
DB_VERSION: latestDbVersion,
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v23.2024-04-04',
CURRENT: '',
},
}
const server = {
@ -29,8 +28,11 @@ const server = {
JWT_SECRET: process.env.JWT_SECRET ?? 'secret123',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '10m',
GRAPHIQL: process.env.GRAPHIQL === 'true' || false,
GDT_ACTIVE: process.env.GDT_ACTIVE === 'true' || false,
GDT_API_URL: process.env.GDT_API_URL ?? 'https://gdt.gradido.net',
PRODUCTION: process.env.NODE_ENV === 'production' || false,
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',
}
const database = {
@ -64,10 +66,9 @@ const dltConnector = {
const community = {
COMMUNITY_NAME: process.env.COMMUNITY_NAME ?? 'Gradido Entwicklung',
COMMUNITY_URL,
COMMUNITY_REGISTER_URL: COMMUNITY_URL + (process.env.COMMUNITY_REGISTER_PATH ?? '/register'),
COMMUNITY_REDEEM_URL: COMMUNITY_URL + (process.env.COMMUNITY_REDEEM_PATH ?? '/redeem/{code}'),
COMMUNITY_REDEEM_URL: COMMUNITY_URL + (process.env.COMMUNITY_REDEEM_PATH ?? '/redeem/'),
COMMUNITY_REDEEM_CONTRIBUTION_URL:
COMMUNITY_URL + (process.env.COMMUNITY_REDEEM_CONTRIBUTION_PATH ?? '/redeem/CL-{code}'),
COMMUNITY_URL + (process.env.COMMUNITY_REDEEM_CONTRIBUTION_PATH ?? '/redeem/CL-'),
COMMUNITY_DESCRIPTION:
process.env.COMMUNITY_DESCRIPTION ?? 'Die lokale Entwicklungsumgebung von Gradido.',
COMMUNITY_SUPPORT_MAIL: process.env.COMMUNITY_SUPPORT_MAIL ?? 'support@supportmail.com',
@ -76,6 +77,7 @@ const community = {
const loginServer = {
LOGIN_APP_SECRET: process.env.LOGIN_APP_SECRET ?? '21ffbbc616fe',
LOGIN_SERVER_KEY: process.env.LOGIN_SERVER_KEY ?? 'a51ef8ac7ef1abf162fb7a65261acd7a',
USE_CRYPTO_WORKER: process.env.USE_CRYPTO_WORKER ?? false,
}
const email = {
@ -85,14 +87,14 @@ const email = {
EMAIL_USERNAME: process.env.EMAIL_USERNAME ?? '',
EMAIL_SENDER: process.env.EMAIL_SENDER ?? 'info@gradido.net',
EMAIL_PASSWORD: process.env.EMAIL_PASSWORD ?? '',
EMAIL_SMTP_URL: process.env.EMAIL_SMTP_URL ?? 'mailserver',
EMAIL_SMTP_HOST: process.env.EMAIL_SMTP_HOST ?? 'mailserver',
EMAIL_SMTP_PORT: Number(process.env.EMAIL_SMTP_PORT) || 1025,
// eslint-disable-next-line no-unneeded-ternary
EMAIL_TLS: process.env.EMAIL_TLS === 'false' ? false : true,
EMAIL_LINK_VERIFICATION:
COMMUNITY_URL + (process.env.EMAIL_LINK_VERIFICATION_PATH ?? '/checkEmail/{optin}{code}'),
COMMUNITY_URL + (process.env.EMAIL_LINK_VERIFICATION_PATH ?? '/checkEmail/'),
EMAIL_LINK_SETPASSWORD:
COMMUNITY_URL + (process.env.EMAIL_LINK_SETPASSWORD_PATH ?? '/reset-password/{optin}'),
COMMUNITY_URL + (process.env.EMAIL_LINK_SETPASSWORD_PATH ?? '/reset-password/'),
EMAIL_LINK_FORGOTPASSWORD:
COMMUNITY_URL + (process.env.EMAIL_LINK_FORGOTPASSWORD_PATH ?? '/forgot-password'),
EMAIL_LINK_OVERVIEW: COMMUNITY_URL + (process.env.EMAIL_LINK_OVERVIEW_PATH ?? '/overview'),
@ -114,18 +116,6 @@ const webhook = {
// This is needed by graphql-directive-auth
process.env.APP_SECRET = server.JWT_SECRET
// Check config version
constants.CONFIG_VERSION.CURRENT = process.env.CONFIG_VERSION ?? constants.CONFIG_VERSION.DEFAULT
if (
![constants.CONFIG_VERSION.EXPECTED, constants.CONFIG_VERSION.DEFAULT].includes(
constants.CONFIG_VERSION.CURRENT,
)
) {
throw new Error(
`Fatal: Config Version incorrect - expected "${constants.CONFIG_VERSION.EXPECTED}" or "${constants.CONFIG_VERSION.DEFAULT}", but found "${constants.CONFIG_VERSION.CURRENT}"`,
)
}
const federation = {
FEDERATION_BACKEND_SEND_ON_API: process.env.FEDERATION_BACKEND_SEND_ON_API ?? '1_0',
// ?? operator don't work here as expected
@ -171,3 +161,5 @@ export const CONFIG = {
...gms,
...humhub,
}
validate(schema, CONFIG)

View File

@ -0,0 +1,355 @@
// eslint-disable-next-line import/no-unresolved
import {
COMMUNITY_NAME,
COMMUNITY_URL,
COMMUNITY_DESCRIPTION,
COMMUNITY_SUPPORT_MAIL,
DB_HOST,
DB_PASSWORD,
DB_PORT,
DB_USER,
DB_VERSION,
DB_DATABASE,
DECAY_START_TIME,
GDT_API_URL,
GDT_ACTIVE,
GMS_ACTIVE,
GRAPHIQL,
HUMHUB_ACTIVE,
LOG4JS_CONFIG,
LOGIN_APP_SECRET,
LOGIN_SERVER_KEY,
LOG_LEVEL,
NODE_ENV,
PRODUCTION,
TYPEORM_LOGGING_RELATIVE_PATH,
} from '@config/commonSchema'
import Joi from 'joi'
export const schema = Joi.object({
COMMUNITY_NAME,
COMMUNITY_URL,
COMMUNITY_DESCRIPTION,
COMMUNITY_SUPPORT_MAIL,
DB_HOST,
DB_PASSWORD,
DB_PORT,
DB_USER,
DB_VERSION,
DB_DATABASE,
DECAY_START_TIME,
GDT_API_URL,
GDT_ACTIVE,
GMS_ACTIVE,
GRAPHIQL,
HUMHUB_ACTIVE,
LOG4JS_CONFIG,
LOGIN_APP_SECRET,
LOGIN_SERVER_KEY,
LOG_LEVEL,
NODE_ENV,
PRODUCTION,
TYPEORM_LOGGING_RELATIVE_PATH,
COMMUNITY_REDEEM_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.description('The url for redeeming link transactions, must start with frontend base url')
.default('http://0.0.0.0/redeem/')
.custom((value: string, helpers: Joi.CustomHelpers<string>): string | Joi.ErrorReport => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
if (!value.startsWith(helpers.state.ancestors[0].COMMUNITY_URL)) {
return helpers.error('string.pattern.base', { value, communityUrl: COMMUNITY_URL })
}
return value
})
.required(),
COMMUNITY_REDEEM_CONTRIBUTION_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.custom((value: string, helpers: Joi.CustomHelpers<string>): string | Joi.ErrorReport => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
if (!value.startsWith(helpers.state.ancestors[0].COMMUNITY_URL)) {
return helpers.error('string.pattern.base', { value, communityUrl: COMMUNITY_URL })
}
return value
})
.description(
'The url for redeeming contribution link transactions, must start with frontend base url.',
)
.default('http://0.0.0.0/redeem/CL-')
.required(),
DLT_CONNECTOR: Joi.boolean()
.description('Flag to indicate if DLT-Connector is used. (Still in development)')
.default(false)
.required(),
DLT_CONNECTOR_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.default('http://localhost:6010')
.when('DLT_CONNECTOR', { is: true, then: Joi.required() })
.description('The URL for GDT API endpoint'),
EMAIL: Joi.boolean()
.default(false)
.description('Enable or disable email functionality')
.required(),
EMAIL_TEST_MODUS: Joi.boolean()
.default(false)
.description('When enabled, all emails are sended to EMAIL_TEST_RECEIVER')
.optional(),
EMAIL_TEST_RECEIVER: Joi.string()
.email()
.default('stage1@gradido.net')
.when('EMAIL_TEST_MODUS', { is: true, then: Joi.required() })
.description('Email address used in test mode'),
EMAIL_USERNAME: Joi.alternatives().conditional(Joi.ref('EMAIL'), {
is: true,
then: Joi.alternatives().conditional(Joi.ref('NODE_ENV'), {
is: 'development',
then: Joi.string()
.allow('')
.description('Username for SMTP authentication (optional in development)'),
otherwise: Joi.string()
.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
.description('Valid SMTP username required in production')
.required(),
}),
otherwise: Joi.string().allow('').optional(),
}),
EMAIL_SENDER: Joi.string()
.email()
.when('EMAIL', { is: true, then: Joi.required() })
.default('info@gradido.net')
.description('Email address used as sender'),
EMAIL_PASSWORD: Joi.alternatives().conditional(Joi.ref('EMAIL'), {
is: true,
then: Joi.alternatives().conditional(Joi.ref('NODE_ENV'), {
is: 'development',
then: Joi.string()
.allow('')
.description('Password for SMTP authentication (optional in development)'),
otherwise: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#]).{8,}$/)
.description(
'Password must be at least 8 characters long, include uppercase and lowercase letters, a number, and a special character',
)
.required(),
}),
otherwise: Joi.string().allow('').optional(),
}),
EMAIL_SMTP_HOST: Joi.string()
.hostname()
.when('EMAIL', { is: true, then: Joi.required() })
.default('mailserver')
.description('SMTP server hostname'),
EMAIL_SMTP_PORT: Joi.number()
.integer()
.positive()
.when('EMAIL', { is: true, then: Joi.required() })
.default(1025)
.description('SMTP server port'),
EMAIL_TLS: Joi.boolean().default(true).description('Enable or disable TLS for SMTP').optional(),
EMAIL_LINK_VERIFICATION: Joi.string()
.uri({ scheme: ['http', 'https'] })
.custom((value: string, helpers: Joi.CustomHelpers<string>): string | Joi.ErrorReport => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
if (!value.startsWith(helpers.state.ancestors[0].COMMUNITY_URL)) {
return helpers.error('string.pattern.base', { value, communityUrl: COMMUNITY_URL })
}
return value
})
.description('Email Verification link for activate Email.')
.required(),
EMAIL_LINK_SETPASSWORD: Joi.string()
.uri({ scheme: ['http', 'https'] })
.custom((value: string, helpers: Joi.CustomHelpers<string>): string | Joi.ErrorReport => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
if (!value.startsWith(helpers.state.ancestors[0].COMMUNITY_URL)) {
return helpers.error('string.pattern.base', { value, communityUrl: COMMUNITY_URL })
}
return value
})
.description('Email Verification link for set initial Password.')
.required(),
EMAIL_LINK_FORGOTPASSWORD: Joi.string()
.uri({ scheme: ['http', 'https'] })
.custom((value: string, helpers: Joi.CustomHelpers<string>): string | Joi.ErrorReport => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
if (!value.startsWith(helpers.state.ancestors[0].COMMUNITY_URL)) {
return helpers.error('string.pattern.base', { value, communityUrl: COMMUNITY_URL })
}
return value
})
.description('Email Verification link for set new Password, when old Password was forgotten.')
.required(),
EMAIL_LINK_OVERVIEW: Joi.string()
.uri({ scheme: ['http', 'https'] })
.custom((value: string, helpers: Joi.CustomHelpers<string>): string | Joi.ErrorReport => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
if (!value.startsWith(helpers.state.ancestors[0].COMMUNITY_URL)) {
return helpers.error('string.pattern.base', { value, communityUrl: COMMUNITY_URL })
}
return value
})
.description('Link to Wallet Overview')
.required(),
EMAIL_CODE_VALID_TIME: Joi.number()
.integer()
.positive()
.max(43200) // max at 30 days
.default(1440)
.description('Time in minutes a code is valid')
.required(),
EMAIL_CODE_REQUEST_TIME: Joi.number()
.integer()
.positive()
.max(43200) // max at 30 days
.default(10)
.description('Time in minutes before a new code can be requested')
.required(),
FEDERATION_BACKEND_SEND_ON_API: Joi.string()
.pattern(/^\d+_\d+$/)
.default('1_0')
.description('API Version of sending requests to another communities, e.g., "1_0"')
.required(),
FEDERATION_VALIDATE_COMMUNITY_TIMER: Joi.number()
.integer()
.min(1000)
.default(60000)
.description('Timer interval in milliseconds for community validation')
.required(),
FEDERATION_XCOM_SENDCOINS_ENABLED: Joi.boolean()
.default(false)
.description('Enable or disable the federation send coins feature')
.optional(),
FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID: Joi.string()
.uuid()
.default('56a55482-909e-46a4-bfa2-cd025e894ebc')
.description(
'UUID of the receiver community for federation cross-community transactions if the receiver is unknown',
)
.required(),
FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS: Joi.number()
.integer()
.min(0)
.default(3)
.description('Maximum number of retries for reverting send coins transactions')
.required(),
GMS_CREATE_USER_THROW_ERRORS: Joi.boolean()
.default(false)
.when('GMS_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional() })
.description('Whether errors should be thrown when creating users in GMS'),
GMS_API_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.when('GMS_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional() })
.default('http://localhost:4044/')
.description('The API URL for the GMS service'),
GMS_DASHBOARD_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.when('GMS_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional() })
.default('http://localhost:8080/')
.description('The URL for the GMS dashboard'),
GMS_WEBHOOK_SECRET: Joi.string()
.min(1)
.default('secret')
.when('GMS_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional() })
.description('The secret postfix for the GMS webhook endpoint'),
HUMHUB_API_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.when('HUMHUB_ACTIVE', { is: true, then: Joi.required(), otherwise: Joi.optional() })
.description('The API URL for HumHub integration'),
HUMHUB_JWT_KEY: Joi.string()
.min(1)
.when('HUMHUB_ACTIVE', {
is: true,
then: Joi.required(),
otherwise: Joi.string().allow('').optional(),
})
.description('JWT key for HumHub integration, must be the same as configured in humhub'),
PORT: Joi.number()
.integer()
.min(1024)
.max(49151)
.description('Port for hosting backend, default: 4000')
.default(4000)
.required(),
KLICKTIPP: Joi.boolean()
.default(false)
.description("Indicates whether Klicktipp integration is enabled, 'true' or 'false'"),
KLICKTTIPP_API_URL: Joi.string()
.uri({ scheme: ['https'] }) // Sicherstellen, dass es eine gültige HTTPS-URL ist
.default('https://api.klicktipp.com')
.description("The API URL for Klicktipp, must be a valid URL starting with 'https'"),
KLICKTIPP_USER: Joi.string().default('gradido_test').description('The username for Klicktipp'),
KLICKTIPP_PASSWORD: Joi.string()
.min(6)
.default('secret321')
.description('The password for Klicktipp, should be at least 6 characters'),
KLICKTIPP_APIKEY_DE: Joi.string()
.default('SomeFakeKeyDE')
.description('The API key for Klicktipp (German version)'),
KLICKTIPP_APIKEY_EN: Joi.string()
.default('SomeFakeKeyEN')
.description('The API key for Klicktipp (English version)'),
USE_CRYPTO_WORKER: Joi.boolean()
.default(false)
.description(
'Flag to enable or disable password encryption in separate thread, should be enabled if possible',
),
// TODO: check format
JWT_SECRET: Joi.string()
.default('secret123')
.description('jwt secret for jwt tokens used for login')
.required(),
JWT_EXPIRES_IN: Joi.alternatives()
.try(
Joi.string()
.pattern(/^\d+[smhdw]$/) // Time specification such as “10m”, “1h”, “2d”, etc.
.description('Expiration time for JWT login token, in format like "10m", "1h", "1d"')
.default('10m'),
Joi.number()
.positive() // positive number to accept seconds
.description('Expiration time for JWT login token in seconds'),
)
.required()
.description('Time for JWT token to expire, auto logout'),
WEBHOOK_ELOPAGE_SECRET: Joi.string().description("isn't really used any more").optional(),
})

View File

@ -0,0 +1,23 @@
import { User } from '@entity/User'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { PublishNameLogic } from './PublishName.logic'
describe('test publish name logic', () => {
describe('test username', () => {
it('alias or initials with alias set', () => {
const user = new User()
user.alias = 'alias'
const logic = new PublishNameLogic(user)
expect(logic.getUsername(PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS)).toBe(user.alias)
})
it('alias or initials with empty alias', () => {
const user = new User()
user.firstName = 'John'
user.lastName = 'Smith'
const logic = new PublishNameLogic(user)
expect(logic.getUsername(PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS)).toBe('JoSm')
})
})
})

View File

@ -1,61 +1,91 @@
import { User } from '@entity/User'
import XRegExp from 'xregexp'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
export class PublishNameLogic {
// allowed characters for humhub usernames
private usernameRegex: RegExp = XRegExp('[\\p{L}\\d_\\-@\\.]', 'g')
constructor(private user: User) {}
private firstUpperCaseSecondLowerCase(name: string) {
if (name && name.length >= 2) {
return name.charAt(0).toUpperCase() + name.charAt(1).toLocaleLowerCase()
}
return name
}
// remove character which are invalid for humhub username
private filterOutInvalidChar(name: string) {
// eslint-disable-next-line import/no-named-as-default-member
return XRegExp.match(name, this.usernameRegex, 'all').join('')
}
public hasAlias(): boolean {
if (this.user.alias && this.user.alias.length >= 3) {
return true
}
return false
}
/**
* get first name based on publishNameType: PublishNameType value
* @param publishNameType
* @returns user.firstName for PUBLISH_NAME_FIRST, PUBLISH_NAME_FIRST_INITIAL or PUBLISH_NAME_FULL
* first initial from user.firstName for PUBLISH_NAME_INITIALS or PUBLISH_NAME_INITIAL_LAST
*/
public getFirstName(publishNameType: PublishNameType): string {
if (
[
PublishNameType.PUBLISH_NAME_FIRST,
PublishNameType.PUBLISH_NAME_FIRST_INITIAL,
PublishNameType.PUBLISH_NAME_FULL,
].includes(publishNameType)
) {
return this.user.firstName
}
if (PublishNameType.PUBLISH_NAME_INITIALS === publishNameType) {
return this.user.firstName.substring(0, 1)
}
if (PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS === publishNameType) {
if (this.user.alias) {
return this.user.alias
} else {
return this.user.firstName.substring(0, 1)
}
}
return ''
return [
PublishNameType.PUBLISH_NAME_FIRST,
PublishNameType.PUBLISH_NAME_FIRST_INITIAL,
PublishNameType.PUBLISH_NAME_FULL,
].includes(publishNameType)
? this.user.firstName
: ''
}
/**
* get last name based on publishNameType: GmsPublishNameType value
* @param publishNameType
* @returns user.lastName for PUBLISH_NAME_LAST, PUBLISH_NAME_INITIAL_LAST, PUBLISH_NAME_FULL
* first initial from user.lastName for PUBLISH_NAME_FIRST_INITIAL, PUBLISH_NAME_INITIALS
* @returns user.lastName for PUBLISH_NAME_LAST, PUBLISH_NAME_FULL
* first initial from user.lastName for PUBLISH_NAME_FIRST_INITIAL
*/
public getLastName(publishNameType: PublishNameType): string {
if (PublishNameType.PUBLISH_NAME_FULL === publishNameType) {
return this.user.lastName
} else if (
[PublishNameType.PUBLISH_NAME_FIRST_INITIAL, PublishNameType.PUBLISH_NAME_INITIALS].includes(
publishNameType,
)
) {
return this.user.lastName.substring(0, 1)
} else if (
PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS === publishNameType &&
!this.user.alias
) {
return this.user.lastName.substring(0, 1)
}
return publishNameType === PublishNameType.PUBLISH_NAME_FULL
? this.user.lastName
: publishNameType === PublishNameType.PUBLISH_NAME_FIRST_INITIAL
? this.user.lastName.charAt(0)
: ''
}
return ''
/**
* get username from user.alias for PUBLISH_NAME_ALIAS_OR_INITALS and if user has alias
* get first name first two characters and last name first two characters for PUBLISH_NAME_ALIAS_OR_INITALS
* if no alias or PUBLISH_NAME_INITIALS
* @param publishNameType
* @returns user.alias for publishNameType = PUBLISH_NAME_ALIAS_OR_INITALS and user has alias
* else return user.firstName[0,2] + user.lastName[0,2] for publishNameType = [PUBLISH_NAME_ALIAS_OR_INITALS, PUBLISH_NAME_INITIALS]
*/
public getUsername(publishNameType: PublishNameType): string {
if (this.isUsernameFromInitials(publishNameType)) {
return (
this.firstUpperCaseSecondLowerCase(this.filterOutInvalidChar(this.user.firstName)) +
this.firstUpperCaseSecondLowerCase(this.filterOutInvalidChar(this.user.lastName))
)
} else if (this.isUsernameFromAlias(publishNameType)) {
return this.filterOutInvalidChar(this.user.alias)
}
return this.user.gradidoID
}
public isUsernameFromInitials(publishNameType: PublishNameType): boolean {
return (
PublishNameType.PUBLISH_NAME_INITIALS === publishNameType ||
(PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS === publishNameType && !this.hasAlias())
)
}
public isUsernameFromAlias(publishNameType: PublishNameType): boolean {
return PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS === publishNameType && this.hasAlias()
}
}

View File

@ -124,6 +124,10 @@ exports[`sendEmailVariants sendAccountActivationEmail result has the correct htm
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -160,7 +164,8 @@ If the validity of the link has already expired, you can have a new link sent to
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -295,6 +300,10 @@ exports[`sendEmailVariants sendAccountMultiRegistrationEmail calls "sendEmailTra
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -329,7 +338,8 @@ exports[`sendEmailVariants sendAccountMultiRegistrationEmail calls "sendEmailTra
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -464,6 +474,10 @@ exports[`sendEmailVariants sendAddedContributionMessageEmail result has the corr
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -495,7 +509,8 @@ exports[`sendEmailVariants sendAddedContributionMessageEmail result has the corr
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -630,6 +645,10 @@ exports[`sendEmailVariants sendContributionChangedByModeratorEmail result has th
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -662,7 +681,8 @@ exports[`sendEmailVariants sendContributionChangedByModeratorEmail result has th
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -797,6 +817,10 @@ exports[`sendEmailVariants sendContributionConfirmedEmail result has the correct
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -829,7 +853,8 @@ exports[`sendEmailVariants sendContributionConfirmedEmail result has the correct
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -964,6 +989,10 @@ exports[`sendEmailVariants sendContributionDeletedEmail result has the correct h
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -996,7 +1025,8 @@ exports[`sendEmailVariants sendContributionDeletedEmail result has the correct h
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1131,6 +1161,10 @@ exports[`sendEmailVariants sendContributionDeniedEmail result has the correct ht
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -1163,7 +1197,8 @@ exports[`sendEmailVariants sendContributionDeniedEmail result has the correct ht
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1298,6 +1333,10 @@ exports[`sendEmailVariants sendResetPasswordEmail result has the correct html as
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -1334,7 +1373,8 @@ If the validity of the link has already expired, you can have a new link sent to
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1469,6 +1509,10 @@ exports[`sendEmailVariants sendTransactionLinkRedeemedEmail result has the corre
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -1501,7 +1545,8 @@ exports[`sendEmailVariants sendTransactionLinkRedeemedEmail result has the corre
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>
@ -1636,6 +1681,10 @@ exports[`sendEmailVariants sendTransactionReceivedEmail result has the correct h
line-break: anywhere;
margin-bottom: 40px;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.slink {
width: 150px;
}
@ -1650,13 +1699,17 @@ exports[`sendEmailVariants sendTransactionReceivedEmail result has the correct h
<h2 style=\\"margin-top: 15px; color: #383838;\\">Bibi Bloxberg has sent you 37.40 Gradido</h2>
<div class=\\"text-block\\" style=\\"margin-top: 20px; color: #9ca0a8;\\">
<p>Hello Peter Lustig,</p>
<p>You have just received 37.40 GDD from Bibi Bloxberg (bibi@bloxberg.de).</p>
<p> You have just received 37.40 GDD from Bibi Bloxberg (<a href=\\"mailto:bibi@bloxberg.de?subject=RE%3A%20Bibi%20Bloxberg%20has%20sent%20you%2037.40%20Gradido\\">bibi@bloxberg.de</a>).
</p>
</div>
<div class=\\"content\\" style=\\"display: block; width: 78%; margin: 40px 1% 40px 1%; padding: 20px 10% 40px 10%; border-radius: 24px; background-image: linear-gradient(180deg, #f5f5f5, #f5f5f5);\\">
<h2 style=\\"margin-top: 15px; color: #383838;\\">Transaction details</h2>
<div class=\\"p_content\\" style=\\"margin: 15px 0 15px 0; line-height: 26px; color: #9ca0a8;\\">You can find transaction details in your Gradido account.</div><a class=\\"button-3\\" href=\\"http://localhost/transactions\\" style=\\"display: inline-block; padding: 9px 15px; color: white; border: 0; line-height: inherit; text-decoration: none; cursor: pointer; border-radius: 20px; background-image: radial-gradient(circle farthest-corner at 0% 0%, #f9cd69, #c58d38); box-shadow: 16px 13px 35px 0 rgba(56, 56, 56, 0.3); margin: 25px 0 25px 0; width: 50%;\\">To account</a>
<div class=\\"p_content\\" style=\\"margin: 15px 0 15px 0; line-height: 26px; color: #9ca0a8;\\">Please do not reply to this email.</div>
</div>
<h2 style=\\"margin-top: 15px; color: #383838;\\">Message</h2>
<div class=\\"child-left\\" style=\\"text-align: left;\\">
<div class=\\"p_content\\" style=\\"margin: 15px 0 15px 0; line-height: 26px; color: #9ca0a8;\\">Du bist schon lustiger ;)</div>
</div>
<div class=\\"child-right\\" style=\\"text-align: right;\\"><a class=\\"button-5\\" href=\\"mailto:bibi@bloxberg.de?subject=RE%3A%20Bibi%20Bloxberg%20has%20sent%20you%2037.40%20Gradido\\" style=\\"display: inline-block; padding: 9px 15px; border: 0; line-height: inherit; text-decoration: none; cursor: pointer; border-radius: 20px; background-image: radial-gradient(circle farthest-corner at 0% 0%, #f9cd69, #c58d38); margin: 25px 0 25px 0; background: linear-gradient(135deg, #53900c, #6e6e6e); font-size: 20px; font-weight: 600; color: #f5f5f5; width: auto; box-shadow: 20px 20px 25px; transition: all 0.3s ease;\\"><span class=\\"chatbox-wrapper\\" style=\\"margin-right: 8px;\\"><img class=\\"bi-chatbox\\" alt=\\"chatbox\\" loading=\\"lazy\\" src=\\"cid:chatboxicon\\" style=\\"margin-bottom: -5px;\\"></span><span>Reply</span></a>
</div>
</div><a class=\\"button-3\\" href=\\"http://localhost/transactions\\" style=\\"display: inline-block; padding: 9px 15px; color: white; border: 0; line-height: inherit; text-decoration: none; cursor: pointer; border-radius: 20px; background-image: radial-gradient(circle farthest-corner at 0% 0%, #f9cd69, #c58d38); box-shadow: 16px 13px 35px 0 rgba(56, 56, 56, 0.3); margin: 25px 0 25px 0; width: 50%;\\">To account</a>
<div class=\\"text-block\\" style=\\"margin-top: 20px; color: #9ca0a8;\\">
<p>Kind regards,<br>your Gradido team
</p>
@ -1667,7 +1720,8 @@ exports[`sendEmailVariants sendTransactionReceivedEmail result has the correct h
<div class=\\"socialmedia\\" style=\\"display: flex; margin-top: 40px; max-width: 600px;\\"><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.facebook.com/groups/Gradido/\\" style=\\"width: 150px;\\"><img class=\\"bi-facebook\\" alt=\\"facebook\\" loading=\\"lazy\\" src=\\"cid:facebookicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://t.me/GradidoGruppe\\" style=\\"width: 150px;\\"><img class=\\"bi-telegram\\" alt=\\"Telegram\\" loading=\\"lazy\\" src=\\"cid:telegramicon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://twitter.com/gradido\\" style=\\"width: 150px;\\"><img class=\\"bi-twitter\\" alt=\\"Twitter\\" loading=\\"lazy\\" src=\\"cid:twittericon\\"></a><a class=\\"slink\\" target=\\"_blank\\" href=\\"https://www.youtube.com/c/GradidoNet\\" style=\\"width: 150px;\\"><img class=\\"bi-youtube\\" alt=\\"youtube\\" loading=\\"lazy\\" src=\\"cid:youtubeicon\\"></a></div>
<div class=\\"line\\" style=\\"width: 100%; height: 13px; margin-top: 40px; background-image: linear-gradient(90deg, #c58d38, #f3cd7c 40%, #dbb056 55%, #eec05f 71%, #cc9d3d);\\"></div>
<div class=\\"footer\\" style=\\"padding-bottom: 20px;\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a><img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\">
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">If you have any further questions, please contact our support.</div><a class=\\"footer_p2\\" href=\\"mailto:support@gradido.net\\" style=\\"color: #383838; font-weight: bold;\\">support@gradido.net</a>
<div> <img class=\\"image\\" alt=\\"Gradido Logo\\" src=\\"https://gdd.gradido.net/img/brand/green.png\\" style=\\"width: 200px; margin-top: 30px; margin-bottom: 30px;\\" width=\\"200\\"></div>
<div><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/impressum/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Impressum</a></div><br><a class=\\"terms_of_use\\" href=\\"https://gradido.net/de/datenschutz/\\" target=\\"_blank\\" style=\\"color: #9ca0a8;\\">Privacy Policy</a>
<div class=\\"footer_p1\\" style=\\"margin-top: 30px; color: #9ca0a8; margin-bottom: 30px;\\">Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><br><br></div>
</div>

View File

@ -7,9 +7,12 @@ import { CONFIG } from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
const testMailServerHost = 'localhost'
const testMailServerPort = 1025
CONFIG.EMAIL = false
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = 1234
CONFIG.EMAIL_SMTP_HOST = testMailServerHost
CONFIG.EMAIL_SMTP_PORT = testMailServerPort
CONFIG.EMAIL_SENDER = 'info@gradido.net'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
@ -20,11 +23,11 @@ jest.mock('nodemailer', () => {
__esModule: true,
createTransport: jest.fn(() => {
return {
sendMail: jest.fn(() => {
sendMail: () => {
return {
messageId: 'message',
}
}),
},
}
}),
}
@ -73,8 +76,8 @@ describe('sendEmailTranslated', () => {
it('calls the transporter', () => {
expect(createTransport).toBeCalledWith({
host: 'EMAIL_SMTP_URL',
port: 1234,
host: testMailServerHost,
port: testMailServerPort,
secure: false,
requireTLS: true,
auth: {
@ -87,11 +90,6 @@ describe('sendEmailTranslated', () => {
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',
@ -135,11 +133,6 @@ describe('sendEmailTranslated', () => {
it('call of "sendEmailTranslated" with faked "to"', () => {
expect(result).toMatchObject({
envelope: {
from: CONFIG.EMAIL_SENDER,
to: [CONFIG.EMAIL_TEST_RECEIVER, 'support@gradido.net'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: CONFIG.EMAIL_TEST_RECEIVER,
cc: 'support@gradido.net',

View File

@ -45,7 +45,7 @@ export const sendEmailTranslated = async ({
receiver.to = CONFIG.EMAIL_TEST_RECEIVER
}
const transport = createTransport({
host: CONFIG.EMAIL_SMTP_URL,
host: CONFIG.EMAIL_SMTP_HOST,
port: CONFIG.EMAIL_SMTP_PORT,
secure: false, // true for 465, false for other ports
requireTLS: CONFIG.EMAIL_TLS,
@ -62,6 +62,7 @@ export const sendEmailTranslated = async ({
message: {
from: `Gradido (${i18n.__('emails.general.doNotAnswer')}) <${CONFIG.EMAIL_SENDER}>`,
},
send: CONFIG.EMAIL,
transport,
preview: false,
// i18n, // is only needed if you don't install i18n
@ -98,13 +99,18 @@ export const sendEmailTranslated = async ({
path: path.join(__dirname, 'templates/includes/youtube-icon.png'),
cid: 'youtubeicon',
},
{
filename: 'chatbox-icon.png',
path: path.join(__dirname, 'templates/includes/chatbox-icon.png'),
cid: 'chatboxicon',
},
],
},
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'
})
.catch((error: unknown) => {
logger.error('Error sending notification email', error)
return false
return error
})
return resultSend

View File

@ -11,7 +11,8 @@ import { logger, i18n as localization } from '@test/testSetup'
import { CONFIG } from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
// eslint-disable-next-line import/no-namespace
import * as sendEmailTranslatedApi from './sendEmailTranslated'
import {
sendAddedContributionMessageEmail,
sendAccountActivationEmail,
@ -25,7 +26,29 @@ import {
sendContributionChangedByModeratorEmail,
} from './sendEmailVariants'
const testMailServerHost = 'localhost'
const testMailServerPort = 1025
const testMailTLS = false
CONFIG.EMAIL_SENDER = 'info@gradido.net'
CONFIG.EMAIL_SMTP_HOST = testMailServerHost
CONFIG.EMAIL_SMTP_PORT = testMailServerPort
CONFIG.EMAIL_TLS = testMailTLS
jest.mock('nodemailer', () => {
return {
__esModule: true,
createTransport: jest.fn(() => {
return {
sendMail: () => {
return {
messageId: 'message',
}
},
}
}),
}
})
let con: Connection
let testEnv: {
@ -43,13 +66,7 @@ afterAll(async () => {
await con.close()
})
jest.mock('./sendEmailTranslated', () => {
const originalModule = jest.requireActual('./sendEmailTranslated')
return {
__esModule: true,
sendEmailTranslated: jest.fn((a) => originalModule.sendEmailTranslated(a)),
}
})
const sendEmailTranslatedSpy = jest.spyOn(sendEmailTranslatedApi, 'sendEmailTranslated')
describe('sendEmailVariants', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -70,7 +87,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -93,11 +110,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -129,7 +141,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -151,11 +163,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -185,7 +192,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -204,11 +211,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -243,7 +245,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -267,11 +269,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -305,7 +302,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -329,11 +326,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -366,7 +358,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -389,11 +381,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -426,7 +413,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -449,11 +436,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -485,7 +467,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -507,11 +489,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -546,7 +523,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -571,11 +548,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',
@ -600,6 +572,7 @@ describe('sendEmailVariants', () => {
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
memo: 'Du bist schon lustiger ;)',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
senderEmail: 'bibi@bloxberg.de',
@ -609,7 +582,7 @@ describe('sendEmailVariants', () => {
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
expect(sendEmailTranslatedSpy).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
@ -618,6 +591,7 @@ describe('sendEmailVariants', () => {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
memo: 'Du bist schon lustiger ;)',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
senderEmail: 'bibi@bloxberg.de',
@ -633,11 +607,6 @@ describe('sendEmailVariants', () => {
describe('result', () => {
it('is the expected object', () => {
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 (emails.general.doNotAnswer) <info@gradido.net>',

View File

@ -247,6 +247,7 @@ export const sendTransactionReceivedEmail = (data: {
senderFirstName: string
senderLastName: string
senderEmail: string
memo: string
transactionAmount: Decimal
}): Promise<Record<string, unknown> | boolean | null> => {
return sendEmailTranslated({
@ -256,6 +257,7 @@ export const sendTransactionReceivedEmail = (data: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
memo: data.memo,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
senderEmail: data.senderEmail,

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M240-400h480v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM880-80 720-240H160q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v720ZM160-320h594l46 45v-525H160v480Zm0 0v-480 480Z"/></svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="386.729" height="196.289" viewBox="0 0 386.729 196.289">
<defs>
<linearGradient id="linear-gradient" x1="0.552" y1="-1.481" x2="0.673" y2="1.111" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#53900c"/>
<stop offset="1" stop-color="#6e6e6e"/>
</linearGradient>
<filter id="bg_button_copy" x="0" y="0" width="386.729" height="196.289" filterUnits="userSpaceOnUse">
<feOffset dx="20" dy="20" input="SourceAlpha"/>
<feGaussianBlur stdDeviation="25" result="blur"/>
<feFlood flood-color="#383838" flood-opacity="0.306"/>
<feComposite operator="in" in2="blur"/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
<g id="Antwort_button" transform="translate(55 55)">
<g transform="matrix(1, 0, 0, 1, -55, -55)" filter="url(#bg_button_copy)">
<path id="bg_button_copy-2" data-name="bg button copy" d="M22.44,0H214.289a22.44,22.44,0,0,1,22.44,22.44v1.408a22.44,22.44,0,0,1-22.44,22.44H22.44A22.44,22.44,0,0,1,0,23.848V22.44A22.44,22.44,0,0,1,22.44,0Z" transform="translate(55 55)" fill="url(#linear-gradient)"/>
</g>
<text id="Jetzt_antworten" data-name="Jetzt antworten" transform="translate(130.252 30.144)" fill="#f5f5f5" font-size="20" font-family="WorkSans-SemiBold, Work Sans" font-weight="600"><tspan x="-79.86" y="0">Jetzt antworten</tspan></text>
<path id="Icon_answer" data-name="Icon answer" d="M83.2-870.4h9.6V-872H83.2Zm0-2.4h9.6v-1.6H83.2Zm0-2.4h9.6v-1.6H83.2ZM96-864l-3.2-3.2H81.6a1.541,1.541,0,0,1-1.13-.47A1.541,1.541,0,0,1,80-868.8v-9.6a1.541,1.541,0,0,1,.47-1.13A1.541,1.541,0,0,1,81.6-880H94.4a1.541,1.541,0,0,1,1.13.47A1.541,1.541,0,0,1,96-878.4Zm-14.4-4.8H93.48l.92.9v-10.5H81.6Zm0,0v0Z" transform="translate(-59.497 895.145)" fill="#e8eaed"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

View File

@ -0,0 +1,6 @@
span.chatbox-wrapper
img.bi-chatbox(
alt="chatbox"
loading="lazy"
src="cid:chatboxicon"
)

View File

@ -44,9 +44,10 @@ footer
class="footer_p2"
href='mailto:' + t("emails.footer.supportEmail")
)= t("emails.footer.supportEmail")
img.image(
alt="Gradido Logo"
src="https://gdd.gradido.net/img/brand/green.png"
div
img.image(
alt="Gradido Logo"
src="https://gdd.gradido.net/img/brand/green.png"
)
div
a(

View File

@ -51,7 +51,8 @@ h2 {
}
.button-3,
.button-4 {
.button-4,
.button-5 {
display: inline-block;
padding: 9px 15px;
color: white;
@ -70,6 +71,35 @@ h2 {
background-image: radial-gradient(circle farthest-corner at 0% 0%, #616161, #c2c2c2);
}
.button-5 {
background: linear-gradient(135deg, #53900c, #6e6e6e);
font-size: 20px;
font-weight: 600;
color: #f5f5f5;
width: auto;
box-shadow: 20px 20px 25px;
transition: all 0.3s ease;
}
.button-5:hover {
transform: translateY(-5px);
box-shadow: 20px 25px 30px rgba(56, 56, 56, 0.4);
}
.chatbox-wrapper {
margin-right: 8px;
}
.bi-chatbox {
margin-bottom: -5px;
}
.child-right {
text-align: right;
}
.child-left {
text-align: left;
}
.socialmedia {
display: flex;
margin-top: 40px;

View File

@ -1,15 +1,30 @@
extend ../layout.pug
block content
block content
mixin mailto(email, subject)
- var formattedSubject = encodeURIComponent(subject)
a(class!=attributes.class href=`mailto:${email}?subject=${formattedSubject}`)
block
- var subject= t('emails.transactionReceived.replySubject', { senderFirstName, senderLastName, transactionAmount })
h2= t('emails.transactionReceived.title', { senderFirstName, senderLastName, transactionAmount })
.text-block
include ../includes/salutation.pug
p= t('emails.transactionReceived.haveReceivedAmountGDDFrom', { transactionAmount, senderFirstName, senderLastName, senderEmail })
p
= t('emails.transactionReceived.haveReceivedAmountGDDFrom', { transactionAmount, senderFirstName, senderLastName })
| (
+mailto(senderEmail, subject)=senderEmail
|).
.content
h2= t('emails.general.transactionDetails')
div(class="p_content")= t('emails.general.detailsYouFindOnLinkToYourAccount')
h2= t('emails.general.message')
.child-left
div(class="p_content")= memo
.child-right
+mailto(senderEmail, subject)(class="button-5")
include ../includes/chatbox-icon.pug
span #{t('emails.general.answerNow')}
a.button-3(href=`${communityURL}/transactions`) #{t('emails.general.toAccount')}
a.button-3(href=`${communityURL}/transactions`) #{t('emails.general.toAccount')}
include ../includes/doNotReply.pug

View File

@ -19,7 +19,7 @@ export class ContributionLink {
this.cycle = contributionLink.cycle
this.maxPerCycle = contributionLink.maxPerCycle
this.code = contributionLink.code
this.link = CONFIG.COMMUNITY_REDEEM_CONTRIBUTION_URL.replace(/{code}/g, this.code)
this.link = CONFIG.COMMUNITY_REDEEM_CONTRIBUTION_URL + this.code
}
@Field(() => Int)

View File

@ -20,7 +20,7 @@ export class TransactionLink {
this.deletedAt = transactionLink.deletedAt
this.redeemedAt = transactionLink.redeemedAt
this.redeemedBy = redeemedBy
this.link = CONFIG.COMMUNITY_REDEEM_URL.replace(/{code}/g, this.code)
this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code
}
@Field(() => Int)

View File

@ -0,0 +1,12 @@
import { Field, ObjectType } from 'type-graphql'
import { Location } from './Location'
@ObjectType()
export class UserLocationResult {
@Field(() => Location)
userLocation: Location
@Field(() => Location)
communityLocation: Location
}

View File

@ -28,6 +28,8 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { getCommunityByUuid } from './util/communities'
jest.mock('@/password/EncryptorUtils')
// to do: We need a setup for the tests that closes the connection
let mutate: ApolloServerTestClient['mutate'],
query: ApolloServerTestClient['query'],

View File

@ -22,6 +22,8 @@ import { listContributionLinks } from '@/seeds/graphql/queries'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'],
query: ApolloServerTestClient['query'],
con: Connection

View File

@ -27,6 +27,7 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/password/EncryptorUtils')
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
return {

View File

@ -59,6 +59,7 @@ import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { getFirstDayOfPreviousNMonth } from '@/util/utilities'
jest.mock('@/emails/sendEmailVariants')
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'],
query: ApolloServerTestClient['query'],

View File

@ -24,6 +24,16 @@ export class GdtResolver {
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Ctx() context: Context,
): Promise<GdtEntryList> {
if (!CONFIG.GDT_ACTIVE) {
return new GdtEntryList(
'disabled',
0,
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
[],
0,
0,
)
}
const userEntity = getUser(context)
try {
@ -51,6 +61,9 @@ export class GdtResolver {
@Authorized([RIGHTS.GDT_BALANCE])
@Query(() => Float, { nullable: true })
async gdtBalance(@Ctx() context: Context): Promise<number | null> {
if (!CONFIG.GDT_ACTIVE) {
return null
}
const user = getUser(context)
try {
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
@ -71,6 +84,9 @@ export class GdtResolver {
@Query(() => Int)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async existPid(@Arg('pid', () => Int) pid: number): Promise<number> {
if (!CONFIG.GDT_ACTIVE) {
return 0
}
// load user
const resultPID = await apiGet(`${CONFIG.GDT_API_URL}/publishers/checkPidApi/${pid}`)
if (!resultPID.success) {

View File

@ -15,6 +15,8 @@ import { userFactory } from '@/seeds/factory/user'
import { login, subscribeNewsletter, unsubscribeNewsletter } from '@/seeds/graphql/mutations'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
jest.mock('@/password/EncryptorUtils')
let testEnv: any, mutate: any, con: any
beforeAll(async () => {

View File

@ -38,6 +38,8 @@ import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { transactionLinkCode } from './TransactionLinkResolver'
jest.mock('@/password/EncryptorUtils')
// mock semaphore to allow use fake timers
jest.mock('@/util/TRANSACTIONS_LOCK')
TRANSACTIONS_LOCK.acquire = jest.fn().mockResolvedValue(jest.fn())

View File

@ -37,6 +37,8 @@ import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let query: ApolloServerTestClient['query']

View File

@ -190,6 +190,7 @@ export const executeTransaction = async (
lastName: recipient.lastName,
email: recipient.emailContact.email,
language: recipient.language,
memo,
senderFirstName: sender.firstName,
senderLastName: sender.lastName,
senderEmail: sender.emailContact.email,
@ -230,8 +231,11 @@ export class TransactionResolver {
logger.addContext('user', user.id)
logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.emailId})`)
const gdtResolver = new GdtResolver()
const balanceGDTPromise = gdtResolver.gdtBalance(context)
let balanceGDTPromise: Promise<number | null> = Promise.resolve(null)
if (CONFIG.GDT_ACTIVE) {
const gdtResolver = new GdtResolver()
balanceGDTPromise = gdtResolver.gdtBalance(context)
}
// find current balance
const lastTransaction = await getLastTransaction(user.id)
@ -418,8 +422,10 @@ export class TransactionResolver {
).toDecimalPlaces(2, Decimal.ROUND_HALF_UP)
}
})
const balanceGDT = await balanceGDTPromise
context.balanceGDT = balanceGDT
if (CONFIG.GDT_ACTIVE) {
const balanceGDT = await balanceGDTPromise
context.balanceGDT = balanceGDT
}
// Construct Result
return new TransactionList(await balanceResolver.balance(context), transactions)

View File

@ -74,6 +74,7 @@ import { objectValuesToArray } from '@/util/utilities'
import { Location2Point } from './util/Location2Point'
jest.mock('@/apis/humhub/HumHubClient')
jest.mock('@/password/EncryptorUtils')
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
@ -96,6 +97,8 @@ jest.mock('@/apis/KlicktippController', () => {
}
})
CONFIG.EMAIL_CODE_REQUEST_TIME = 10
let admin: User
let user: User
let mutate: ApolloServerTestClient['mutate'],
@ -112,6 +115,7 @@ beforeAll(async () => {
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
CONFIG.HUMHUB_ACTIVE = false
await cleanDB()
})
@ -185,7 +189,7 @@ describe('UserResolver', () => {
communityUuid: homeCom.communityUuid,
foreign: false,
gmsAllowed: true,
humhubAllowed: false,
humhubAllowed: true,
gmsPublishName: 0,
humhubPublishName: 0,
gmsPublishLocation: 2,
@ -240,10 +244,10 @@ describe('UserResolver', () => {
describe('account activation email', () => {
it('sends an account activation email', () => {
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailVerificationCode,
).replace(/{code}/g, '')
const activationLink = `${
CONFIG.EMAIL_LINK_VERIFICATION
}${emailVerificationCode.toString()}`
expect(sendAccountActivationEmail).toBeCalledWith({
firstName: 'Peter',
lastName: 'Lustig',
@ -587,8 +591,8 @@ describe('UserResolver', () => {
expect(newUser.emailContact.emailChecked).toBeTruthy()
})
it('updates the password', () => {
const encryptedPass = encryptPassword(newUser, 'Aa12345_')
it('updates the password', async () => {
const encryptedPass = await encryptPassword(newUser, 'Aa12345_')
expect(newUser.password.toString()).toEqual(encryptedPass.toString())
})
@ -1546,9 +1550,9 @@ describe('UserResolver', () => {
expect(bibi).toEqual(
expect.objectContaining({
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
password: (
await SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')
).toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
@ -1570,10 +1574,7 @@ describe('UserResolver', () => {
})
bibi = usercontact.user
bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL
bibi.password = SecretKeyCryptographyCreateKey(
'bibi@bloxberg.de',
'Aa12345_',
)[0].readBigUInt64LE()
bibi.password = await SecretKeyCryptographyCreateKey('bibi@bloxberg.de', 'Aa12345_')
await bibi.save()
})
@ -1590,9 +1591,9 @@ describe('UserResolver', () => {
expect(bibi).toEqual(
expect.objectContaining({
firstName: 'Bibi',
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
password: (
await SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')
).toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
@ -2229,14 +2230,13 @@ describe('UserResolver', () => {
})
it('sends an account activation email', async () => {
const userConatct = await UserContact.findOneOrFail({
const userContact = await UserContact.findOneOrFail({
where: { email: 'bibi@bloxberg.de' },
relations: ['user'],
})
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
userConatct.emailVerificationCode.toString(),
).replace(/{code}/g, '')
const activationLink = `${
CONFIG.EMAIL_LINK_VERIFICATION
}${userContact.emailVerificationCode.toString()}`
expect(sendAccountActivationEmail).toBeCalledWith({
firstName: 'Bibi',
lastName: 'Bloxberg',
@ -2251,14 +2251,14 @@ describe('UserResolver', () => {
})
it('stores the EMAIL_ADMIN_CONFIRMATION event in the database', async () => {
const userConatct = await UserContact.findOneOrFail({
const userContact = await UserContact.findOneOrFail({
where: { email: 'bibi@bloxberg.de' },
relations: ['user'],
})
await expect(DbEvent.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventType.EMAIL_ADMIN_CONFIRMATION,
affectedUserId: userConatct.user.id,
affectedUserId: userContact.user.id,
actingUserId: admin.id,
}),
)

View File

@ -2,12 +2,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { getConnection, In } from '@dbTools/typeorm'
import { getConnection, In, Point } from '@dbTools/typeorm'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { UserRole } from '@entity/UserRole'
import i18n from 'i18n'
import { Resolver, Query, Args, Arg, Authorized, Ctx, Mutation, Int } from 'type-graphql'
import { IRestResponse } from 'typed-rest-client'
@ -29,15 +28,16 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
import { GmsUserAuthenticationResult } from '@model/GmsUserAuthenticationResult'
import { User } from '@model/User'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { UserLocationResult } from '@model/UserLocationResult'
import { updateGmsUser } from '@/apis/gms/GmsClient'
import { GmsUser } from '@/apis/gms/model/GmsUser'
import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { GetUser } from '@/apis/humhub/model/GetUser'
import { PostUser } from '@/apis/humhub/model/PostUser'
import { subscribe } from '@/apis/KlicktippController'
import { encode } from '@/auth/JWT'
import { RIGHTS } from '@/auth/RIGHTS'
import { CONFIG } from '@/config'
import { PublishNameLogic } from '@/data/PublishName.logic'
import {
sendAccountActivationEmail,
sendAccountMultiRegistrationEmail,
@ -59,6 +59,7 @@ import {
EVENT_ADMIN_USER_DELETE,
EVENT_ADMIN_USER_UNDELETE,
} from '@/event/Events'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { isValidPassword } from '@/password/EncryptorUtils'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
@ -67,6 +68,7 @@ import { backendLogger as logger } from '@/server/logger'
import { communityDbUser } from '@/util/communityUser'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
import { delay } from '@/util/utilities'
import random from 'random-bigint'
import { randombytes_random } from 'sodium-native'
@ -79,7 +81,7 @@ import { getUserCreations } from './util/creations'
import { findUserByIdentifier } from './util/findUserByIdentifier'
import { findUsers } from './util/findUsers'
import { getKlicktippState } from './util/getKlicktippState'
import { Location2Point } from './util/Location2Point'
import { Location2Point, Point2Location } from './util/Location2Point'
import { setUserRole, deleteUserRole } from './util/modifyUserRole'
import { sendUserToGms } from './util/sendUserToGms'
import { syncHumhub } from './util/syncHumhub'
@ -107,7 +109,7 @@ const newEmailContact = (email: string, userId: number): DbUserContact => {
// eslint-disable-next-line @typescript-eslint/ban-types
export const activationLink = (verificationCode: string): string => {
logger.debug(`activationLink(${verificationCode})...`)
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, verificationCode.toString())
return CONFIG.EMAIL_LINK_SETPASSWORD + verificationCode.toString()
}
const newGradidoID = async (): Promise<string> => {
@ -148,7 +150,16 @@ export class UserResolver {
): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`)
email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email)
let dbUser: DbUser
try {
dbUser = await findUserByEmail(email)
} catch (e) {
// simulate delay which occur on password encryption 650 ms +- 50 rnd
await delay(650 + Math.floor(Math.random() * 101) - 50)
throw e
}
if (dbUser.deletedAt) {
throw new LogError('This user was permanently deleted. Contact support for questions', dbUser)
}
@ -160,7 +171,7 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new LogError('The User has not set a password yet', dbUser)
}
if (!verifyPassword(dbUser, password)) {
if (!(await verifyPassword(dbUser, password))) {
throw new LogError('No user with this credentials', dbUser)
}
@ -168,14 +179,15 @@ export class UserResolver {
let humhubUserPromise: Promise<IRestResponse<GetUser>> | undefined
const klicktippStatePromise = getKlicktippState(dbUser.emailContact.email)
if (CONFIG.HUMHUB_ACTIVE && dbUser.humhubAllowed) {
const getHumhubUser = new PostUser(dbUser)
humhubUserPromise = HumHubClient.getInstance()?.userByUsernameAsync(
dbUser.alias ?? dbUser.gradidoID,
getHumhubUser.account.username,
)
}
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
dbUser.password = encryptPassword(dbUser, password)
dbUser.password = await encryptPassword(dbUser, password)
await dbUser.save()
}
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
@ -313,6 +325,8 @@ export class UserResolver {
dbUser.firstName = firstName
dbUser.lastName = lastName
dbUser.language = language
// enable humhub from now on for new user
dbUser.humhubAllowed = true
if (alias && (await validateAlias(alias))) {
dbUser.alias = alias
}
@ -357,10 +371,9 @@ export class UserResolver {
throw new LogError('Error while updating dbUser', error)
})
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailContact.emailVerificationCode.toString(),
).replace(/{code}/g, redeemCode ? '/' + redeemCode : '')
const activationLink = `${
CONFIG.EMAIL_LINK_VERIFICATION
}${emailContact.emailVerificationCode.toString()}${redeemCode ? `/${redeemCode}` : ''}`
void sendAccountActivationEmail({
firstName,
@ -383,6 +396,9 @@ export class UserResolver {
await queryRunner.release()
}
logger.info('createUser() successful...')
if (CONFIG.HUMHUB_ACTIVE) {
void syncHumhub(null, dbUser)
}
if (redeemCode) {
eventRegisterRedeem.affectedUser = dbUser
@ -494,7 +510,7 @@ export class UserResolver {
// Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
user.password = encryptPassword(user, password)
user.password = await encryptPassword(user, password)
logger.debug('User credentials updated ...')
const queryRunner = getConnection().createQueryRunner()
@ -624,13 +640,13 @@ export class UserResolver {
)
}
if (!verifyPassword(user, password)) {
if (!(await verifyPassword(user, password))) {
throw new LogError(`Old password is invalid`)
}
// Save new password hash and newly encrypted private key
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
user.password = encryptPassword(user, passwordNew)
user.password = await encryptPassword(user, passwordNew)
}
// Save hideAmountGDD value
@ -683,17 +699,25 @@ export class UserResolver {
await EVENT_USER_INFO_UPDATE(user)
// validate if user settings are changed with relevance to update gms-user
if (CONFIG.GMS_ACTIVE && updateUserInGMS) {
logger.debug(`changed user-settings relevant for gms-user update...`)
const homeCom = await getHomeCommunity()
if (homeCom.gmsApiKey !== null) {
logger.debug(`gms-user update...`, user)
await updateGmsUser(homeCom.gmsApiKey, new GmsUser(user))
logger.debug(`gms-user update successfully.`)
try {
if (CONFIG.GMS_ACTIVE && updateUserInGMS) {
logger.debug(`changed user-settings relevant for gms-user update...`)
const homeCom = await getHomeCommunity()
if (homeCom.gmsApiKey !== null) {
logger.debug(`send User to Gms...`, user)
await sendUserToGms(user, homeCom)
logger.debug(`sendUserToGms successfully.`)
}
}
} catch (e) {
logger.error('error sync user with gms', e)
}
if (CONFIG.HUMHUB_ACTIVE) {
await syncHumhub(updateUserInfosArgs, user)
try {
if (CONFIG.HUMHUB_ACTIVE) {
await syncHumhub(updateUserInfosArgs, user)
}
} catch (e) {
logger.error('error sync user with humhub', e)
}
return true
@ -712,14 +736,35 @@ export class UserResolver {
@Authorized([RIGHTS.GMS_USER_PLAYGROUND])
@Query(() => GmsUserAuthenticationResult)
async authenticateGmsUserSearch(@Ctx() context: Context): Promise<GmsUserAuthenticationResult> {
logger.info(`authUserForGmsUserSearch()...`)
logger.info(`authenticateGmsUserSearch()...`)
const dbUser = getUser(context)
let result: GmsUserAuthenticationResult
let result = new GmsUserAuthenticationResult()
if (context.token) {
result = await authenticateGmsUserPlayground(context.token, dbUser)
logger.info('authUserForGmsUserSearch=', result)
const homeCom = await getHomeCommunity()
if (!homeCom.gmsApiKey) {
throw new LogError('authenticateGmsUserSearch missing HomeCommunity GmsApiKey')
}
result = await authenticateGmsUserPlayground(homeCom.gmsApiKey, context.token, dbUser)
logger.info('authenticateGmsUserSearch=', result)
} else {
throw new LogError('authUserForGmsUserSearch without token')
throw new LogError('authenticateGmsUserSearch missing valid user login-token')
}
return result
}
@Authorized([RIGHTS.GMS_USER_PLAYGROUND])
@Query(() => UserLocationResult)
async userLocation(@Ctx() context: Context): Promise<UserLocationResult> {
logger.info(`userLocation()...`)
const dbUser = getUser(context)
const result = new UserLocationResult()
if (context.token) {
const homeCom = await getHomeCommunity()
result.communityLocation = Point2Location(homeCom.location as Point)
result.userLocation = Point2Location(dbUser.location as Point)
logger.info('userLocation=', result)
} else {
throw new LogError('userLocation missing valid user login-token')
}
return result
}
@ -733,7 +778,8 @@ export class UserResolver {
if (!humhubClient) {
throw new LogError('cannot create humhub client')
}
const username = dbUser.alias ?? dbUser.gradidoID
const userNameLogic = new PublishNameLogic(dbUser)
const username = userNameLogic.getUsername(dbUser.humhubPublishName as PublishNameType)
let humhubUser = await humhubClient.userByUsername(username)
if (!humhubUser) {
humhubUser = await humhubClient.userByEmail(dbUser.emailContact.email)
@ -744,7 +790,7 @@ export class UserResolver {
if (humhubUser.account.status !== 1) {
throw new LogError('user status is not 1', humhubUser.account.status)
}
return await humhubClient.createAutoLoginUrl(username)
return await humhubClient.createAutoLoginUrl(humhubUser.account.username)
}
@Authorized([RIGHTS.SEARCH_ADMIN_USERS])
@ -954,16 +1000,15 @@ export class UserResolver {
}
export async function findUserByEmail(email: string): Promise<DbUser> {
const dbUserContact = await DbUserContact.findOneOrFail({
where: { email },
const dbUser = await DbUser.findOneOrFail({
where: {
emailContact: { email },
},
withDeleted: true,
relations: ['user'],
relations: { userRoles: true, emailContact: true },
}).catch(() => {
throw new LogError('No user with this credentials', email)
})
const dbUser = dbUserContact.user
dbUser.emailContact = dbUserContact
dbUser.userRoles = await UserRole.find({ where: { userId: dbUser.id } })
return dbUser
}

View File

@ -25,6 +25,8 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']

View File

@ -7,13 +7,14 @@ import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
export async function authenticateGmsUserPlayground(
apiKey: string,
token: string,
dbUser: DbUser,
): Promise<GmsUserAuthenticationResult> {
const result = new GmsUserAuthenticationResult()
const dashboardUrl = ensureUrlEndsWithSlash(CONFIG.GMS_DASHBOARD_URL)
result.url = dashboardUrl.concat('playground')
result.url = dashboardUrl.concat('usersearch-playground')
result.token = await verifyAuthToken(dbUser.communityUuid, token)
logger.info('GmsUserAuthenticationResult:', result)
return result

View File

@ -12,6 +12,8 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { getOpenCreations, getUserCreation } from './creations'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']

View File

@ -16,6 +16,8 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { findUserByIdentifier } from './findUserByIdentifier'
jest.mock('@/password/EncryptorUtils')
let con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']

View File

@ -32,6 +32,8 @@ import { raeuberHotzenplotz } from '@/seeds/users/raeuber-hotzenplotz'
import { sendTransactionsToDltConnector } from './sendTransactionsToDltConnector'
jest.mock('@/password/EncryptorUtils')
/*
// Mock the GraphQLClient
jest.mock('graphql-request', () => {

View File

@ -1,7 +1,7 @@
import { Community as DbCommunity } from '@entity/Community'
import { User as DbUser } from '@entity/User'
import { createGmsUser } from '@/apis/gms/GmsClient'
import { createGmsUser, updateGmsUser } from '@/apis/gms/GmsClient'
import { GmsUser } from '@/apis/gms/model/GmsUser'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
@ -14,13 +14,20 @@ export async function sendUserToGms(user: DbUser, homeCom: DbCommunity): Promise
logger.debug('User send to GMS:', user)
const gmsUser = new GmsUser(user)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (await createGmsUser(homeCom.gmsApiKey, gmsUser)) {
logger.debug('GMS user published successfully:', gmsUser)
user.gmsRegistered = true
user.gmsRegisteredAt = new Date()
await DbUser.save(user)
logger.debug('mark user as gms published:', user)
if (!user.gmsRegistered && user.gmsRegisteredAt === null) {
logger.debug('create user in gms:', gmsUser)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (await createGmsUser(homeCom.gmsApiKey, gmsUser)) {
logger.debug('GMS user published successfully:', gmsUser)
await updateUserGmsStatus(user)
}
} else {
logger.debug('update user in gms:', gmsUser)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (await updateGmsUser(homeCom.gmsApiKey, gmsUser)) {
logger.debug('GMS user published successfully:', gmsUser)
await updateUserGmsStatus(user)
}
}
} catch (err) {
if (CONFIG.GMS_CREATE_USER_THROW_ERRORS) {
@ -30,3 +37,11 @@ export async function sendUserToGms(user: DbUser, homeCom: DbCommunity): Promise
}
}
}
async function updateUserGmsStatus(user: DbUser) {
logger.debug('updateUserGmsStatus:', user)
user.gmsRegistered = true
user.gmsRegisteredAt = new Date()
await DbUser.save(user)
logger.debug('mark user as gms published:', user)
}

View File

@ -119,6 +119,7 @@ export async function settlePendingSenderTransaction(
lastName: recipient.lastName,
email: recipient.emailContact.email,
language: recipient.language,
memo,
senderFirstName: sender.firstName,
senderLastName: sender.lastName,
senderEmail: sender.emailContact.email,

View File

@ -7,11 +7,12 @@ import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs'
import { backendLogger as logger } from '@/server/logger'
export async function syncHumhub(
updateUserInfosArg: UpdateUserInfosArgs,
updateUserInfosArg: UpdateUserInfosArgs | null,
user: User,
): Promise<void> {
// check for humhub relevant changes
if (
updateUserInfosArg &&
updateUserInfosArg.alias === undefined &&
updateUserInfosArg.firstName === undefined &&
updateUserInfosArg.lastName === undefined &&

View File

@ -56,6 +56,7 @@
},
"general": {
"amountGDD": "Betrag: {amountGDD} GDD",
"answerNow": "Jetzt antworten",
"completeRegistration": "Registrierung abschließen",
"contribution": "Gemeinwohl-Beitrag: {contributionMemo}",
"contributionDetails": "Beitragsdetails",
@ -63,6 +64,7 @@
"helloName": "Hallo {firstName} {lastName},",
"linkValidity": "Der Link hat eine Gültigkeit von {hours} Stunden.\nSollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen.",
"linkValidityWithMinutes": "Der Link hat eine Gültigkeit von {hours} Stunden und {minutes} Minuten.\nSollte die Gültigkeit des Links bereits abgelaufen sein, kannst du dir hier einen neuen Link schicken lassen.",
"message": "Nachricht",
"newLink": "Neuer Link",
"orCopyLink": "Oder kopiere den Link in dein Browserfenster.",
"pleaseDoNotReply": "Bitte antworte nicht auf diese E-Mail.",
@ -86,8 +88,9 @@
"title": "{senderFirstName} {senderLastName} hat deinen Gradido-Link eingelöst"
},
"transactionReceived": {
"haveReceivedAmountGDDFrom": "du hast soeben {transactionAmount} GDD von {senderFirstName} {senderLastName} ({senderEmail}) erhalten.",
"haveReceivedAmountGDDFrom": "du hast soeben {transactionAmount} GDD erhalten von {senderFirstName} {senderLastName}",
"subject": "{senderFirstName} {senderLastName} hat dir {transactionAmount} Gradido gesendet",
"replySubject": "Re: {senderFirstName} {senderLastName} hat dir {transactionAmount} Gradido gesendet",
"title": "{senderFirstName} {senderLastName} hat dir {transactionAmount} Gradido gesendet"
}
},

View File

@ -56,6 +56,7 @@
},
"general": {
"amountGDD": "Amount: {amountGDD} GDD",
"answerNow": "Reply",
"completeRegistration": "Complete registration",
"contribution": "Contribution: : {contributionMemo}",
"contributionDetails": "Contribution details",
@ -63,6 +64,7 @@
"helloName": "Hello {firstName} {lastName},",
"linkValidity": "The link has a validity of {hours} hours.\nIf the validity of the link has already expired, you can have a new link sent to you here.",
"linkValidityWithMinutes": "The link has a validity of {hours} hours and {minutes} minutes.\nIf the validity of the link has already expired, you can have a new link sent to you here.",
"message": "Message",
"newLink": "New link",
"orCopyLink": "Or copy the link into your browser window.",
"pleaseDoNotReply": "Please do not reply to this email.",
@ -86,7 +88,8 @@
"title": "{senderFirstName} {senderLastName} has redeemed your Gradido link"
},
"transactionReceived": {
"haveReceivedAmountGDDFrom": "You have just received {transactionAmount} GDD from {senderFirstName} {senderLastName} ({senderEmail}).",
"haveReceivedAmountGDDFrom": "You have just received {transactionAmount} GDD from {senderFirstName} {senderLastName}",
"replySubject": "RE: {senderFirstName} {senderLastName} has sent you {transactionAmount} Gradido",
"subject": "{senderFirstName} {senderLastName} has sent you {transactionAmount} Gradido",
"title": "{senderFirstName} {senderLastName} has sent you {transactionAmount} Gradido"
}

View File

@ -0,0 +1,53 @@
import { worker } from 'workerpool'
import { CONFIG } from '@/config'
import {
crypto_box_SEEDBYTES,
crypto_hash_sha512_init,
crypto_hash_sha512_update,
crypto_hash_sha512_final,
crypto_hash_sha512_BYTES,
crypto_hash_sha512_STATEBYTES,
crypto_shorthash_BYTES,
crypto_pwhash_SALTBYTES,
crypto_pwhash,
crypto_shorthash,
} from 'sodium-native'
export const SecretKeyCryptographyCreateKey = (
salt: string,
password: string,
configLoginAppSecret: Buffer,
configLoginServerKey: Buffer,
): bigint => {
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
crypto_hash_sha512_update(state, Buffer.from(salt))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(crypto_shorthash_BYTES)
crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return encryptionKeyHash.readBigUInt64LE()
}
if (CONFIG.USE_CRYPTO_WORKER === true && typeof process.send === 'function') {
worker({
SecretKeyCryptographyCreateKey,
})
}

View File

@ -2,7 +2,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { cpus } from 'os'
import path from 'path'
import { User } from '@entity/User'
import { Pool, pool } from 'workerpool'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
@ -10,61 +14,73 @@ import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import {
crypto_shorthash_KEYBYTES,
crypto_box_SEEDBYTES,
crypto_hash_sha512_init,
crypto_hash_sha512_update,
crypto_hash_sha512_final,
crypto_hash_sha512_BYTES,
crypto_hash_sha512_STATEBYTES,
crypto_shorthash_BYTES,
crypto_pwhash_SALTBYTES,
crypto_pwhash,
crypto_shorthash,
} from 'sodium-native'
import { crypto_shorthash_KEYBYTES } from 'sodium-native'
import { SecretKeyCryptographyCreateKey as SecretKeyCryptographyCreateKeySync } from './EncryptionWorker'
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
let encryptionWorkerPool: Pool | undefined
if (CONFIG.USE_CRYPTO_WORKER === true) {
encryptionWorkerPool = pool(
path.join(__dirname, '..', '..', 'build', 'src', 'password', '/EncryptionWorker.js'),
{
// TODO: put maxQueueSize into config
maxQueueSize: 30 * cpus().length,
},
)
}
// 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 !== crypto_shorthash_KEYBYTES) {
throw new LogError(
'ServerKey has an invalid size',
configLoginServerKey.length,
crypto_shorthash_KEYBYTES,
)
/**
* @param salt
* @param password
* @returns can throw an exception if worker pool is full, if more than 30 * cpu core count logins happen in a time range of 30 seconds
*/
export const SecretKeyCryptographyCreateKey = async (
salt: string,
password: string,
): Promise<bigint> => {
try {
logger.trace('call worker for: SecretKeyCryptographyCreateKey')
if (configLoginServerKey.length !== crypto_shorthash_KEYBYTES) {
throw new LogError(
'ServerKey has an invalid size',
configLoginServerKey.length,
crypto_shorthash_KEYBYTES,
)
}
let result: Promise<bigint>
if (encryptionWorkerPool) {
result = (await encryptionWorkerPool.exec('SecretKeyCryptographyCreateKey', [
salt,
password,
configLoginAppSecret,
configLoginServerKey,
])) as Promise<bigint>
} else {
result = Promise.resolve(
SecretKeyCryptographyCreateKeySync(
salt,
password,
configLoginAppSecret,
configLoginServerKey,
),
)
}
return result
} catch (e) {
// pool is throwing this error
// throw new Error('Max queue size of ' + this.maxQueueSize + ' reached');
// will be shown in frontend to user
throw new LogError('Server is full, please try again in 10 minutes.', e)
}
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
crypto_hash_sha512_update(state, Buffer.from(salt))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(crypto_shorthash_BYTES)
crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return [encryptionKeyHash, encryptionKey]
}
export const getUserCryptographicSalt = (dbUser: User): string => {

View File

@ -3,13 +3,12 @@ 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 => {
export const encryptPassword = async (dbUser: User, password: string): Promise<bigint> => {
const salt = getUserCryptographicSalt(dbUser)
const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash
const passwordHash = keyBuffer[0].readBigUInt64LE()
return passwordHash
return SecretKeyCryptographyCreateKey(salt, password)
}
export const verifyPassword = (dbUser: User, password: string): boolean => {
return dbUser.password.toString() === encryptPassword(dbUser, password).toString()
export const verifyPassword = async (dbUser: User, password: string): Promise<boolean> => {
const encryptedPassword = await encryptPassword(dbUser, password)
return dbUser.password.toString() === encryptedPassword.toString()
}

View File

@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { User } from '@entity/User'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import {
crypto_shorthash_KEYBYTES,
crypto_box_SEEDBYTES,
crypto_hash_sha512_init,
crypto_hash_sha512_update,
crypto_hash_sha512_final,
crypto_hash_sha512_BYTES,
crypto_hash_sha512_STATEBYTES,
crypto_shorthash_BYTES,
crypto_pwhash_SALTBYTES,
crypto_pwhash,
crypto_shorthash,
crypto_pwhash_OPSLIMIT_MIN,
crypto_pwhash_MEMLIMIT_MIN,
} from 'sodium-native'
const SecretKeyCryptographyCreateKeyMock = (
salt: string,
password: string,
configLoginAppSecret: Buffer,
configLoginServerKey: Buffer,
): bigint => {
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
crypto_hash_sha512_update(state, Buffer.from(salt))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = crypto_pwhash_OPSLIMIT_MIN
const memLimit = crypto_pwhash_MEMLIMIT_MIN
const algo = 2
crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(crypto_shorthash_BYTES)
crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return encryptionKeyHash.readBigUInt64LE()
}
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
// 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,}$/)
}
/**
* @param salt
* @param password
* @returns can throw an exception if worker pool is full, if more than 30 * cpu core count logins happen in a time range of 30 seconds
*/
export const SecretKeyCryptographyCreateKey = async (
salt: string,
password: string,
): Promise<bigint> => {
try {
logger.trace('call worker for: SecretKeyCryptographyCreateKey')
if (configLoginServerKey.length !== crypto_shorthash_KEYBYTES) {
throw new LogError(
'ServerKey has an invalid size',
configLoginServerKey.length,
crypto_shorthash_KEYBYTES,
)
}
return Promise.resolve(
SecretKeyCryptographyCreateKeyMock(
salt,
password,
configLoginAppSecret,
configLoginServerKey,
),
)
} catch (e) {
// pool is throwing this error
// throw new Error('Max queue size of ' + this.maxQueueSize + ' reached');
// will be shown in frontend to user
throw new LogError('Server is full, please try again in 10 minutes.', e)
}
}
export const getUserCryptographicSalt = (dbUser: User): string => {
switch (dbUser.passwordEncryptionType) {
case PasswordEncryptionType.NO_PASSWORD:
throw new LogError('User has no password set', dbUser.id)
case PasswordEncryptionType.EMAIL:
return dbUser.emailContact.email
case PasswordEncryptionType.GRADIDO_ID:
return dbUser.gradidoID
default:
throw new LogError('Unknown password encryption type', dbUser.passwordEncryptionType)
}
}

Some files were not shown because too many files have changed in this diff Show More