diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml new file mode 100644 index 000000000..2f9229737 --- /dev/null +++ b/.github/workflows/lint_pr.yml @@ -0,0 +1,72 @@ +name: "gradido lint pull request CI" + +on: + pull_request: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Configure which types are allowed (newline delimited). + # Default: https://github.com/commitizen/conventional-commit-types + #types: | + # fix + # feat + # Configure which scopes are allowed (newline delimited). + scopes: | + backend + frontend + admin + database + release + other + # Configure that a scope must always be provided. + requireScope: true + # Configure which scopes (newline delimited) are disallowed in PR + # titles. For instance by setting # the value below, `chore(release): + # ...` and `ci(e2e,release): ...` will be rejected. + #disallowScopes: | + # release + # Configure additional validation for the subject based on a regex. + # This example ensures the subject doesn't start with an uppercase character. + subjectPattern: ^(?![A-Z]).+$ + # If `subjectPattern` is configured, you can use this property to override + # the default error message that is shown when the pattern doesn't match. + # The variables `subject` and `title` can be used within the message. + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + doesn't start with an uppercase character. + # If you use GitHub Enterprise, you can set this to the URL of your server + #githubBaseUrl: https://github.myorg.com/api/v3 + # If the PR contains one of these labels (newline delimited), the + # validation is skipped. + # If you want to rerun the validation when labels change, you might want + # to use the `labeled` and `unlabeled` event triggers in your workflow. + #ignoreLabels: | + # bot + # ignore-semantic-pull-request + # If you're using a format for the PR title that differs from the traditional Conventional + # Commits spec, you can use these options to customize the parsing of the type, scope and + # subject. The `headerPattern` should contain a regex where the capturing groups in parentheses + # correspond to the parts listed in `headerPatternCorrespondence`. + # See: https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser#headerpattern + headerPattern: '^(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$' + headerPatternCorrespondence: type, scope, subject + # For work-in-progress PRs you can typically use draft pull requests + # from GitHub. However, private repositories on the free plan don't have + # this option and therefore this action allows you to opt-in to using the + # special "[WIP]" prefix to indicate this state. This will avoid the + # validation of the PR title and the pull request checks remain pending. + # Note that a second check will be reported if this is enabled. + wip: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34ebeff11..c3238507a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: gradido test CI -on: [push] +on: push jobs: ############################################################################## @@ -15,7 +15,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # FRONTEND ############################################################### ########################################################################## @@ -24,7 +24,7 @@ jobs: docker build --target test -t "gradido/frontend:test" frontend/ docker save "gradido/frontend:test" > /tmp/frontend.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-frontend-test path: /tmp/frontend.tar @@ -41,7 +41,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # ADMIN INTERFACE ######################################################## ########################################################################## @@ -50,7 +50,7 @@ jobs: docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test" docker save "gradido/admin:test" > /tmp/admin.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-admin-test path: /tmp/admin.tar @@ -67,7 +67,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # BACKEND ################################################################ ########################################################################## @@ -76,7 +76,7 @@ jobs: docker build -f ./backend/Dockerfile --target test -t "gradido/backend:test" . docker save "gradido/backend:test" > /tmp/backend.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-backend-test path: /tmp/backend.tar @@ -93,7 +93,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DATABASE UP ############################################################ ########################################################################## @@ -102,7 +102,7 @@ jobs: docker build --target test_up -t "gradido/database:test_up" database/ docker save "gradido/database:test_up" > /tmp/database_up.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-database-test_up path: /tmp/database_up.tar @@ -119,7 +119,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # BUILD MARIADB DOCKER IMAGE ############################################# ########################################################################## @@ -128,7 +128,7 @@ jobs: docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./ docker save "gradido/mariadb:test" > /tmp/mariadb.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-mariadb-test path: /tmp/mariadb.tar @@ -139,13 +139,13 @@ jobs: build_test_nginx: name: Docker Build Test - Nginx runs-on: ubuntu-latest - #needs: [nothing] + needs: [build_test_backend, build_test_admin, build_test_frontend] steps: ########################################################################## # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # BUILD NGINX DOCKER IMAGE ############################################### ########################################################################## @@ -154,7 +154,7 @@ jobs: docker build -t "gradido/nginx:test" nginx/ docker save "gradido/nginx:test" > /tmp/nginx.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-nginx-test path: /tmp/nginx.tar @@ -171,12 +171,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -200,12 +200,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -229,12 +229,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -258,12 +258,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -287,12 +287,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -308,7 +308,7 @@ jobs: # JOB: LOCALES ADMIN ######################################################### ############################################################################## locales_admin: - name: Locales - Admin + name: Locales - Admin Interface runs-on: ubuntu-latest needs: [build_test_admin] steps: @@ -316,12 +316,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -345,12 +345,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Backend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-backend-test path: /tmp @@ -374,12 +374,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Backend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-database-test_up path: /tmp @@ -403,12 +403,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGES ################################################# ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -453,12 +453,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGES ################################################# ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -495,12 +495,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGES ################################################# ########################################################################## - name: Download Docker Image (Mariadb) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-mariadb-test path: /tmp @@ -543,7 +543,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOCKER COMPOSE DATABASE UP + RESET ##################################### ########################################################################## @@ -553,3 +553,108 @@ jobs: run: docker-compose -f docker-compose.yml run -T database yarn up - name: database | reset run: docker-compose -f docker-compose.yml run -T database yarn reset + + ############################################################################## + # JOB: END-TO-END TESTS ##################################################### + ############################################################################## + end-to-end-tests: + name: End-to-End Tests + runs-on: ubuntu-latest + needs: [build_test_mariadb, build_test_database_up, build_test_backend, build_test_admin, build_test_frontend, build_test_nginx] + steps: + ########################################################################## + # CHECKOUT CODE ########################################################## + ########################################################################## + - name: Checkout code + uses: actions/checkout@v3 + ########################################################################## + # DOWNLOAD DOCKER IMAGES ################################################# + ########################################################################## + - name: Download Docker Image (Mariadb) + uses: actions/download-artifact@v3 + with: + name: docker-mariadb-test + path: /tmp + - name: Load Docker Image (Mariadb) + run: docker load < /tmp/mariadb.tar + - name: Download Docker Image (Database Up) + uses: actions/download-artifact@v3 + with: + name: docker-database-test_up + path: /tmp + - name: Load Docker Image (Database Up) + run: docker load < /tmp/database_up.tar + - name: Download Docker Image (Backend) + uses: actions/download-artifact@v3 + with: + name: docker-backend-test + path: /tmp + - name: Load Docker Image (Backend) + run: docker load < /tmp/backend.tar + - name: Download Docker Image (Frontend) + uses: actions/download-artifact@v3 + with: + name: docker-frontend-test + path: /tmp + - name: Load Docker Image (Frontend) + run: docker load < /tmp/frontend.tar + - name: Download Docker Image (Admin Interface) + uses: actions/download-artifact@v3 + with: + name: docker-admin-test + path: /tmp + - name: Load Docker Image (Admin Interface) + run: docker load < /tmp/admin.tar + - name: Download Docker Image (Nginx) + uses: actions/download-artifact@v3 + with: + name: docker-nginx-test + path: /tmp + - name: Load Docker Image (Nginx) + run: docker load < /tmp/nginx.tar + + ########################################################################## + # BOOT UP THE TEST SYSTEM ################################################ + ########################################################################## + - 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: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend + + - name: Sleep for 10 seconds + run: sleep 10s + + - name: Boot up test system | seed backend + run: | + sudo chown runner:docker -R * + cd database + yarn && yarn dev_reset + cd ../backend + yarn && yarn seed + cd .. + + - 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: Sleep for 15 seconds + run: sleep 15s + + ########################################################################## + # END-TO-END TESTS ####################################################### + ########################################################################## + - name: End-to-end tests | run tests + id: e2e-tests + run: | + cd e2e-tests/cypress/tests/ + yarn + yarn run cypress run --spec cypress/e2e/User.Authentication.feature + - name: End-to-end tests | if tests failed, upload screenshots + if: steps.e2e-tests.outcome == 'failure' + uses: actions/upload-artifact@v3 + with: + name: cypress-screenshots + path: /home/runner/work/gradido/gradido/e2e-tests/cypress/tests/cypress/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d4eb48283..9ce354b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,73 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [1.14.1](https://github.com/gradido/gradido/compare/1.14.0...1.14.1) + +- fix(frontend): load contributionMessages is fixed [`#2390`](https://github.com/gradido/gradido/pull/2390) + +#### [1.14.0](https://github.com/gradido/gradido/compare/1.13.3...1.14.0) + +> 14 November 2022 + +- chore(release): version 1.14.0 [`#2389`](https://github.com/gradido/gradido/pull/2389) +- fix(frontend): close all open collapse by change tabs in community [`#2388`](https://github.com/gradido/gradido/pull/2388) +- fix(backend): corrected E-Mail texts [`#2386`](https://github.com/gradido/gradido/pull/2386) +- fix(frontend): better history messages [`#2381`](https://github.com/gradido/gradido/pull/2381) +- fix(frontend): mailto link [`#2383`](https://github.com/gradido/gradido/pull/2383) +- fix(admin): fix text in admin area to uppercase [`#2365`](https://github.com/gradido/gradido/pull/2365) +- feat(frontend): move the information about gradido being free to the auth layout [`#2349`](https://github.com/gradido/gradido/pull/2349) +- fix(admin): load error fixed for contribution link [`#2364`](https://github.com/gradido/gradido/pull/2364) +- fix(admin): edit contribution link does not take old values [`#2362`](https://github.com/gradido/gradido/pull/2362) +- fix(other): corrected dockerfile descriptions [`#2346`](https://github.com/gradido/gradido/pull/2346) +- feat(backend): 🍰 Send email for rejected contributions [`#2340`](https://github.com/gradido/gradido/pull/2340) +- feat(admin): edit automatic contribution link [`#2309`](https://github.com/gradido/gradido/pull/2309) +- refactor(backend): fix logger mocks [`#2308`](https://github.com/gradido/gradido/pull/2308) +- fix(admin): update contribution list after admin updates contribution [`#2330`](https://github.com/gradido/gradido/pull/2330) +- fix(frontend): inconsistent labeling on login register [`#2350`](https://github.com/gradido/gradido/pull/2350) +- feat(backend): setup hyperswarm [`#1874`](https://github.com/gradido/gradido/pull/1874) +- feat(other): lint pull request workflow [`#2338`](https://github.com/gradido/gradido/pull/2338) +- Feature: 🍰 add updated at to contributions [`#2237`](https://github.com/gradido/gradido/pull/2237) +- Refactor: GitHub test workflow - disable video recording and reduce wait time [`#2336`](https://github.com/gradido/gradido/pull/2336) +- 2274 feature concept manuel user registration for admins [`#2289`](https://github.com/gradido/gradido/pull/2289) +- 1574 concept to introduce gradidoID and change password encryption [`#2252`](https://github.com/gradido/gradido/pull/2252) +- contributionlink stage-2 and stage-3 of capturing and activation [`#2241`](https://github.com/gradido/gradido/pull/2241) +- Github workflow: update actions to the current API version using Node v 16 [`#2323`](https://github.com/gradido/gradido/pull/2323) +- feature: Fullstack tests in GitHub workflow [`#2319`](https://github.com/gradido/gradido/pull/2319) + +#### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3) + +> 1 November 2022 + +- release: Version 1.13.3 [`#2322`](https://github.com/gradido/gradido/pull/2322) +- 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312) +- fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302) +- fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320) +- bug: 2295 remove horizontal scrollbar in admin overview [`#2311`](https://github.com/gradido/gradido/pull/2311) +- 2292 community information contact [`#2313`](https://github.com/gradido/gradido/pull/2313) +- bug: 2315 Contribution Month and TEST FAIL in MASTER [`#2316`](https://github.com/gradido/gradido/pull/2316) +- 2291 add button for close contribution messages box [`#2314`](https://github.com/gradido/gradido/pull/2314) + +#### [1.13.2](https://github.com/gradido/gradido/compare/1.13.1...1.13.2) + +> 28 October 2022 + +- release: Version 1.13.2 [`#2307`](https://github.com/gradido/gradido/pull/2307) +- fix: 🍰 Links In Contribution Messages Target Blank [`#2306`](https://github.com/gradido/gradido/pull/2306) +- fix: Link in Contribution Messages [`#2305`](https://github.com/gradido/gradido/pull/2305) +- Refactor: 🍰 Change the query so that we only look on the ``contributions`` table. [`#2217`](https://github.com/gradido/gradido/pull/2217) +- Refactor: Admin Resolver Events and Logging [`#2244`](https://github.com/gradido/gradido/pull/2244) +- contibution messages, links are recognised [`#2248`](https://github.com/gradido/gradido/pull/2248) +- fix: Include Deleted Email Contacts in User Search [`#2281`](https://github.com/gradido/gradido/pull/2281) +- fix: Pagination Contributions jumps to wrong Page [`#2284`](https://github.com/gradido/gradido/pull/2284) +- fix: Changed some texts in E-Mails and Frontend [`#2276`](https://github.com/gradido/gradido/pull/2276) +- Feat: 🍰 Add `deletedBy` To Contributions And Admin Can Not Delete Own User Contribution [`#2236`](https://github.com/gradido/gradido/pull/2236) +- deleted contributions are displayed to the user [`#2277`](https://github.com/gradido/gradido/pull/2277) + #### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1) +> 20 October 2022 + +- release: Version 1.13.1 [`#2279`](https://github.com/gradido/gradido/pull/2279) - Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273) - Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231) diff --git a/admin/Dockerfile b/admin/Dockerfile index 41f986f87..ed0623a63 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -20,10 +20,10 @@ ENV PORT="8080" # Labels LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.name="gradido:admin" -LABEL org.label-schema.description="Gradido Vue Admin Interface" -LABEL org.label-schema.usage="https://github.com/gradido/gradido/admin/README.md" +LABEL org.label-schema.description="Gradido Admin Interface" +LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md" LABEL org.label-schema.url="https://gradido.net" -LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/backend" +LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/admin" LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}" LABEL org.label-schema.vendor="gradido Community" LABEL org.label-schema.version="${BUILD_VERSION}" diff --git a/admin/package.json b/admin/package.json index 2db889771..7f0e7ffd5 100644 --- a/admin/package.json +++ b/admin/package.json @@ -3,7 +3,7 @@ "description": "Administraion Interface for Gradido", "main": "index.js", "author": "Moriz Wahl", - "version": "1.13.1", + "version": "1.14.1", "license": "Apache-2.0", "private": false, "scripts": { @@ -53,6 +53,7 @@ "vuex-persistedstate": "^4.1.0" }, "devDependencies": { + "@apollo/client": "^3.7.1", "@babel/eslint-parser": "^7.15.8", "@intlify/eslint-plugin-vue-i18n": "^1.4.0", "@vue/cli-plugin-babel": "~4.5.0", @@ -71,6 +72,7 @@ "eslint-plugin-prettier": "3.3.1", "eslint-plugin-promise": "^5.1.1", "eslint-plugin-vue": "^7.20.0", + "mock-apollo-client": "^1.2.1", "postcss": "^8.4.8", "postcss-html": "^1.3.0", "postcss-scss": "^4.0.3", diff --git a/admin/src/components/CommunityStatistic.spec.js b/admin/src/components/CommunityStatistic.spec.js deleted file mode 100644 index dbcca5fed..000000000 --- a/admin/src/components/CommunityStatistic.spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { mount } from '@vue/test-utils' -import CommunityStatistic from './CommunityStatistic' - -const localVue = global.localVue - -const mocks = { - $t: jest.fn((t) => t), - $n: jest.fn((n) => n), -} - -const propsData = { - value: { - totalUsers: '123', - activeUsers: '100', - deletedUsers: '5', - totalGradidoCreated: '2500', - totalGradidoDecayed: '200', - totalGradidoAvailable: '500', - totalGradidoUnbookedDecayed: '111', - }, -} - -describe('CommunityStatistic', () => { - let wrapper - - const Wrapper = () => { - return mount(CommunityStatistic, { localVue, mocks, propsData }) - } - - describe('mount', () => { - beforeEach(() => { - wrapper = Wrapper() - }) - - it('renders the Div Element ".community-statistic"', () => { - expect(wrapper.find('div.community-statistic').exists()).toBe(true) - }) - }) -}) diff --git a/admin/src/components/CommunityStatistic.vue b/admin/src/components/CommunityStatistic.vue deleted file mode 100644 index c19f8deec..000000000 --- a/admin/src/components/CommunityStatistic.vue +++ /dev/null @@ -1,59 +0,0 @@ - - diff --git a/admin/src/components/ContentFooter.vue b/admin/src/components/ContentFooter.vue index bab3f5d12..a875100f6 100644 --- a/admin/src/components/ContentFooter.vue +++ b/admin/src/components/ContentFooter.vue @@ -1,7 +1,7 @@ diff --git a/admin/src/graphql/updateContributionLink.js b/admin/src/graphql/updateContributionLink.js new file mode 100644 index 000000000..24824bd86 --- /dev/null +++ b/admin/src/graphql/updateContributionLink.js @@ -0,0 +1,40 @@ +import gql from 'graphql-tag' + +export const updateContributionLink = gql` + mutation ( + $amount: Decimal! + $name: String! + $memo: String! + $cycle: String! + $validFrom: String + $validTo: String + $maxAmountPerMonth: Decimal + $maxPerCycle: Int! = 1 + $id: Int! + ) { + updateContributionLink( + amount: $amount + name: $name + memo: $memo + cycle: $cycle + validFrom: $validFrom + validTo: $validTo + maxAmountPerMonth: $maxAmountPerMonth + maxPerCycle: $maxPerCycle + id: $id + ) { + id + amount + name + memo + code + link + createdAt + validFrom + validTo + maxAmountPerMonth + cycle + maxPerCycle + } + } +` diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index 325d153dd..ad7a668e2 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -3,7 +3,9 @@ "back": "zurück", "contributionLink": { "amount": "Betrag", + "changeSaved": "Änderungen gespeichert", "clear": "Löschen", + "close": "Schließen", "contributionLinks": "Beitragslinks", "create": "Anlegen", "cycle": "Zyklus", @@ -23,6 +25,7 @@ "once": "einmalig" } }, + "saveChange": "Änderungen speichern", "validFrom": "Startdatum", "validTo": "Enddatum" }, @@ -82,7 +85,6 @@ "hide_details": "Details verbergen", "lastname": "Nachname", "math": { - "colon": ":", "equals": "=", "exclaim": "!", "pipe": "|", @@ -95,11 +97,13 @@ "multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.", "name": "Name", "navbar": { + "automaticContributions": "Automatische Beiträge", "logout": "Abmelden", "multi_creation": "Mehrfachschöpfung", "my-account": "Mein Konto", "open_creation": "Offene Schöpfungen", "overview": "Übersicht", + "statistic": "Statistik", "user_search": "Nutzersuche" }, "not_open_creations": "Keine offenen Schöpfungen", @@ -121,8 +125,9 @@ "save": "Speichern", "statistic": { "activeUsers": "Aktive Mitglieder", + "count": "Menge", "deletedUsers": "Gelöschte Mitglieder", - "name": "Statistik", + "details": "Details", "totalGradidoAvailable": "GDD insgesamt im Umlauf", "totalGradidoCreated": "GDD insgesamt geschöpft", "totalGradidoDecayed": "GDD insgesamt verfallen", diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index 8f050c34b..3f8751fa1 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -3,7 +3,9 @@ "back": "back", "contributionLink": { "amount": "Amount", + "changeSaved": "Changes saved", "clear": "Clear", + "close": "Close", "contributionLinks": "Contribution Links", "create": "Create", "cycle": "Cycle", @@ -23,6 +25,7 @@ "once": "once" } }, + "saveChange": "Save Changes", "validFrom": "Start-date", "validTo": "End-Date" }, @@ -82,7 +85,6 @@ "hide_details": "Hide details", "lastname": "Lastname", "math": { - "colon": ":", "equals": "=", "exclaim": "!", "pipe": "|", @@ -95,11 +97,13 @@ "multiple_creation_text": "Please select one or more members for which you would like to perform creations.", "name": "Name", "navbar": { + "automaticContributions": "Automatic Contributions", "logout": "Logout", "multi_creation": "Multiple creation", "my-account": "My Account", "open_creation": "Open creations", "overview": "Overview", + "statistic": "Statistic", "user_search": "User search" }, "not_open_creations": "No open creations", @@ -121,8 +125,9 @@ "save": "Speichern", "statistic": { "activeUsers": "Active members", + "count": "Count", "deletedUsers": "Deleted members", - "name": "Statistic", + "details": "Details", "totalGradidoAvailable": "Total GDD in circulation", "totalGradidoCreated": "Total created GDD", "totalGradidoDecayed": "Total GDD decay", diff --git a/admin/src/pages/CommunityStatistic.spec.js b/admin/src/pages/CommunityStatistic.spec.js new file mode 100644 index 000000000..50e04d11f --- /dev/null +++ b/admin/src/pages/CommunityStatistic.spec.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils' +import CommunityStatistic from './CommunityStatistic.vue' +import { communityStatistics } from '@/graphql/communityStatistics.js' +import { toastErrorSpy } from '../../test/testSetup' +import VueApollo from 'vue-apollo' +import { createMockClient } from 'mock-apollo-client' + +const mockClient = createMockClient() +const apolloProvider = new VueApollo({ + defaultClient: mockClient, +}) + +const localVue = global.localVue +localVue.use(VueApollo) + +const defaultData = () => { + return { + communityStatistics: { + totalUsers: 3113, + activeUsers: 1057, + deletedUsers: 35, + totalGradidoCreated: '4083774.05000000000000000000', + totalGradidoDecayed: '-1062639.13634129622923372197', + totalGradidoAvailable: '2513565.869444365732411569', + totalGradidoUnbookedDecayed: '-500474.6738366222166261272', + }, + } +} + +const mocks = { + $t: jest.fn((t) => t), + $n: jest.fn((n) => n), +} + +describe('CommunityStatistic', () => { + let wrapper + + const communityStatisticsMock = jest.fn() + + mockClient.setRequestHandler( + communityStatistics, + communityStatisticsMock + .mockRejectedValueOnce({ message: 'Ouch!' }) + .mockResolvedValue({ data: defaultData() }), + ) + + const Wrapper = () => { + return mount(CommunityStatistic, { localVue, mocks, apolloProvider }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders the Div Element ".community-statistic"', () => { + expect(wrapper.find('div.community-statistic').exists()).toBe(true) + }) + + describe('server response for get statistics is an error', () => { + it('toast an error message', () => { + expect(toastErrorSpy).toBeCalledWith('Ouch!') + }) + }) + + describe('server response for getting statistics is success', () => { + it('renders the data correctly', () => { + expect(wrapper.findAll('tr').at(1).findAll('td').at(1).text()).toEqual('3113') + expect(wrapper.findAll('tr').at(2).findAll('td').at(1).text()).toEqual('1057') + expect(wrapper.findAll('tr').at(3).findAll('td').at(1).text()).toEqual('35') + expect(wrapper.findAll('tr').at(4).findAll('td').at(1).text()).toEqual( + '4083774.05000000000000000000 GDD', + ) + expect(wrapper.findAll('tr').at(4).findAll('td').at(2).text()).toEqual( + '4083774.05000000000000000000', + ) + expect(wrapper.findAll('tr').at(5).findAll('td').at(1).text()).toEqual( + '-1062639.13634129622923372197 GDD', + ) + expect(wrapper.findAll('tr').at(5).findAll('td').at(2).text()).toEqual( + '-1062639.13634129622923372197', + ) + expect(wrapper.findAll('tr').at(6).findAll('td').at(1).text()).toEqual( + '2513565.869444365732411569 GDD', + ) + expect(wrapper.findAll('tr').at(6).findAll('td').at(2).text()).toEqual( + '2513565.869444365732411569', + ) + expect(wrapper.findAll('tr').at(7).findAll('td').at(1).text()).toEqual( + '-500474.6738366222166261272 GDD', + ) + expect(wrapper.findAll('tr').at(7).findAll('td').at(2).text()).toEqual( + '-500474.6738366222166261272', + ) + }) + }) + }) +}) diff --git a/admin/src/pages/CommunityStatistic.vue b/admin/src/pages/CommunityStatistic.vue new file mode 100644 index 000000000..3b4865ee3 --- /dev/null +++ b/admin/src/pages/CommunityStatistic.vue @@ -0,0 +1,42 @@ + + diff --git a/admin/src/pages/ContributionLinks.spec.js b/admin/src/pages/ContributionLinks.spec.js new file mode 100644 index 000000000..fb60a99cf --- /dev/null +++ b/admin/src/pages/ContributionLinks.spec.js @@ -0,0 +1,58 @@ +import { mount } from '@vue/test-utils' +import ContributionLinks from './ContributionLinks.vue' +import { listContributionLinks } from '@/graphql/listContributionLinks.js' + +const localVue = global.localVue + +const apolloQueryMock = jest.fn().mockResolvedValueOnce({ + data: { + listContributionLinks: { + links: [ + { + id: 1, + name: 'Meditation', + memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l', + amount: '200', + validFrom: '2022-04-01', + validTo: '2022-08-01', + cycle: 'täglich', + maxPerCycle: '3', + maxAmountPerMonth: 0, + link: 'https://localhost/redeem/CL-1a2345678', + }, + ], + count: 1, + }, + }, +}) + +const mocks = { + $t: jest.fn((t) => t), + $d: jest.fn((d) => d), + $apollo: { + query: apolloQueryMock, + }, +} + +describe('ContributionLinks', () => { + // eslint-disable-next-line no-unused-vars + let wrapper + + const Wrapper = () => { + return mount(ContributionLinks, { localVue, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('calls listContributionLinks', () => { + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + query: listContributionLinks, + }), + ) + }) + }) +}) diff --git a/admin/src/pages/ContributionLinks.vue b/admin/src/pages/ContributionLinks.vue new file mode 100644 index 000000000..75667523f --- /dev/null +++ b/admin/src/pages/ContributionLinks.vue @@ -0,0 +1,45 @@ + + diff --git a/admin/src/pages/CreationConfirm.spec.js b/admin/src/pages/CreationConfirm.spec.js index 0ea1aeba2..13fa24f5f 100644 --- a/admin/src/pages/CreationConfirm.spec.js +++ b/admin/src/pages/CreationConfirm.spec.js @@ -1,42 +1,22 @@ import { mount } from '@vue/test-utils' import CreationConfirm from './CreationConfirm.vue' import { adminDeleteContribution } from '../graphql/adminDeleteContribution' +import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions' import { confirmContribution } from '../graphql/confirmContribution' import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' +import VueApollo from 'vue-apollo' +import { createMockClient } from 'mock-apollo-client' + +const mockClient = createMockClient() +const apolloProvider = new VueApollo({ + defaultClient: mockClient, +}) const localVue = global.localVue -const storeCommitMock = jest.fn() -const apolloQueryMock = jest.fn().mockResolvedValue({ - data: { - listUnconfirmedContributions: [ - { - id: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - userId: 99, - email: 'bibi@bloxberg.de', - amount: 500, - memo: 'Danke für alles', - date: new Date(), - moderator: 1, - }, - { - id: 2, - firstName: 'Räuber', - lastName: 'Hotzenplotz', - userId: 100, - email: 'raeuber@hotzenplotz.de', - amount: 1000000, - memo: 'Gut Ergattert', - date: new Date(), - moderator: 1, - }, - ], - }, -}) +localVue.use(VueApollo) -const apolloMutateMock = jest.fn().mockResolvedValue({}) +const storeCommitMock = jest.fn() const mocks = { $t: jest.fn((t) => t), @@ -53,17 +33,69 @@ const mocks = { }, }, }, - $apollo: { - query: apolloQueryMock, - mutate: apolloMutateMock, - }, +} + +const defaultData = () => { + return { + listUnconfirmedContributions: [ + { + id: 1, + firstName: 'Bibi', + lastName: 'Bloxberg', + userId: 99, + email: 'bibi@bloxberg.de', + amount: 500, + memo: 'Danke für alles', + date: new Date(), + moderator: 1, + state: 'PENDING', + creation: [500, 500, 500], + messageCount: 0, + }, + { + id: 2, + firstName: 'Räuber', + lastName: 'Hotzenplotz', + userId: 100, + email: 'raeuber@hotzenplotz.de', + amount: 1000000, + memo: 'Gut Ergattert', + date: new Date(), + moderator: 1, + state: 'PENDING', + creation: [500, 500, 500], + messageCount: 0, + }, + ], + } } describe('CreationConfirm', () => { let wrapper + const listUnconfirmedContributionsMock = jest.fn() + const adminDeleteContributionMock = jest.fn() + const confirmContributionMock = jest.fn() + + mockClient.setRequestHandler( + listUnconfirmedContributions, + listUnconfirmedContributionsMock + .mockRejectedValueOnce({ message: 'Ouch!' }) + .mockResolvedValue({ data: defaultData() }), + ) + + mockClient.setRequestHandler( + adminDeleteContribution, + adminDeleteContributionMock.mockResolvedValue({ data: { adminDeleteContribution: true } }), + ) + + mockClient.setRequestHandler( + confirmContribution, + confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }), + ) + const Wrapper = () => { - return mount(CreationConfirm, { localVue, mocks }) + return mount(CreationConfirm, { localVue, mocks, apolloProvider }) } describe('mount', () => { @@ -72,12 +104,20 @@ describe('CreationConfirm', () => { wrapper = Wrapper() }) - it('has a DIV element with the class.creation-confirm', () => { - expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() + describe('server response for get pending creations is error', () => { + it('toast an error message', () => { + expect(toastErrorSpy).toBeCalledWith('Ouch!') + }) }) - it('has two pending creations', () => { - expect(wrapper.vm.pendingCreations).toHaveLength(2) + describe('server response is succes', () => { + it('has a DIV element with the class.creation-confirm', () => { + expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() + }) + + it('has two pending creations', () => { + expect(wrapper.vm.pendingCreations).toHaveLength(2) + }) }) describe('store', () => { @@ -105,10 +145,7 @@ describe('CreationConfirm', () => { }) it('calls the adminDeleteContribution mutation', () => { - expect(apolloMutateMock).toBeCalledWith({ - mutation: adminDeleteContribution, - variables: { id: 1 }, - }) + expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 }) }) it('commits openCreationsMinus to store', () => { @@ -128,7 +165,7 @@ describe('CreationConfirm', () => { }) it('does not call the adminDeleteContribution mutation', () => { - expect(apolloMutateMock).not.toBeCalled() + expect(adminDeleteContributionMock).not.toBeCalled() }) }) }) @@ -139,7 +176,7 @@ describe('CreationConfirm', () => { beforeEach(async () => { spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm') spy.mockImplementation(() => Promise.resolve('some value')) - apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' }) + adminDeleteContributionMock.mockRejectedValue({ message: 'Ouchhh!' }) await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click') }) @@ -150,7 +187,6 @@ describe('CreationConfirm', () => { describe('confirm creation with success', () => { beforeEach(async () => { - apolloMutateMock.mockResolvedValue({}) await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click') }) @@ -179,10 +215,7 @@ describe('CreationConfirm', () => { }) it('calls the confirmContribution mutation', () => { - expect(apolloMutateMock).toBeCalledWith({ - mutation: confirmContribution, - variables: { id: 2 }, - }) + expect(confirmContributionMock).toBeCalledWith({ id: 2 }) }) it('commits openCreationsMinus to store', () => { @@ -200,7 +233,7 @@ describe('CreationConfirm', () => { describe('confirm creation with error', () => { beforeEach(async () => { - apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' }) + confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' }) await wrapper.find('#overlay').findAll('button').at(1).trigger('click') }) @@ -210,19 +243,5 @@ describe('CreationConfirm', () => { }) }) }) - - describe('server response for get pending creations is error', () => { - beforeEach(() => { - jest.clearAllMocks() - apolloQueryMock.mockRejectedValue({ - message: 'Ouch!', - }) - wrapper = Wrapper() - }) - - it('toast an error message', () => { - expect(toastErrorSpy).toBeCalledWith('Ouch!') - }) - }) }) }) diff --git a/admin/src/pages/CreationConfirm.vue b/admin/src/pages/CreationConfirm.vue index 1f82fafc4..311c898a5 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -10,6 +10,7 @@ @remove-creation="removeCreation" @show-overlay="showOverlay" @update-state="updateState" + @update-contributions="$apollo.queries.PendingContributions.refetch()" /> @@ -71,21 +72,6 @@ export default { this.toastError(error.message) }) }, - getPendingCreations() { - this.$apollo - .query({ - query: listUnconfirmedContributions, - fetchPolicy: 'network-only', - }) - .then((result) => { - this.$store.commit('resetOpenCreations') - this.pendingCreations = result.data.listUnconfirmedContributions - this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length) - }) - .catch((error) => { - this.toastError(error.message) - }) - }, updatePendingCreations(id) { this.pendingCreations = this.pendingCreations.filter((obj) => obj.id !== id) this.$store.commit('openCreationsMinus', 1) @@ -127,8 +113,24 @@ export default { ] }, }, - async created() { - await this.getPendingCreations() + apollo: { + PendingContributions: { + query() { + return listUnconfirmedContributions + }, + variables() { + // may be at some point we need a pagination here + return {} + }, + update({ listUnconfirmedContributions }) { + this.$store.commit('resetOpenCreations') + this.pendingCreations = listUnconfirmedContributions + this.$store.commit('setOpenCreations', listUnconfirmedContributions.length) + }, + error({ message }) { + this.toastError(message) + }, + }, }, } diff --git a/admin/src/pages/Overview.spec.js b/admin/src/pages/Overview.spec.js index 1861c5330..8c714853f 100644 --- a/admin/src/pages/Overview.spec.js +++ b/admin/src/pages/Overview.spec.js @@ -1,7 +1,5 @@ import { mount } from '@vue/test-utils' import Overview from './Overview.vue' -import { listContributionLinks } from '@/graphql/listContributionLinks.js' -import { communityStatistics } from '@/graphql/communityStatistics.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' const localVue = global.localVue @@ -23,40 +21,6 @@ const apolloQueryMock = jest ], }, }) - .mockResolvedValueOnce({ - data: { - communityStatistics: { - totalUsers: 3113, - activeUsers: 1057, - deletedUsers: 35, - totalGradidoCreated: '4083774.05000000000000000000', - totalGradidoDecayed: '-1062639.13634129622923372197', - totalGradidoAvailable: '2513565.869444365732411569', - totalGradidoUnbookedDecayed: '-500474.6738366222166261272', - }, - }, - }) - .mockResolvedValueOnce({ - data: { - listContributionLinks: { - links: [ - { - id: 1, - name: 'Meditation', - memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l', - amount: '200', - validFrom: '2022-04-01', - validTo: '2022-08-01', - cycle: 'täglich', - maxPerCycle: '3', - maxAmountPerMonth: 0, - link: 'https://localhost/redeem/CL-1a2345678', - }, - ], - count: 1, - }, - }, - }) .mockResolvedValue({ data: { listUnconfirmedContributions: [ @@ -110,22 +74,6 @@ describe('Overview', () => { ) }) - it('calls communityStatistics', () => { - expect(apolloQueryMock).toBeCalledWith( - expect.objectContaining({ - query: communityStatistics, - }), - ) - }) - - it('calls listContributionLinks', () => { - expect(apolloQueryMock).toBeCalledWith( - expect.objectContaining({ - query: listContributionLinks, - }), - ) - }) - it('commits three pending creations to store', () => { expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3) }) diff --git a/admin/src/pages/Overview.vue b/admin/src/pages/Overview.vue index cfa247b8e..115fffb72 100644 --- a/admin/src/pages/Overview.vue +++ b/admin/src/pages/Overview.vue @@ -28,42 +28,13 @@ - - diff --git a/admin/src/plugins/apolloProvider.js b/admin/src/plugins/apolloProvider.js index 95b7aab7e..8b02013f4 100644 --- a/admin/src/plugins/apolloProvider.js +++ b/admin/src/plugins/apolloProvider.js @@ -10,7 +10,7 @@ const authLink = new ApolloLink((operation, forward) => { operation.setContext({ headers: { Authorization: token && token.length > 0 ? `Bearer ${token}` : '', - clientRequestTime: new Date().toString(), + clientTimezoneOffset: new Date().getTimezoneOffset(), }, }) return forward(operation).map((response) => { diff --git a/admin/src/plugins/apolloProvider.test.js b/admin/src/plugins/apolloProvider.test.js index 7889c3318..483862bea 100644 --- a/admin/src/plugins/apolloProvider.test.js +++ b/admin/src/plugins/apolloProvider.test.js @@ -94,7 +94,7 @@ describe('apolloProvider', () => { expect(setContextMock).toBeCalledWith({ headers: { Authorization: 'Bearer some-token', - clientRequestTime: expect.any(String), + clientTimezoneOffset: expect.any(Number), }, }) }) @@ -110,7 +110,7 @@ describe('apolloProvider', () => { expect(setContextMock).toBeCalledWith({ headers: { Authorization: '', - clientRequestTime: expect.any(String), + clientTimezoneOffset: expect.any(Number), }, }) }) diff --git a/admin/src/router/router.test.js b/admin/src/router/router.test.js index eb9b646cb..fdc4b0b83 100644 --- a/admin/src/router/router.test.js +++ b/admin/src/router/router.test.js @@ -44,8 +44,8 @@ describe('router', () => { }) describe('routes', () => { - it('has seven routes defined', () => { - expect(routes).toHaveLength(7) + it('has nine routes defined', () => { + expect(routes).toHaveLength(9) }) it('has "/overview" as default', async () => { @@ -81,6 +81,20 @@ describe('router', () => { }) }) + describe('contribution-links', () => { + it('loads the "ContributionLinks" page', async () => { + const component = await routes.find((r) => r.path === '/contribution-links').component() + expect(component.default.name).toBe('ContributionLinks') + }) + }) + + describe('statistics', () => { + it('loads the "CommunityStatistic" page', async () => { + const component = await routes.find((r) => r.path === '/statistic').component() + expect(component.default.name).toBe('CommunityStatistic') + }) + }) + describe('not found page', () => { it('renders the "NotFound" component', async () => { const component = await routes.find((r) => r.path === '*').component() diff --git a/admin/src/router/routes.js b/admin/src/router/routes.js index 72e7b1ac5..e365a6e40 100644 --- a/admin/src/router/routes.js +++ b/admin/src/router/routes.js @@ -6,6 +6,10 @@ const routes = [ path: '/', component: () => import('@/pages/Overview.vue'), }, + { + path: '/statistic', + component: () => import('@/pages/CommunityStatistic.vue'), + }, { // TODO: Implement a "You are logged out"-Page path: '/logout', @@ -23,6 +27,10 @@ const routes = [ path: '/creation-confirm', component: () => import('@/pages/CreationConfirm.vue'), }, + { + path: '/contribution-links', + component: () => import('@/pages/ContributionLinks.vue'), + }, { path: '*', component: () => import('@/components/NotFoundPage.vue'), diff --git a/admin/yarn.lock b/admin/yarn.lock index 09b543354..7507f2559 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -2,6 +2,25 @@ # yarn lockfile v1 +"@apollo/client@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.7.1.tgz#86ce47c18a0714e229231148b0306562550c2248" + integrity sha512-xu5M/l7p9gT9Fx7nF3AQivp0XukjB7TM7tOd5wifIpI8RskYveL4I+rpTijzWrnqCPZabkbzJKH7WEAKdctt9w== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + "@wry/context" "^0.7.0" + "@wry/equality" "^0.5.0" + "@wry/trie" "^0.3.0" + graphql-tag "^2.12.6" + hoist-non-react-statics "^3.3.2" + optimism "^0.16.1" + prop-types "^15.7.2" + response-iterator "^0.2.6" + symbol-observable "^4.0.0" + ts-invariant "^0.10.3" + tslib "^2.3.0" + zen-observable-ts "^1.2.5" + "@babel/code-frame@7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" @@ -1030,6 +1049,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@graphql-typed-document-node/core@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" + integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== + "@hapi/address@2.x.x": version "2.1.4" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -2419,6 +2443,20 @@ "@types/node" ">=6" tslib "^1.9.3" +"@wry/context@^0.6.0": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.6.1.tgz#c3c29c0ad622adb00f6a53303c4f965ee06ebeb2" + integrity sha512-LOmVnY1iTU2D8tv4Xf6MVMZZ+juIJ87Kt/plMijjN20NMAXGmH4u8bS1t0uT74cZ5gwpocYueV58YwyI8y+GKw== + dependencies: + tslib "^2.3.0" + +"@wry/context@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.7.0.tgz#be88e22c0ddf62aeb0ae9f95c3d90932c619a5c8" + integrity sha512-LcDAiYWRtwAoSOArfk7cuYvFXytxfVrdX7yxoUmK7pPITLk5jYh2F8knCwS7LjgYL8u1eidPlKKV6Ikqq0ODqQ== + dependencies: + tslib "^2.3.0" + "@wry/equality@^0.1.2": version "0.1.11" resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" @@ -2426,6 +2464,20 @@ dependencies: tslib "^1.9.3" +"@wry/equality@^0.5.0": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.5.3.tgz#fafebc69561aa2d40340da89fa7dc4b1f6fb7831" + integrity sha512-avR+UXdSrsF2v8vIqIgmeTY0UR91UT+IyablCyKe/uk22uOJ8fusKZnH9JH9e1/EtLeNJBtagNmL3eJdnOV53g== + dependencies: + tslib "^2.3.0" + +"@wry/trie@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.3.2.tgz#a06f235dc184bd26396ba456711f69f8c35097e6" + integrity sha512-yRTyhWSls2OY/pYLfwff867r8ekooZ4UI+/gxot5Wj8EFwSf2rG+n+Mo/6LoLQm1TKA4GRj2+LCpbfS937dClQ== + dependencies: + tslib "^2.3.0" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -6704,6 +6756,13 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== +graphql-tag@^2.12.6: + version "2.12.6" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" + integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg== + dependencies: + tslib "^2.1.0" + graphql-tag@^2.4.2: version "2.12.5" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.5.tgz#5cff974a67b417747d05c8d9f5f3cb4495d0db8f" @@ -6880,6 +6939,13 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" @@ -9174,7 +9240,7 @@ lolex@^5.0.0: dependencies: "@sinonjs/commons" "^1.7.0" -loose-envify@^1.0.0: +loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -9498,6 +9564,11 @@ mkdirp@0.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: dependencies: minimist "^1.2.5" +mock-apollo-client@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/mock-apollo-client/-/mock-apollo-client-1.2.1.tgz#e3bfdc3ff73b1fea28fa7e91ec82e43ba8cbfa39" + integrity sha512-QYQ6Hxo+t7hard1bcHHbsHxlNQYTQsaMNsm2Psh/NbwLMi2R4tGzplJKt97MUWuARHMq3GHB4PTLj/gxej4Caw== + moo-color@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" @@ -9987,6 +10058,14 @@ optimism@^0.10.0: dependencies: "@wry/context" "^0.4.0" +optimism@^0.16.1: + version "0.16.1" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.16.1.tgz#7c8efc1f3179f18307b887e18c15c5b7133f6e7d" + integrity sha512-64i+Uw3otrndfq5kaoGNoY7pvOhSsjFEN4bdEFh80MWVk/dbgJfMv7VFDeCT8LxNAlEVhQmdVEbfE7X2nWNIIg== + dependencies: + "@wry/context" "^0.6.0" + "@wry/trie" "^0.3.0" + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -10901,6 +10980,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types@^15.7.2: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -11080,7 +11168,7 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -react-is@^16.8.4: +react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -11423,6 +11511,11 @@ resolve@1.x, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.14.2, is-core-module "^2.2.0" path-parse "^1.0.6" +response-iterator@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/response-iterator/-/response-iterator-0.2.6.tgz#249005fb14d2e4eeb478a3f735a28fd8b4c9f3da" + integrity sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw== + restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -12446,6 +12539,11 @@ symbol-observable@^1.0.2: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +symbol-observable@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" + integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== + symbol-tree@^3.2.2, symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -12727,6 +12825,13 @@ tryer@^1.0.1: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== +ts-invariant@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.10.3.tgz#3e048ff96e91459ffca01304dbc7f61c1f642f6c" + integrity sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ== + dependencies: + tslib "^2.1.0" + ts-invariant@^0.4.0: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" @@ -12785,6 +12890,11 @@ tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -13844,7 +13954,14 @@ zen-observable-ts@^0.8.21: tslib "^1.9.3" zen-observable "^0.8.0" -zen-observable@^0.8.0: +zen-observable-ts@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz#6c6d9ea3d3a842812c6e9519209365a122ba8b58" + integrity sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg== + dependencies: + zen-observable "0.8.15" + +zen-observable@0.8.15, zen-observable@^0.8.0: version "0.8.15" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== diff --git a/backend/.env.dist b/backend/.env.dist index 648a2054c..3b6fe2ce4 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -1,4 +1,4 @@ -CONFIG_VERSION=v10.2022-09-20 +CONFIG_VERSION=v11.2022-10-27 # Server PORT=4000 @@ -60,3 +60,8 @@ EVENT_PROTOCOL_DISABLED=false # SET LOG LEVEL AS NEEDED IN YOUR .ENV # POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal # LOG_LEVEL=info + +# DHT +# if you set this value, the DHT hyperswarm will start to announce and listen +# on an hash created from this tpoic +# DHT_TOPIC=GRADIDO_HUB diff --git a/backend/.env.template b/backend/.env.template index dddf845dc..d009d08ff 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -55,3 +55,6 @@ WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET # EventProtocol EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED + +# DHT +DHT_TOPIC=$DHT_TOPIC diff --git a/backend/Dockerfile b/backend/Dockerfile index 035841c17..6225a4cd7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,7 @@ ################################################################################## # BASE ########################################################################### ################################################################################## -FROM node:12.19.0-alpine3.10 as base +FROM node:18.7.0-alpine3.16 as base # ENVs (available in production aswell, can be overwritten by commandline or env file) ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame diff --git a/backend/package.json b/backend/package.json index 6cd886735..3e26225bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "gradido-backend", - "version": "1.13.1", + "version": "1.14.1", "description": "Gradido unified backend providing an API-Service for Gradido Transactions", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/backend", @@ -18,6 +18,9 @@ "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts" }, "dependencies": { + "@hyperswarm/dht": "^6.2.0", + "@types/email-templates": "^10.0.1", + "@types/i18n": "^0.13.4", "@types/jest": "^27.0.2", "@types/lodash.clonedeep": "^4.5.6", "@types/uuid": "^8.3.4", @@ -29,14 +32,17 @@ "cross-env": "^7.0.3", "decimal.js-light": "^2.5.1", "dotenv": "^10.0.0", + "email-templates": "^10.0.1", "express": "^4.17.1", "graphql": "^15.5.1", + "i18n": "^0.15.1", "jest": "^27.2.4", "jsonwebtoken": "^8.5.1", "lodash.clonedeep": "^4.5.0", "log4js": "^6.4.6", "mysql2": "^2.3.0", "nodemailer": "^6.6.5", + "pug": "^3.0.2", "random-bigint": "^0.0.1", "reflect-metadata": "^0.1.13", "sodium-native": "^3.3.0", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index f31dfc93d..26227b90d 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,14 +10,14 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0051-add_delete_by_to_contributions', + DB_VERSION: '0053-change_password_encryption', 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: 'v10.2022-09-20', + EXPECTED: 'v11.2022-10-27', CURRENT: '', }, } @@ -116,6 +116,10 @@ if ( ) } +const federation = { + DHT_TOPIC: process.env.DHT_TOPIC || null, +} + const CONFIG = { ...constants, ...server, @@ -126,6 +130,7 @@ const CONFIG = { ...loginServer, ...webhook, ...eventProtocol, + ...federation, } export default CONFIG diff --git a/backend/src/emails/README.md b/backend/src/emails/README.md new file mode 100644 index 000000000..9ab1d1124 --- /dev/null +++ b/backend/src/emails/README.md @@ -0,0 +1,50 @@ +# Using `forwardemail`–`email-templates` With `pug` Package + +You'll find the GitHub repository of the `email-templates` package and the `pug` package here: + +- [email-templates](https://github.com/forwardemail/email-templates) +- [pug](https://www.npmjs.com/package/pug) + +## `pug` Documentation + +The full `pug` documentation you'll find here: + +- [pugjs.org](https://pugjs.org/) + +### Caching Possibility + +In case we are sending many emails in the future there is the possibility to cache the `pug` templates: + +- [cache-pug-templates](https://github.com/ladjs/cache-pug-templates) + +## Testing + +To test your send emails you have different possibilities: + +### In General + +To send emails to yourself while developing set in `.env` the value `EMAIL_TEST_MODUS=true` and `EMAIL_TEST_RECEIVER` to your preferred email address. + +### Unit Or Integration Tests + +To change the behavior to show previews etc. you have the following options to be set in `sendEmailTranslated.ts` on creating the email object: + +```js + const email = new Email({ + … + // send emails in development/test env: + send: true, + … + // to open send emails in the browser + preview: true, + // or + // to open send emails in a specific the browser + preview: { + open: { + app: 'firefox', + wait: false, + }, + }, + … + }) +``` diff --git a/backend/src/emails/accountMultiRegistration/html.pug b/backend/src/emails/accountMultiRegistration/html.pug new file mode 100644 index 000000000..4c8a94d28 --- /dev/null +++ b/backend/src/emails/accountMultiRegistration/html.pug @@ -0,0 +1,22 @@ +doctype html +html(lang=locale) + head + title= t('emails.accountMultiRegistration.subject') + body + h1(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.subject') + #container.col + p(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.helloName', { firstName, lastName }) + p= t('emails.accountMultiRegistration.emailReused') + br + span= t('emails.accountMultiRegistration.emailExists') + p= t('emails.accountMultiRegistration.onForgottenPasswordClickLink') + br + a(href=resendLink) #{resendLink} + br + span= t('emails.accountMultiRegistration.onForgottenPasswordCopyLink') + p= t('emails.accountMultiRegistration.ifYouAreNotTheOne') + br + a(href='https://gradido.net/de/contact/') https://gradido.net/de/contact/ + p(style='margin-top: 24px;')= t('emails.accountMultiRegistration.sincerelyYours') + br + span= t('emails.accountMultiRegistration.yourGradidoTeam') diff --git a/backend/src/emails/accountMultiRegistration/subject.pug b/backend/src/emails/accountMultiRegistration/subject.pug new file mode 100644 index 000000000..322f07c78 --- /dev/null +++ b/backend/src/emails/accountMultiRegistration/subject.pug @@ -0,0 +1 @@ += t('emails.accountMultiRegistration.subject') \ No newline at end of file diff --git a/backend/src/emails/sendEmailTranslated.test.ts b/backend/src/emails/sendEmailTranslated.test.ts new file mode 100644 index 000000000..28327f779 --- /dev/null +++ b/backend/src/emails/sendEmailTranslated.test.ts @@ -0,0 +1,110 @@ +import { createTransport } from 'nodemailer' +import { logger, i18n } from '@test/testSetup' +import CONFIG from '@/config' +import { sendEmailTranslated } from './sendEmailTranslated' + +CONFIG.EMAIL = false +CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL' +CONFIG.EMAIL_SMTP_PORT = '1234' +CONFIG.EMAIL_USERNAME = 'user' +CONFIG.EMAIL_PASSWORD = 'pwd' + +jest.mock('nodemailer', () => { + return { + __esModule: true, + createTransport: jest.fn(() => { + return { + sendMail: jest.fn(() => { + return { + messageId: 'message', + } + }), + } + }), + } +}) + +describe('sendEmailTranslated', () => { + let result: Record | null + + describe('config email is false', () => { + beforeEach(async () => { + result = await sendEmailTranslated({ + receiver: { + to: 'receiver@mail.org', + cc: 'support@gradido.net', + }, + template: 'accountMultiRegistration', + locals: { + locale: 'en', + }, + }) + }) + + it('logs warning', () => { + expect(logger.info).toBeCalledWith('Emails are disabled via config...') + }) + + it('returns false', () => { + expect(result).toBeFalsy() + }) + }) + + describe('config email is true', () => { + beforeEach(async () => { + CONFIG.EMAIL = true + result = await sendEmailTranslated({ + receiver: { + to: 'receiver@mail.org', + cc: 'support@gradido.net', + }, + template: 'accountMultiRegistration', + locals: { + locale: 'en', + }, + }) + }) + + it('calls the transporter', () => { + expect(createTransport).toBeCalledWith({ + host: 'EMAIL_SMTP_URL', + port: 1234, + secure: false, + requireTLS: true, + auth: { + user: 'user', + pass: 'pwd', + }, + }) + }) + + describe('call of "sendEmailTranslated"', () => { + it('has expected result', () => { + expect(result).toMatchObject({ + envelope: { + from: 'info@gradido.net', + to: ['receiver@mail.org', 'support@gradido.net'], + }, + message: expect.any(String), + originalMessage: expect.objectContaining({ + to: 'receiver@mail.org', + cc: 'support@gradido.net', + from: 'Gradido (nicht antworten) ', + attachments: [], + subject: 'Gradido: Try To Register Again With Your Email', + html: expect.stringContaining('Gradido: Try To Register Again With Your Email'), + text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'), + }), + }) + }) + }) + + it.skip('calls "i18n.setLocale" with "en"', () => { + expect(i18n.setLocale).toBeCalledWith('en') + }) + + it.skip('calls "i18n.__" for translation', () => { + expect(i18n.__).toBeCalled() + }) + }) +}) diff --git a/backend/src/emails/sendEmailTranslated.ts b/backend/src/emails/sendEmailTranslated.ts new file mode 100644 index 000000000..3fe4177f4 --- /dev/null +++ b/backend/src/emails/sendEmailTranslated.ts @@ -0,0 +1,85 @@ +import { backendLogger as logger } from '@/server/logger' +import path from 'path' +import { createTransport } from 'nodemailer' +import Email from 'email-templates' +import i18n from 'i18n' + +import CONFIG from '@/config' + +export const sendEmailTranslated = async (params: { + receiver: { + to: string + cc?: string + } + template: string + locals: Record +}): Promise | null> => { + let resultSend: Record | null = null + + // TODO: test the calling order of 'i18n.setLocale' for example: language of logging 'en', language of email receiver 'es', reset language of current user 'de' + + // because language of receiver can differ from language of current user who triggers the sending + const rememberLocaleToRestore = i18n.getLocale() + + i18n.setLocale('en') // for logging + logger.info( + `send Email: language=${params.locals.locale} to=${params.receiver.to}` + + (params.receiver.cc ? `, cc=${params.receiver.cc}` : '') + + `, subject=${i18n.__('emails.' + params.template + '.subject')}`, + ) + + if (!CONFIG.EMAIL) { + logger.info(`Emails are disabled via config...`) + return null + } + // because 'CONFIG.EMAIL_TEST_MODUS' can be boolean 'true' or string '`false`' + if (CONFIG.EMAIL_TEST_MODUS === true) { + logger.info( + `Testmodus=ON: change receiver from ${params.receiver.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`, + ) + params.receiver.to = CONFIG.EMAIL_TEST_RECEIVER + } + const transport = createTransport({ + host: CONFIG.EMAIL_SMTP_URL, + port: Number(CONFIG.EMAIL_SMTP_PORT), + secure: false, // true for 465, false for other ports + requireTLS: true, + auth: { + user: CONFIG.EMAIL_USERNAME, + pass: CONFIG.EMAIL_PASSWORD, + }, + }) + + i18n.setLocale(params.locals.locale) // for email + + // TESTING: see 'README.md' + const email = new Email({ + message: { + from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, + }, + transport, + preview: false, + // i18n, // is only needed if you don't install i18n + }) + + // ATTENTION: await is needed, because otherwise on send the email gets send in the language of the current user, because below the language gets reset + await email + .send({ + template: path.join(__dirname, params.template), + message: params.receiver, + locals: params.locals, // the 'locale' in here seems not to be used by 'email-template', because it doesn't work if the language isn't set before by 'i18n.setLocale' + }) + .then((result: Record) => { + resultSend = result + logger.info('Send email successfully !!!') + logger.info('Result: ', result) + }) + .catch((error: unknown) => { + logger.error('Error sending notification email: ', error) + throw new Error('Error sending notification email!') + }) + + i18n.setLocale(rememberLocaleToRestore) + + return resultSend +} diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/sendEmailVariants.test.ts new file mode 100644 index 000000000..4ac8221a7 --- /dev/null +++ b/backend/src/emails/sendEmailVariants.test.ts @@ -0,0 +1,88 @@ +import CONFIG from '@/config' +import { sendAccountMultiRegistrationEmail } from './sendEmailVariants' +import { sendEmailTranslated } from './sendEmailTranslated' + +CONFIG.EMAIL = true +CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL' +CONFIG.EMAIL_SMTP_PORT = '1234' +CONFIG.EMAIL_USERNAME = 'user' +CONFIG.EMAIL_PASSWORD = 'pwd' + +jest.mock('./sendEmailTranslated', () => { + const originalModule = jest.requireActual('./sendEmailTranslated') + return { + __esModule: true, + sendEmailTranslated: jest.fn((a) => originalModule.sendEmailTranslated(a)), + } +}) + +describe('sendEmailVariants', () => { + let result: Record | null + + describe('sendAccountMultiRegistrationEmail', () => { + beforeAll(async () => { + result = await sendAccountMultiRegistrationEmail({ + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + language: 'en', + }) + }) + + describe('calls "sendEmailTranslated"', () => { + it('with expected parameters', () => { + expect(sendEmailTranslated).toBeCalledWith({ + receiver: { + to: 'Peter Lustig ', + }, + template: 'accountMultiRegistration', + locals: { + firstName: 'Peter', + lastName: 'Lustig', + locale: 'en', + resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, + }, + }) + }) + + it('has expected result', () => { + expect(result).toMatchObject({ + envelope: { + from: 'info@gradido.net', + to: ['peter@lustig.de'], + }, + message: expect.any(String), + originalMessage: expect.objectContaining({ + to: 'Peter Lustig ', + from: 'Gradido (nicht antworten) ', + attachments: [], + subject: 'Gradido: Try To Register Again With Your Email', + html: + expect.stringContaining( + 'Gradido: Try To Register Again With Your Email', + ) && + expect.stringContaining('>Gradido: Try To Register Again With Your Email') && + expect.stringContaining( + 'Your email address has just been used again to register an account with Gradido.', + ) && + expect.stringContaining( + 'However, an account already exists for your email address.', + ) && + expect.stringContaining( + 'Please click on the following link if you have forgotten your password:', + ) && + expect.stringContaining( + `${CONFIG.EMAIL_LINK_FORGOTPASSWORD}`, + ) && + expect.stringContaining('or copy the link above into your browser window.') && + expect.stringContaining( + 'If you are not the one who tried to register again, please contact our support:', + ) && + expect.stringContaining('Sincerely yours,
your Gradido team'), + text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'), + }), + }) + }) + }) + }) +}) diff --git a/backend/src/emails/sendEmailVariants.ts b/backend/src/emails/sendEmailVariants.ts new file mode 100644 index 000000000..fb142f206 --- /dev/null +++ b/backend/src/emails/sendEmailVariants.ts @@ -0,0 +1,20 @@ +import CONFIG from '@/config' +import { sendEmailTranslated } from './sendEmailTranslated' + +export const sendAccountMultiRegistrationEmail = (data: { + firstName: string + lastName: string + email: string + language: string +}): Promise | null> => { + return sendEmailTranslated({ + receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` }, + template: 'accountMultiRegistration', + locals: { + locale: data.language, + firstName: data.firstName, + lastName: data.lastName, + resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, + }, + }) +} diff --git a/backend/src/event/Event.ts b/backend/src/event/Event.ts index d86ddf15f..09a31d4e0 100644 --- a/backend/src/event/Event.ts +++ b/backend/src/event/Event.ts @@ -66,6 +66,9 @@ export class EventTransactionCreation extends EventBasicTx {} export class EventTransactionReceive extends EventBasicTxX {} export class EventTransactionReceiveRedeem extends EventBasicTxX {} export class EventContributionCreate extends EventBasicCt {} +export class EventAdminContributionCreate extends EventBasicCt {} +export class EventAdminContributionDelete extends EventBasicCt {} +export class EventAdminContributionUpdate extends EventBasicCt {} export class EventUserCreateContributionMessage extends EventBasicCtMsg {} export class EventAdminCreateContributionMessage extends EventBasicCtMsg {} export class EventContributionDelete extends EventBasicCt {} @@ -74,6 +77,14 @@ export class EventContributionConfirm extends EventBasicCtX {} export class EventContributionDeny extends EventBasicCtX {} export class EventContributionLinkDefine extends EventBasicCt {} export class EventContributionLinkActivateRedeem extends EventBasicCt {} +export class EventDeleteUser extends EventBasicUserId {} +export class EventUndeleteUser extends EventBasicUserId {} +export class EventChangeUserRole extends EventBasicUserId {} +export class EventAdminUpdateContribution extends EventBasicCt {} +export class EventAdminDeleteContribution extends EventBasicCt {} +export class EventCreateContributionLink extends EventBasicCt {} +export class EventDeleteContributionLink extends EventBasicCt {} +export class EventUpdateContributionLink extends EventBasicCt {} export class Event { constructor() @@ -289,6 +300,27 @@ export class Event { return this } + public setEventAdminContributionCreate(ev: EventAdminContributionCreate): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_CREATE + + return this + } + + public setEventAdminContributionDelete(ev: EventAdminContributionDelete): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_DELETE + + return this + } + + public setEventAdminContributionUpdate(ev: EventAdminContributionUpdate): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_UPDATE + + return this + } + public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event { this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId) this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE @@ -345,6 +377,62 @@ export class Event { return this } + public setEventDeleteUser(ev: EventDeleteUser): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.DELETE_USER + + return this + } + + public setEventUndeleteUser(ev: EventUndeleteUser): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.UNDELETE_USER + + return this + } + + public setEventChangeUserRole(ev: EventChangeUserRole): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.CHANGE_USER_ROLE + + return this + } + + public setEventAdminUpdateContribution(ev: EventAdminUpdateContribution): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_UPDATE_CONTRIBUTION + + return this + } + + public setEventAdminDeleteContribution(ev: EventAdminDeleteContribution): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_DELETE_CONTRIBUTION + + return this + } + + public setEventCreateContributionLink(ev: EventCreateContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.CREATE_CONTRIBUTION_LINK + + return this + } + + public setEventDeleteContributionLink(ev: EventDeleteContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.DELETE_CONTRIBUTION_LINK + + return this + } + + public setEventUpdateContributionLink(ev: EventUpdateContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.UPDATE_CONTRIBUTION_LINK + + return this + } + setByBasicUser(userId: number): Event { this.setEventBasic() this.userId = userId diff --git a/backend/src/event/EventProtocolType.ts b/backend/src/event/EventProtocolType.ts index d53eb6961..b7c2f0151 100644 --- a/backend/src/event/EventProtocolType.ts +++ b/backend/src/event/EventProtocolType.ts @@ -33,6 +33,17 @@ export enum EventProtocolType { CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM', CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE', CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE', + ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE', + ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE', + ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE', USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE', ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE', + DELETE_USER = 'DELETE_USER', + UNDELETE_USER = 'UNDELETE_USER', + CHANGE_USER_ROLE = 'CHANGE_USER_ROLE', + ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION', + ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION', + CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK', + DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK', + UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK', } diff --git a/backend/src/federation/@types/@hyperswarm__dht/index.d.ts b/backend/src/federation/@types/@hyperswarm__dht/index.d.ts new file mode 100644 index 000000000..efb9ad438 --- /dev/null +++ b/backend/src/federation/@types/@hyperswarm__dht/index.d.ts @@ -0,0 +1 @@ +declare module '@hyperswarm/dht' diff --git a/backend/src/federation/index.ts b/backend/src/federation/index.ts new file mode 100644 index 000000000..2ca58b432 --- /dev/null +++ b/backend/src/federation/index.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import DHT from '@hyperswarm/dht' +// import { Connection } from '@dbTools/typeorm' +import { backendLogger as logger } from '@/server/logger' + +function between(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min) +} + +const POLLTIME = 20000 +const SUCCESSTIME = 120000 +const ERRORTIME = 240000 +const ANNOUNCETIME = 30000 +const nodeRand = between(1, 99) +const nodeURL = `https://test${nodeRand}.org` +const nodeAPI = { + API_1_00: `${nodeURL}/api/1_00/`, + API_1_01: `${nodeURL}/api/1_01/`, + API_2_00: `${nodeURL}/graphql/2_00/`, +} + +export const startDHT = async ( + // connection: Connection, + topic: string, +): Promise => { + try { + const TOPIC = DHT.hash(Buffer.from(topic)) + + const keyPair = DHT.keyPair() + + const node = new DHT({ keyPair }) + + const server = node.createServer() + + server.on('connection', function (socket: any) { + // noiseSocket is E2E between you and the other peer + // pipe it somewhere like any duplex stream + logger.info(`Remote public key: ${socket.remotePublicKey.toString('hex')}`) + // console.log("Local public key", noiseSocket.publicKey.toString("hex")); // same as keyPair.publicKey + + socket.on('data', (data: Buffer) => logger.info(`data: ${data.toString('ascii')}`)) + + // process.stdin.pipe(noiseSocket).pipe(process.stdout); + }) + + await server.listen() + + setInterval(async () => { + logger.info(`Announcing on topic: ${TOPIC.toString('hex')}`) + await node.announce(TOPIC, keyPair).finished() + }, ANNOUNCETIME) + + let successfulRequests: string[] = [] + let errorfulRequests: string[] = [] + + setInterval(async () => { + logger.info('Refreshing successful nodes') + successfulRequests = [] + }, SUCCESSTIME) + + setInterval(async () => { + logger.info('Refreshing errorful nodes') + errorfulRequests = [] + }, ERRORTIME) + + setInterval(async () => { + const result = await node.lookup(TOPIC) + + const collectedPubKeys: string[] = [] + + for await (const data of result) { + data.peers.forEach((peer: any) => { + const pubKey = peer.publicKey.toString('hex') + if ( + pubKey !== keyPair.publicKey.toString('hex') && + !successfulRequests.includes(pubKey) && + !errorfulRequests.includes(pubKey) && + !collectedPubKeys.includes(pubKey) + ) { + collectedPubKeys.push(peer.publicKey.toString('hex')) + } + }) + } + + logger.info(`Found new peers: ${collectedPubKeys}`) + + collectedPubKeys.forEach((remotePubKey) => { + // publicKey here is keyPair.publicKey from above + const socket = node.connect(Buffer.from(remotePubKey, 'hex')) + + // socket.once("connect", function () { + // console.log("client side emitted connect"); + // }); + + // socket.once("end", function () { + // console.log("client side ended"); + // }); + + socket.once('error', (err: any) => { + errorfulRequests.push(remotePubKey) + logger.error(`error on peer ${remotePubKey}: ${err.message}`) + }) + + socket.on('open', function () { + // noiseSocket fully open with the other peer + // console.log("writing to socket"); + socket.write(Buffer.from(`${nodeRand}`)) + socket.write(Buffer.from(JSON.stringify(nodeAPI))) + successfulRequests.push(remotePubKey) + }) + // pipe it somewhere like any duplex stream + // process.stdin.pipe(noiseSocket).pipe(process.stdout) + }) + }, POLLTIME) + } catch (err) { + logger.error(err) + } +} diff --git a/backend/src/graphql/enum/PasswordEncryptionType.ts b/backend/src/graphql/enum/PasswordEncryptionType.ts new file mode 100644 index 000000000..b3a00d748 --- /dev/null +++ b/backend/src/graphql/enum/PasswordEncryptionType.ts @@ -0,0 +1,12 @@ +import { registerEnumType } from 'type-graphql' + +export enum PasswordEncryptionType { + NO_PASSWORD = 0, + EMAIL = 1, + GRADIDO_ID = 2, +} + +registerEnumType(PasswordEncryptionType, { + name: 'PasswordEncryptionType', // this one is mandatory + description: 'Type of the password encryption', // this one is optional +}) diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index 281f2b99b..503bab472 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { objectValuesToArray } from '@/util/utilities' -import { testEnvironment, resetToken, cleanDB } from '@test/helpers' +import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' @@ -42,6 +42,9 @@ import { Contribution } from '@entity/Contribution' import { Transaction as DbTransaction } from '@entity/Transaction' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { EventProtocol } from '@entity/EventProtocol' +import { EventProtocolType } from '@/event/EventProtocolType' +import { logger } from '@test/testSetup' // mock account activation email to avoid console spam jest.mock('@/mailer/sendAccountActivationEmail', () => { @@ -80,6 +83,12 @@ let user: User let creation: Contribution | void let result: any +describe('contributionDateFormatter', () => { + it('formats the date correctly', () => { + expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024') + }) +}) + describe('AdminResolver', () => { describe('set user role', () => { describe('unauthenticated', () => { @@ -136,6 +145,7 @@ describe('AdminResolver', () => { describe('user to get a new role does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), ).resolves.toEqual( @@ -144,6 +154,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('change role with success', () => { @@ -188,6 +202,7 @@ describe('AdminResolver', () => { describe('change role with error', () => { describe('is own role', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), ).resolves.toEqual( @@ -196,11 +211,15 @@ describe('AdminResolver', () => { }), ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Administrator can not change his own role!') + }) }) describe('user has already role to be set', () => { describe('to admin', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true }, @@ -213,10 +232,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already admin!') + }) }) describe('to usual user', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false }, @@ -229,6 +253,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already a usual user!') + }) }) }) }) @@ -289,6 +317,7 @@ describe('AdminResolver', () => { describe('user to be deleted does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), ).resolves.toEqual( @@ -297,10 +326,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('delete self', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: deleteUser, variables: { userId: admin.id } }), ).resolves.toEqual( @@ -309,6 +343,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') + }) }) describe('delete with success', () => { @@ -330,6 +368,7 @@ describe('AdminResolver', () => { describe('delete deleted user', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: deleteUser, variables: { userId: user.id } }), ).resolves.toEqual( @@ -338,6 +377,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) + }) }) }) }) @@ -397,6 +440,7 @@ describe('AdminResolver', () => { describe('user to be undelete does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), ).resolves.toEqual( @@ -405,6 +449,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('user to undelete is not deleted', () => { @@ -413,6 +461,7 @@ describe('AdminResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), ).resolves.toEqual( @@ -422,6 +471,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is not deleted') + }) + describe('undelete deleted user', () => { beforeAll(async () => { await mutate({ mutation: deleteUser, variables: { userId: user.id } }) @@ -704,7 +757,7 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -814,7 +867,7 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -889,18 +942,25 @@ describe('AdminResolver', () => { }) describe('adminCreateContribution', () => { + const now = new Date() + beforeAll(async () => { - const now = new Date() creation = await creationFactory(testEnv, { email: 'peter@lustig.de', amount: 400, memo: 'Herzlich Willkommen bei Gradido!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), }) }) describe('user to create for does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -909,15 +969,25 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find user with email: bibi@bloxberg.de', + ) + }) }) describe('user to create for is deleted', () => { beforeAll(async () => { user = await userFactory(testEnv, stephenHawking) variables.email = 'stephen@hawking.uk' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -928,15 +998,25 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'This user was deleted. Cannot create a contribution.', + ) + }) }) describe('user to create for has email not confirmed', () => { beforeAll(async () => { user = await userFactory(testEnv, garrickOllivander) variables.email = 'garrick@ollivander.com' + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ) }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -947,16 +1027,44 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Contribution could not be saved, Email is not activated', + ) + }) }) describe('valid user to create for', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) variables.email = 'bibi@bloxberg.de' + variables.creationDate = 'invalid-date' }) describe('date of creation is not a date string', () => { it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ mutation: adminCreateContribution, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`) + }) + }) + + describe('date of creation is four months ago', () => { + it('throws an error', async () => { + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 4, 1), + ) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -967,36 +1075,21 @@ describe('AdminResolver', () => { }), ) }) - }) - describe('date of creation is four months ago', () => { - it('throws an error', async () => { - const now = new Date() - variables.creationDate = new Date( - now.getFullYear(), - now.getMonth() - 4, - 1, - ).toString() - await expect( - mutate({ mutation: adminCreateContribution, variables }), - ).resolves.toEqual( - expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], - }), + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + new Date(variables.creationDate).toString(), ) }) }) describe('date of creation is in the future', () => { it('throws an error', async () => { - const now = new Date() - variables.creationDate = new Date( - now.getFullYear(), - now.getMonth() + 4, - 1, - ).toString() + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() + 4, 1), + ) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -1007,11 +1100,19 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + new Date(variables.creationDate).toString(), + ) + }) }) describe('amount of creation is too high', () => { it('throws an error', async () => { - variables.creationDate = new Date().toString() + jest.clearAllMocks() + variables.creationDate = contributionDateFormatter(now) await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -1024,6 +1125,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) describe('creation is valid', () => { @@ -1039,6 +1146,15 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the admin create contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, + userId: admin.id, + }), + ) + }) }) describe('second creation surpasses the available amount ', () => { @@ -1056,6 +1172,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ) + }) }) }) }) @@ -1078,7 +1200,7 @@ describe('AdminResolver', () => { email, amount: new Decimal(500), memo: 'Grundeinkommen', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), } }) @@ -1115,6 +1237,7 @@ describe('AdminResolver', () => { describe('user for creation to update does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1123,7 +1246,7 @@ describe('AdminResolver', () => { email: 'bob@baumeister.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1134,10 +1257,17 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find UserContact with email: bob@baumeister.de', + ) + }) }) describe('user for creation to update is deleted', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1146,7 +1276,7 @@ describe('AdminResolver', () => { email: 'stephen@hawking.uk', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1155,10 +1285,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') + }) }) describe('creation does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1167,7 +1302,7 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1176,10 +1311,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('No contribution found to given id.') + }) }) describe('user email does not match creation user', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1188,7 +1328,9 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1201,11 +1343,18 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'user of the pending contribution and send user does not correspond', + ) + }) }) describe('creation update is not valid', () => { // as this test has not clearly defined that date, it is a false positive - it.skip('throws an error', async () => { + it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1214,24 +1363,32 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(1900), memo: 'Danke Peter!', - creationDate: new Date().toString(), + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( expect.objectContaining({ errors: [ new GraphQLError( - 'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.', + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', ), ], }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) - describe('creation update is successful changing month', () => { + describe.skip('creation update is successful changing month', () => { // skipped as changing the month is currently disable - it.skip('returns update creation object', async () => { + it('returns update creation object', async () => { await expect( mutate({ mutation: adminUpdateContribution, @@ -1240,7 +1397,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(300), memo: 'Danke Peter!', - creationDate: new Date().toString(), + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1250,17 +1409,26 @@ describe('AdminResolver', () => { date: expect.any(String), memo: 'Danke Peter!', amount: '300', - creation: ['1000', '1000', '200'], + creation: ['1000', '700', '500'], }, }, }), ) }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) }) describe('creation update is successful without changing month', () => { // actually this mutation IS changing the month - it.skip('returns update creation object', async () => { + it('returns update creation object', async () => { await expect( mutate({ mutation: adminUpdateContribution, @@ -1269,7 +1437,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(200), memo: 'Das war leider zu Viel!', - creationDate: new Date().toString(), + creationDate: creation + ? contributionDateFormatter(creation.contributionDate) + : contributionDateFormatter(new Date()), }, }), ).resolves.toEqual( @@ -1279,12 +1449,21 @@ describe('AdminResolver', () => { date: expect.any(String), memo: 'Das war leider zu Viel!', amount: '200', - creation: ['1000', '1000', '300'], + creation: ['1000', '800', '500'], }, }, }), ) }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) }) }) @@ -1304,10 +1483,10 @@ describe('AdminResolver', () => { lastName: 'Lustig', email: 'peter@lustig.de', date: expect.any(String), - memo: 'Herzlich Willkommen bei Gradido!', - amount: '400', + memo: 'Das war leider zu Viel!', + amount: '200', moderator: admin.id, - creation: ['1000', '600', '500'], + creation: ['1000', '800', '500'], }, { id: expect.any(Number), @@ -1318,7 +1497,7 @@ describe('AdminResolver', () => { memo: 'Grundeinkommen', amount: '500', moderator: admin.id, - creation: ['1000', '600', '500'], + creation: ['1000', '800', '500'], }, { id: expect.any(Number), @@ -1352,6 +1531,7 @@ describe('AdminResolver', () => { describe('adminDeleteContribution', () => { describe('creation id does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminDeleteContribution, @@ -1365,6 +1545,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) }) describe('admin deletes own user contribution', () => { @@ -1378,12 +1562,13 @@ describe('AdminResolver', () => { variables: { amount: 100.0, memo: 'Test env contribution', - creationDate: new Date().toString(), + creationDate: contributionDateFormatter(new Date()), }, }) }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminDeleteContribution, @@ -1414,12 +1599,22 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the admin delete contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, + userId: admin.id, + }), + ) + }) }) }) describe('confirmContribution', () => { describe('creation does not exits', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: confirmContribution, @@ -1433,6 +1628,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) }) describe('confirm own creation', () => { @@ -1442,7 +1641,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: 400, memo: 'Herzlich Willkommen bei Gradido!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, 1), + ), }) }) @@ -1460,6 +1661,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') + }) }) describe('confirm creation for other user', () => { @@ -1469,7 +1674,9 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: 450, memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), }) }) @@ -1488,6 +1695,14 @@ describe('AdminResolver', () => { ) }) + it('stores the contribution confirm event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_CONFIRM, + }), + ) + }) + it('creates a transaction', async () => { const transaction = await DbTransaction.find() expect(transaction[0].amount.toString()).toBe('450') @@ -1512,6 +1727,14 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the send confirmation email event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.SEND_CONFIRMATION_EMAIL, + }), + ) + }) }) describe('confirm two creations one after the other quickly', () => { @@ -1524,13 +1747,17 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: 50, memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), }) c2 = await creationFactory(testEnv, { email: 'bibi@bloxberg.de', amount: 50, memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', - creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, 1), + ), }) }) @@ -2052,6 +2279,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + it('returns an error if missing endDate', async () => { await expect( mutate({ @@ -2068,6 +2301,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + it('returns an error if endDate is before startDate', async () => { await expect( mutate({ @@ -2087,6 +2326,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + it('returns an error if name is an empty string', async () => { await expect( mutate({ @@ -2103,6 +2348,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + it('returns an error if name is shorter than 5 characters', async () => { await expect( mutate({ @@ -2123,6 +2372,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + it('returns an error if name is longer than 100 characters', async () => { await expect( mutate({ @@ -2143,6 +2398,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + it('returns an error if memo is an empty string', async () => { await expect( mutate({ @@ -2159,6 +2420,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + it('returns an error if memo is shorter than 5 characters', async () => { await expect( mutate({ @@ -2179,6 +2444,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + it('returns an error if memo is longer than 255 characters', async () => { await expect( mutate({ @@ -2199,6 +2470,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + it('returns an error if amount is not positive', async () => { await expect( mutate({ @@ -2216,6 +2493,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) }) describe('listContributionLinks', () => { @@ -2271,6 +2554,10 @@ describe('AdminResolver', () => { }) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + describe('valid id', () => { let linkId: number beforeAll(async () => { @@ -2336,6 +2623,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) }) describe('valid id', () => { diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 4fd85dc0f..80c69a864 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -1,4 +1,4 @@ -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { backendLogger as logger } from '@/server/logger' import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' import { @@ -49,6 +49,7 @@ import { validateContribution, isStartEndDateValid, updateCreations, + isValidDateString, } from './util/creations' import { CONTRIBUTIONLINK_NAME_MAX_CHARS, @@ -63,7 +64,17 @@ import ContributionMessageArgs from '@arg/ContributionMessageArgs' import { ContributionMessageType } from '@enum/MessageType' import { ContributionMessage } from '@model/ContributionMessage' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' +import { eventProtocol } from '@/event/EventProtocolEmitter' +import { + Event, + EventAdminContributionCreate, + EventAdminContributionDelete, + EventAdminContributionUpdate, + EventContributionConfirm, + EventSendConfirmationEmail, +} from '@/event/Event' import { ContributionListResult } from '../model/Contribution' // const EMAIL_OPT_IN_REGISTER = 1 @@ -76,7 +87,9 @@ export class AdminResolver { async searchUsers( @Args() { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, + @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const userRepository = getCustomRepository(UserRepository) const userFields = [ 'id', @@ -104,7 +117,10 @@ export class AdminResolver { } } - const creations = await getUserCreations(users.map((u) => u.id)) + const creations = await getUserCreations( + users.map((u) => u.id), + clientTimezoneOffset, + ) const adminUsers = await Promise.all( users.map(async (user) => { @@ -145,11 +161,13 @@ export class AdminResolver { const user = await dbUser.findOne({ id: userId }) // user exists ? if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } // administrator user changes own role? const moderatorUser = getUser(context) if (moderatorUser.id === userId) { + logger.error('Administrator can not change his own role!') throw new Error('Administrator can not change his own role!') } // change isAdmin @@ -158,6 +176,7 @@ export class AdminResolver { if (isAdmin === true) { user.isAdmin = new Date() } else { + logger.error('User is already a usual user!') throw new Error('User is already a usual user!') } break @@ -165,6 +184,7 @@ export class AdminResolver { if (isAdmin === false) { user.isAdmin = null } else { + logger.error('User is already admin!') throw new Error('User is already admin!') } break @@ -183,11 +203,13 @@ export class AdminResolver { const user = await dbUser.findOne({ id: userId }) // user exists ? if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } // moderator user disabled own account? const moderatorUser = getUser(context) if (moderatorUser.id === userId) { + logger.error('Moderator can not delete his own account!') throw new Error('Moderator can not delete his own account!') } // soft-delete user @@ -201,9 +223,11 @@ export class AdminResolver { async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } if (!user.deletedAt) { + logger.error('User is not deleted') throw new Error('User is not deleted') } await user.recover() @@ -219,6 +243,11 @@ export class AdminResolver { logger.info( `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, ) + const clientTimezoneOffset = getClientTimezoneOffset(context) + if (!isValidDateString(creationDate)) { + logger.error(`invalid Date for creationDate=${creationDate}`) + throw new Error(`invalid Date for creationDate=${creationDate}`) + } const emailContact = await UserContact.findOne({ where: { email }, withDeleted: true, @@ -240,13 +269,15 @@ export class AdminResolver { logger.error('Contribution could not be saved, Email is not activated') throw new Error('Contribution could not be saved, Email is not activated') } + + const event = new Event() const moderator = getUser(context) logger.trace('moderator: ', moderator.id) - const creations = await getUserCreation(emailContact.userId) + const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset) logger.trace('creations:', creations) const creationDateObj = new Date(creationDate) logger.trace('creationDateObj:', creationDateObj) - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) const contribution = DbContribution.create() contribution.userId = emailContact.userId contribution.amount = amount @@ -258,8 +289,18 @@ export class AdminResolver { contribution.contributionStatus = ContributionStatus.PENDING logger.trace('contribution to save', contribution) + await DbContribution.save(contribution) - return getUserCreation(emailContact.userId) + + const eventAdminCreateContribution = new EventAdminContributionCreate() + eventAdminCreateContribution.userId = moderator.id + eventAdminCreateContribution.amount = amount + eventAdminCreateContribution.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionCreate(eventAdminCreateContribution), + ) + + return getUserCreation(emailContact.userId, clientTimezoneOffset) } @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @@ -295,6 +336,7 @@ export class AdminResolver { @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const emailContact = await UserContact.findOne({ where: { email }, withDeleted: true, @@ -319,7 +361,6 @@ export class AdminResolver { const contributionToUpdate = await DbContribution.findOne({ where: { id, confirmedAt: IsNull() }, }) - if (!contributionToUpdate) { logger.error('No contribution found to given id.') throw new Error('No contribution found to given id.') @@ -336,16 +377,17 @@ export class AdminResolver { } const creationDateObj = new Date(creationDate) - let creations = await getUserCreation(user.id) + let creations = await getUserCreation(user.id, clientTimezoneOffset) + if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { - creations = updateCreations(creations, contributionToUpdate) + creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) } else { logger.error('Currently the month of the contribution cannot change.') throw new Error('Currently the month of the contribution cannot change.') } // all possible cases not to be true are thrown in this function - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) contributionToUpdate.amount = amount contributionToUpdate.memo = memo contributionToUpdate.contributionDate = new Date(creationDate) @@ -353,19 +395,30 @@ export class AdminResolver { contributionToUpdate.contributionStatus = ContributionStatus.PENDING await DbContribution.save(contributionToUpdate) + const result = new AdminUpdateContribution() result.amount = amount result.memo = contributionToUpdate.memo result.date = contributionToUpdate.contributionDate - result.creation = await getUserCreation(user.id) + result.creation = await getUserCreation(user.id, clientTimezoneOffset) + + const event = new Event() + const eventAdminContributionUpdate = new EventAdminContributionUpdate() + eventAdminContributionUpdate.userId = user.id + eventAdminContributionUpdate.amount = amount + eventAdminContributionUpdate.contributionId = contributionToUpdate.id + await eventProtocol.writeEvent( + event.setEventAdminContributionUpdate(eventAdminContributionUpdate), + ) return result } @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Query(() => [UnconfirmedContribution]) - async listUnconfirmedContributions(): Promise { + async listUnconfirmedContributions(@Ctx() context: Context): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const contributions = await getConnection() .createQueryBuilder() .select('c') @@ -379,7 +432,7 @@ export class AdminResolver { } const userIds = contributions.map((p) => p.userId) - const userCreations = await getUserCreations(userIds) + const userCreations = await getUserCreations(userIds, clientTimezoneOffset) const users = await dbUser.find({ where: { id: In(userIds) }, withDeleted: true, @@ -416,10 +469,34 @@ export class AdminResolver { ) { throw new Error('Own contribution can not be deleted as admin') } + const user = await dbUser.findOneOrFail( + { id: contribution.userId }, + { relations: ['emailContact'] }, + ) contribution.contributionStatus = ContributionStatus.DELETED contribution.deletedBy = moderator.id await contribution.save() const res = await contribution.softRemove() + + const event = new Event() + const eventAdminContributionDelete = new EventAdminContributionDelete() + eventAdminContributionDelete.userId = contribution.userId + eventAdminContributionDelete.amount = contribution.amount + eventAdminContributionDelete.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionDelete(eventAdminContributionDelete), + ) + sendContributionRejectedEmail({ + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + recipientEmail: user.emailContact.email, + recipientFirstName: user.firstName, + recipientLastName: user.lastName, + contributionMemo: contribution.memo, + contributionAmount: contribution.amount, + overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, + }) + return !!res } @@ -429,6 +506,7 @@ export class AdminResolver { @Arg('id', () => Int) id: number, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const contribution = await DbContribution.findOne(id) if (!contribution) { logger.error(`Contribution not found for given id: ${id}`) @@ -447,8 +525,13 @@ export class AdminResolver { logger.error('This user was deleted. Cannot confirm a contribution.') throw new Error('This user was deleted. Cannot confirm a contribution.') } - const creations = await getUserCreation(contribution.userId, false) - validateContribution(creations, contribution.amount, contribution.contributionDate) + const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false) + validateContribution( + creations, + contribution.amount, + contribution.contributionDate, + clientTimezoneOffset, + ) const receivedCallDate = new Date() @@ -515,6 +598,13 @@ export class AdminResolver { } finally { await queryRunner.release() } + + const event = new Event() + const eventContributionConfirm = new EventContributionConfirm() + eventContributionConfirm.userId = user.id + eventContributionConfirm.amount = contribution.amount + eventContributionConfirm.contributionId = contribution.id + await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) return true } @@ -576,6 +666,13 @@ export class AdminResolver { // In case EMails are disabled log the activation link for the user if (!emailSent) { logger.info(`Account confirmation link: ${activationLink}`) + } else { + const event = new Event() + const eventSendConfirmationEmail = new EventSendConfirmationEmail() + eventSendConfirmationEmail.userId = user.id + await eventProtocol.writeEvent( + event.setEventSendConfirmationEmail(eventSendConfirmationEmail), + ) } return true @@ -768,9 +865,11 @@ export class AdminResolver { relations: ['user'], }) if (!contribution) { + logger.error('Contribution not found') throw new Error('Contribution not found') } if (contribution.userId === user.id) { + logger.error('Admin can not answer on own contribution') throw new Error('Admin can not answer on own contribution') } if (!contribution.user.emailContact) { diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 323efe5d9..e512961e7 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -74,6 +74,7 @@ describe('ContributionResolver', () => { describe('input not valid', () => { it('throws error when memo length smaller than 5 chars', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -92,10 +93,11 @@ describe('ContributionResolver', () => { }) it('logs the error found', () => { - expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < (5)`) + expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < 5`) }) it('throws error when memo length greater than 255 chars', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -114,10 +116,11 @@ describe('ContributionResolver', () => { }) it('logs the error found', () => { - expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > (255)`) + expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > 255`) }) it('throws error when creationDate not-valid', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: createContribution, @@ -144,6 +147,7 @@ describe('ContributionResolver', () => { }) it('throws error when creationDate 3 month behind', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -375,6 +379,7 @@ describe('ContributionResolver', () => { describe('wrong contribution id', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateContribution, @@ -399,6 +404,7 @@ describe('ContributionResolver', () => { describe('Memo length smaller than 5 chars', () => { it('throws error', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -418,12 +424,13 @@ describe('ContributionResolver', () => { }) it('logs the error found', () => { - expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < (5)') + expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5') }) }) describe('Memo length greater than 255 chars', () => { it('throws error', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -443,7 +450,7 @@ describe('ContributionResolver', () => { }) it('logs the error found', () => { - expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > (255)') + expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > 255') }) }) @@ -456,6 +463,7 @@ describe('ContributionResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateContribution, @@ -486,6 +494,7 @@ describe('ContributionResolver', () => { describe('admin tries to update a user contribution', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -516,6 +525,7 @@ describe('ContributionResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateContribution, @@ -546,6 +556,7 @@ describe('ContributionResolver', () => { describe('update creation to a date that is older than 3 months', () => { it('throws an error', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -564,7 +575,7 @@ describe('ContributionResolver', () => { ) }) - it('logs the error found', () => { + it.skip('logs the error found', () => { expect(logger.error).toBeCalledWith( 'No information for available creations with the given creationDate=', 'Invalid Date', @@ -830,6 +841,7 @@ describe('ContributionResolver', () => { describe('User deletes already confirmed contribution', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index aec7bc44d..15bdbfc2e 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -1,5 +1,5 @@ import { RIGHTS } from '@/auth/RIGHTS' -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { backendLogger as logger } from '@/server/logger' import { Contribution as dbContribution } from '@entity/Contribution' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' @@ -13,6 +13,8 @@ import { Contribution, ContributionListResult } from '@model/Contribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { validateContribution, getUserCreation, updateCreations } from './util/creations' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' +import { ContributionMessage } from '@entity/ContributionMessage' +import { ContributionMessageType } from '@enum/MessageType' import { Event, EventContributionCreate, @@ -29,23 +31,24 @@ export class ContributionResolver { @Args() { amount, memo, creationDate }: ContributionArgs, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS})`) + logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) } if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS})`) + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } const event = new Event() const user = getUser(context) - const creations = await getUserCreation(user.id) + const creations = await getUserCreation(user.id, clientTimezoneOffset) logger.trace('creations', creations) const creationDateObj = new Date(creationDate) - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) const contribution = dbContribution.create() contribution.userId = user.id @@ -169,13 +172,14 @@ export class ContributionResolver { @Args() { amount, memo, creationDate }: ContributionArgs, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`) + logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) } if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`) + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } @@ -192,22 +196,50 @@ export class ContributionResolver { logger.error('user of the pending contribution and send user does not correspond') throw new Error('user of the pending contribution and send user does not correspond') } - + if ( + contributionToUpdate.contributionStatus !== ContributionStatus.IN_PROGRESS && + contributionToUpdate.contributionStatus !== ContributionStatus.PENDING + ) { + logger.error( + `Contribution can not be updated since the state is ${contributionToUpdate.contributionStatus}`, + ) + throw new Error( + `Contribution can not be updated since the state is ${contributionToUpdate.contributionStatus}`, + ) + } const creationDateObj = new Date(creationDate) - let creations = await getUserCreation(user.id) + let creations = await getUserCreation(user.id, clientTimezoneOffset) if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { - creations = updateCreations(creations, contributionToUpdate) + creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset) } else { logger.error('Currently the month of the contribution cannot change.') throw new Error('Currently the month of the contribution cannot change.') } // all possible cases not to be true are thrown in this function - validateContribution(creations, amount, creationDateObj) + validateContribution(creations, amount, creationDateObj, clientTimezoneOffset) + + const contributionMessage = ContributionMessage.create() + contributionMessage.contributionId = contributionId + contributionMessage.createdAt = contributionToUpdate.updatedAt + ? contributionToUpdate.updatedAt + : contributionToUpdate.createdAt + const changeMessage = `${contributionToUpdate.contributionDate} + --- + ${contributionToUpdate.memo} + --- + ${contributionToUpdate.amount}` + contributionMessage.message = changeMessage + contributionMessage.isModerator = false + contributionMessage.userId = user.id + contributionMessage.type = ContributionMessageType.HISTORY + ContributionMessage.save(contributionMessage) + contributionToUpdate.amount = amount contributionToUpdate.memo = memo contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionStatus = ContributionStatus.PENDING + contributionToUpdate.updatedAt = new Date() dbContribution.save(contributionToUpdate) const event = new Event() diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index b0c061d91..7bfae319e 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -63,6 +63,8 @@ export class StatisticsResolver { .where('transaction.decay IS NOT NULL') .getRawOne() + await queryRunner.release() + return { totalUsers, activeUsers, diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 3d40adbf6..275242bd3 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -6,8 +6,15 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' import { cleanDB, testEnvironment } from '@test/helpers' import { userFactory } from '@/seeds/factory/user' -import { login, createContributionLink, redeemTransactionLink } from '@/seeds/graphql/mutations' +import { + login, + createContributionLink, + redeemTransactionLink, + createContribution, + updateContribution, +} from '@/seeds/graphql/mutations' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import Decimal from 'decimal.js-light' import { GraphQLError } from 'graphql' @@ -32,6 +39,7 @@ describe('TransactionLinkResolver', () => { describe('redeem daily Contribution Link', () => { const now = new Date() let contributionLink: DbContributionLink | undefined + let contribution: UnconfirmedContribution | undefined beforeAll(async () => { await mutate({ @@ -79,56 +87,59 @@ describe('TransactionLinkResolver', () => { ) }) - it('allows the user to redeem the contribution link', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - data: { - redeemTransactionLink: true, - }, - errors: undefined, - }) - }) - - it('does not allow the user to redeem the contribution link a second time on the same day', async () => { - await expect( - mutate({ - mutation: redeemTransactionLink, - variables: { - code: 'CL-' + (contributionLink ? contributionLink.code : ''), - }, - }), - ).resolves.toMatchObject({ - errors: [ - new GraphQLError( - 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', - ), - ], - }) - }) - - describe('after one day', () => { + describe('user has pending contribution of 1000 GDD', () => { beforeAll(async () => { - jest.useFakeTimers() - /* eslint-disable-next-line @typescript-eslint/no-empty-function */ - setTimeout(() => {}, 1000 * 60 * 60 * 24) - jest.runAllTimers() await mutate({ mutation: login, - variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + const result = await mutate({ + mutation: createContribution, + variables: { + amount: new Decimal(1000), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, + }) + contribution = result.data.createContribution + }) + + it('does not allow the user to redeem the contribution link', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.', + ), + ], + }) + }) + }) + + describe('user has no pending contributions that would not allow to redeem the link', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: updateContribution, + variables: { + contributionId: contribution ? contribution.id : -1, + amount: new Decimal(800), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, }) }) - afterAll(() => { - jest.useRealTimers() - }) - - it('allows the user to redeem the contribution link again', async () => { + it('allows the user to redeem the contribution link', async () => { await expect( mutate({ mutation: redeemTransactionLink, @@ -160,6 +171,56 @@ describe('TransactionLinkResolver', () => { ], }) }) + + describe('after one day', () => { + beforeAll(async () => { + jest.useFakeTimers() + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + setTimeout(() => {}, 1000 * 60 * 60 * 24) + jest.runAllTimers() + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('allows the user to redeem the contribution link again', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + data: { + redeemTransactionLink: true, + }, + errors: undefined, + }) + }) + + it('does not allow the user to redeem the contribution link a second time on the same day', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + ), + ], + }) + }) + }) }) }) }) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 4ba5dcd0b..a5c4a5f01 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,5 +1,5 @@ import { backendLogger as logger } from '@/server/logger' -import { Context, getUser } from '@/server/context' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { getConnection } from '@dbTools/typeorm' import { Resolver, @@ -169,6 +169,7 @@ export class TransactionLinkResolver { @Arg('code', () => String) code: string, @Ctx() context: Context, ): Promise { + const clientTimezoneOffset = getClientTimezoneOffset(context) const user = getUser(context) const now = new Date() @@ -258,9 +259,9 @@ export class TransactionLinkResolver { } } - const creations = await getUserCreation(user.id, false) + const creations = await getUserCreation(user.id, clientTimezoneOffset) logger.info('open creations', creations) - validateContribution(creations, contributionLink.amount, now) + validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset) const contribution = new DbContribution() contribution.userId = user.id contribution.createdAt = now diff --git a/backend/src/graphql/resolver/TransactionResolver.test.ts b/backend/src/graphql/resolver/TransactionResolver.test.ts index d391f8ab9..9e74623c8 100644 --- a/backend/src/graphql/resolver/TransactionResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionResolver.test.ts @@ -67,6 +67,7 @@ describe('send coins', () => { describe('unknown recipient', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: login, variables: bobData, @@ -93,6 +94,7 @@ describe('send coins', () => { describe('deleted recipient', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: login, variables: peterData, @@ -125,6 +127,7 @@ describe('send coins', () => { describe('recipient account not activated', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: login, variables: peterData, @@ -166,6 +169,7 @@ describe('send coins', () => { describe('sender and recipient are the same', () => { it('throws an error', async () => { + jest.clearAllMocks() expect( await mutate({ mutation: sendCoins, @@ -189,6 +193,7 @@ describe('send coins', () => { describe('memo text is too long', () => { it('throws an error', async () => { + jest.clearAllMocks() expect( await mutate({ mutation: sendCoins, @@ -212,6 +217,7 @@ describe('send coins', () => { describe('memo text is too short', () => { it('throws an error', async () => { + jest.clearAllMocks() expect( await mutate({ mutation: sendCoins, @@ -235,6 +241,7 @@ describe('send coins', () => { describe('user has not enough GDD', () => { it('throws an error', async () => { + jest.clearAllMocks() expect( await mutate({ mutation: sendCoins, @@ -260,6 +267,7 @@ describe('send coins', () => { describe('sending negative amount', () => { it('throws an error', async () => { + jest.clearAllMocks() expect( await mutate({ mutation: sendCoins, diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index cf4ad8d4b..d8472fba9 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -19,7 +19,7 @@ import { GraphQLError } from 'graphql' import { User } from '@entity/User' import CONFIG from '@/config' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' -import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' +import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { printTimeDuration, activationLink } from './UserResolver' import { contributionLinkFactory } from '@/seeds/factory/contributionLink' @@ -29,13 +29,16 @@ import { TransactionLink } from '@entity/TransactionLink' import { EventProtocolType } from '@/event/EventProtocolType' import { EventProtocol } from '@entity/EventProtocol' -import { logger } from '@test/testSetup' +import { logger, i18n as localization } from '@test/testSetup' import { validate as validateUUID, version as versionUUID } from 'uuid' import { peterLustig } from '@/seeds/users/peter-lustig' import { UserContact } from '@entity/UserContact' import { OptInType } from '../enum/OptInType' import { UserContactType } from '../enum/UserContactType' import { bobBaumeister } from '@/seeds/users/bob-baumeister' +import { encryptPassword } from '@/password/PasswordEncryptor' +import { PasswordEncryptionType } from '../enum/PasswordEncryptionType' +import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils' // import { klicktippSignIn } from '@/apis/KlicktippController' @@ -46,7 +49,7 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => { } }) -jest.mock('@/mailer/sendAccountMultiRegistrationEmail', () => { +jest.mock('@/emails/sendEmailVariants', () => { return { __esModule: true, sendAccountMultiRegistrationEmail: jest.fn(), @@ -73,7 +76,7 @@ let mutate: any, query: any, con: any let testEnv: any beforeAll(async () => { - testEnv = await testEnvironment(logger) + testEnv = await testEnvironment(logger, localization) mutate = testEnv.mutate query = testEnv.query con = testEnv.con @@ -146,6 +149,7 @@ describe('UserResolver', () => { publisherId: 1234, referrerId: null, contributionLinkId: null, + passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD, }, ]) const valUUID = validateUUID(user[0].gradidoID) @@ -213,6 +217,7 @@ describe('UserResolver', () => { firstName: 'Peter', lastName: 'Lustig', email: 'peter@lustig.de', + language: 'de', }) }) @@ -490,7 +495,8 @@ describe('UserResolver', () => { }) it('updates the password', () => { - expect(newUser.password).toEqual('3917921995996627700') + const encryptedPass = encryptPassword(newUser, 'Aa12345_') + expect(newUser.password.toString()).toEqual(encryptedPass.toString()) }) /* @@ -514,18 +520,20 @@ describe('UserResolver', () => { await mutate({ mutation: createUser, variables: createUserVariables }) const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email }) emailVerificationCode = emailContact.emailVerificationCode.toString() - result = await mutate({ - mutation: setPassword, - variables: { code: emailVerificationCode, password: 'not-valid' }, - }) }) afterAll(async () => { await cleanDB() }) - it('throws an error', () => { - expect(result).toEqual( + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: setPassword, + variables: { code: emailVerificationCode, password: 'not-valid' }, + }), + ).toEqual( expect.objectContaining({ errors: [ new GraphQLError( @@ -544,18 +552,20 @@ describe('UserResolver', () => { describe('no valid optin code', () => { beforeAll(async () => { await mutate({ mutation: createUser, variables: createUserVariables }) - result = await mutate({ - mutation: setPassword, - variables: { code: 'not valid', password: 'Aa12345_' }, - }) }) afterAll(async () => { await cleanDB() }) - it('throws an error', () => { - expect(result).toEqual( + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: setPassword, + variables: { code: 'not valid', password: 'Aa12345_' }, + }), + ).toEqual( expect.objectContaining({ errors: [new GraphQLError('Could not login with emailVerificationCode')], }), @@ -582,13 +592,9 @@ describe('UserResolver', () => { }) describe('no users in database', () => { - beforeAll(async () => { + it('throws an error', async () => { jest.clearAllMocks() - result = await mutate({ mutation: login, variables }) - }) - - it('throws an error', () => { - expect(result).toEqual( + expect(await mutate({ mutation: login, variables })).toEqual( expect.objectContaining({ errors: [new GraphQLError('No user with this credentials')], }), @@ -666,6 +672,7 @@ describe('UserResolver', () => { describe('logout', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(mutate({ mutation: logout })).resolves.toEqual( expect.objectContaining({ @@ -704,6 +711,7 @@ describe('UserResolver', () => { describe('verifyLogin', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(query({ query: verifyLogin })).resolves.toEqual( expect.objectContaining({ @@ -723,6 +731,7 @@ describe('UserResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(query({ query: verifyLogin })).resolves.toEqual( expect.objectContaining({ @@ -883,6 +892,7 @@ describe('UserResolver', () => { describe('wrong optin code', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( query({ query: queryOptIn, variables: { optIn: 'not-valid' } }), ).resolves.toEqual( @@ -919,6 +929,7 @@ describe('UserResolver', () => { describe('updateUserInfos', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(mutate({ mutation: updateUserInfos })).resolves.toEqual( expect.objectContaining({ @@ -976,6 +987,7 @@ describe('UserResolver', () => { describe('language is not valid', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateUserInfos, @@ -998,6 +1010,7 @@ describe('UserResolver', () => { describe('password', () => { describe('wrong old password', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateUserInfos, @@ -1020,6 +1033,7 @@ describe('UserResolver', () => { describe('invalid new password', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateUserInfos, @@ -1108,6 +1122,7 @@ describe('UserResolver', () => { describe('searchAdminUsers', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual( expect.objectContaining({ @@ -1149,6 +1164,93 @@ describe('UserResolver', () => { }) }) }) + + describe('password encryption type', () => { + describe('user just registered', () => { + let bibi: User + + it('has password type gradido id', async () => { + const users = await User.find() + bibi = users[1] + + expect(bibi).toEqual( + expect.objectContaining({ + password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0] + .readBigUInt64LE() + .toString(), + passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID, + }), + ) + }) + }) + + describe('user has encryption type email', () => { + const variables = { + email: 'bibi@bloxberg.de', + password: 'Aa12345_', + publisherId: 1234, + } + + let bibi: User + beforeAll(async () => { + const usercontact = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + bibi = usercontact.user + bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL + bibi.password = SecretKeyCryptographyCreateKey( + 'bibi@bloxberg.de', + 'Aa12345_', + )[0].readBigUInt64LE() + + await bibi.save() + }) + + it('changes to gradidoID on login', async () => { + await mutate({ mutation: login, variables: variables }) + + const usercontact = await UserContact.findOneOrFail( + { email: 'bibi@bloxberg.de' }, + { relations: ['user'] }, + ) + bibi = usercontact.user + + expect(bibi).toEqual( + expect.objectContaining({ + firstName: 'Bibi', + password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0] + .readBigUInt64LE() + .toString(), + passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID, + }), + ) + }) + + it('can login after password change', async () => { + resetToken() + expect(await mutate({ mutation: login, variables: variables })).toEqual( + expect.objectContaining({ + data: { + login: { + email: 'bibi@bloxberg.de', + firstName: 'Bibi', + hasElopage: false, + id: expect.any(Number), + isAdmin: null, + klickTipp: { + newsletterState: false, + }, + language: 'de', + lastName: 'Bloxberg', + publisherId: 1234, + }, + }, + }), + ) + }) + }) + }) }) describe('printTimeDuration', () => { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 2287ede98..752c585fd 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,6 +1,7 @@ import fs from 'fs' import { backendLogger as logger } from '@/server/logger' -import { Context, getUser } from '@/server/context' +import i18n from 'i18n' +import { Context, getUser, getClientTimezoneOffset } from '@/server/context' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import CONFIG from '@/config' @@ -18,7 +19,7 @@ import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddle import { OptInType } from '@enum/OptInType' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' -import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' +import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants' import { klicktippSignIn } from '@/apis/KlicktippController' import { RIGHTS } from '@/auth/RIGHTS' import { hasElopageBuys } from '@/util/hasElopageBuys' @@ -39,17 +40,15 @@ import { SearchAdminUsersResult } from '@model/AdminUser' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' import { v4 as uuidv4 } from 'uuid' +import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils' +import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor' +import { PasswordEncryptionType } from '../enum/PasswordEncryptionType' // eslint-disable-next-line @typescript-eslint/no-var-requires const sodium = require('sodium-native') // eslint-disable-next-line @typescript-eslint/no-var-requires const random = require('random-bigint') -// We will reuse this for changePassword -const isPassword = (password: string): boolean => { - return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/) -} - const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl'] const DEFAULT_LANGUAGE = 'de' const isLanguage = (language: string): boolean => { @@ -106,48 +105,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => { return [pubKey, privKey] } -const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => { - logger.trace('SecretKeyCryptographyCreateKey...') - const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex') - const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex') - if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) { - logger.error( - `ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, - ) - throw new Error( - `ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, - ) - } - - const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES) - sodium.crypto_hash_sha512_init(state) - sodium.crypto_hash_sha512_update(state, Buffer.from(salt)) - sodium.crypto_hash_sha512_update(state, configLoginAppSecret) - const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES) - sodium.crypto_hash_sha512_final(state, hash) - - const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES) - const opsLimit = 10 - const memLimit = 33554432 - const algo = 2 - sodium.crypto_pwhash( - encryptionKey, - Buffer.from(password), - hash.slice(0, sodium.crypto_pwhash_SALTBYTES), - opsLimit, - memLimit, - algo, - ) - - const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES) - sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey) - - logger.debug( - `SecretKeyCryptographyCreateKey...successful: encryptionKeyHash= ${encryptionKeyHash}, encryptionKey= ${encryptionKey}`, - ) - return [encryptionKeyHash, encryptionKey] -} - /* const getEmailHash = (email: string): Buffer => { logger.trace('getEmailHash...') @@ -305,8 +262,9 @@ export class UserResolver { async verifyLogin(@Ctx() context: Context): Promise { logger.info('verifyLogin...') // TODO refactor and do not have duplicate code with login(see below) + const clientTimezoneOffset = getClientTimezoneOffset(context) const userEntity = getUser(context) - const user = new User(userEntity, await getUserCreation(userEntity.id)) + const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset)) // user.pubkey = userEntity.pubKey.toString('hex') // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage(context) @@ -323,6 +281,7 @@ export class UserResolver { @Ctx() context: Context, ): Promise { logger.info(`login with ${email}, ***, ${publisherId} ...`) + const clientTimezoneOffset = getClientTimezoneOffset(context) email = email.trim().toLowerCase() const dbUser = await findUserByEmail(email) if (dbUser.deletedAt) { @@ -343,19 +302,26 @@ export class UserResolver { // TODO we want to catch this on the frontend and ask the user to check his emails or resend code throw new Error('User has no private or publicKey') } - const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash - const loginUserPassword = BigInt(dbUser.password.toString()) - if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) { + + if (!verifyPassword(dbUser, password)) { logger.error('The User has no valid credentials.') throw new Error('No user with this credentials') } + + if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) { + dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID + dbUser.password = encryptPassword(dbUser, password) + await dbUser.save() + } // add pubKey in logger-context for layout-pattern X{user} to print it in each logging message logger.addContext('user', dbUser.id) logger.debug('validation of login credentials successful...') - const user = new User(dbUser, await getUserCreation(dbUser.id)) + const user = new User(dbUser, await getUserCreation(dbUser.id, clientTimezoneOffset)) logger.debug(`user= ${JSON.stringify(user, null, 2)}`) + i18n.setLocale(user.language) + // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) logger.info('user.hasElopage=' + user.hasElopage) @@ -408,6 +374,7 @@ export class UserResolver { if (!language || !isLanguage(language)) { language = DEFAULT_LANGUAGE } + i18n.setLocale(language) // check if user with email still exists? email = email.trim().toLowerCase() @@ -416,8 +383,11 @@ export class UserResolver { logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`) if (foundUser) { - // ATTENTION: this logger-message will be exactly expected during tests + // ATTENTION: this logger-message will be exactly expected during tests, next line logger.info(`User already exists with this email=${email}`) + logger.info( + `Specified username when trying to register multiple times with this email: firstName=${firstName}, lastName=${lastName}`, + ) // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. const user = new User(communityDbUser) @@ -430,18 +400,20 @@ export class UserResolver { user.publisherId = publisherId logger.debug('partly faked user=' + user) - // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendAccountMultiRegistrationEmail({ - firstName, - lastName, + firstName: foundUser.firstName, // this is the real name of the email owner, but just "firstName" would be the name of the new registrant which shall not be passed to the outside + lastName: foundUser.lastName, // this is the real name of the email owner, but just "lastName" would be the name of the new registrant which shall not be passed to the outside email, + language: foundUser.language, // use language of the emails owner for sending }) const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail() eventSendAccountMultiRegistrationEmail.userId = foundUser.id eventProtocol.writeEvent( event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail), ) - logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) + logger.info( + `sendAccountMultiRegistrationEmail by ${firstName} ${lastName} to ${foundUser.firstName} ${foundUser.lastName} <${email}>`, + ) /* uncomment this, when you need the activation link on the console */ // In case EMails are disabled log the activation link for the user if (!emailSent) { @@ -470,6 +442,7 @@ export class UserResolver { dbUser.lastName = lastName dbUser.language = language dbUser.publisherId = publisherId + dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD dbUser.passphrase = passphrase.join(' ') logger.debug('new dbUser=' + dbUser) if (redeemCode) { @@ -623,7 +596,7 @@ export class UserResolver { ): Promise { logger.info(`setPassword(${code}, ***)...`) // Validate Password - if (!isPassword(password)) { + if (!isValidPassword(password)) { logger.error('Password entered is lexically invalid') throw new Error( 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', @@ -681,10 +654,11 @@ export class UserResolver { userContact.emailChecked = true // Update Password + user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) - user.password = passwordHash[0].readBigUInt64LE() // using the shorthash + user.password = encryptPassword(user, password) user.pubKey = keyPair[0] user.privKey = encryptedPrivkey logger.debug('User credentials updated ...') @@ -785,11 +759,12 @@ export class UserResolver { throw new Error(`"${language}" isn't a valid language`) } userEntity.language = language + i18n.setLocale(language) } if (password && passwordNew) { // Validate Password - if (!isPassword(passwordNew)) { + if (!isValidPassword(passwordNew)) { logger.error('newPassword does not fullfil the rules') throw new Error( 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', @@ -801,7 +776,7 @@ export class UserResolver { userEntity.emailContact.email, password, ) - if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { + if (!verifyPassword(userEntity, password)) { logger.error(`Old password is invalid`) throw new Error(`Old password is invalid`) } @@ -817,7 +792,8 @@ export class UserResolver { logger.debug('PrivateKey encrypted...') // Save new password hash and newly encrypted private key - userEntity.password = newPasswordHash[0].readBigUInt64LE() + userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID + userEntity.password = encryptPassword(userEntity, passwordNew) userEntity.privKey = encryptedPrivkey } diff --git a/backend/src/graphql/resolver/util/creations.test.ts b/backend/src/graphql/resolver/util/creations.test.ts new file mode 100644 index 000000000..8d747e989 --- /dev/null +++ b/backend/src/graphql/resolver/util/creations.test.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { testEnvironment, cleanDB, contributionDateFormatter } from '@test/helpers' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { User } from '@entity/User' +import { Contribution } from '@entity/Contribution' +import { userFactory } from '@/seeds/factory/user' +import { login, createContribution, adminCreateContribution } from '@/seeds/graphql/mutations' +import { getUserCreation } from './creations' + +let mutate: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +const setZeroHours = (date: Date): Date => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +describe('util/creation', () => { + let user: User + let admin: User + + const now = new Date() + + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + admin = await userFactory(testEnv, peterLustig) + }) + + describe('getUserCreations', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: adminCreateContribution, + variables: { + email: 'bibi@bloxberg.de', + amount: 250.0, + memo: 'Admin contribution for this month', + creationDate: contributionDateFormatter(now), + }, + }) + await mutate({ + mutation: adminCreateContribution, + variables: { + email: 'bibi@bloxberg.de', + amount: 160.0, + memo: 'Admin contribution for the last month', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + }, + }) + await mutate({ + mutation: adminCreateContribution, + variables: { + email: 'bibi@bloxberg.de', + amount: 450.0, + memo: 'Admin contribution for two months ago', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()), + ), + }, + }) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: createContribution, + variables: { + amount: 400.0, + memo: 'Contribution for this month', + creationDate: contributionDateFormatter(now), + }, + }) + await mutate({ + mutation: createContribution, + variables: { + amount: 500.0, + memo: 'Contribution for the last month', + creationDate: contributionDateFormatter( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + }, + }) + }) + + it('has the correct data setup', async () => { + await expect(Contribution.find()).resolves.toEqual([ + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours(now), + amount: expect.decimalEqual(250), + memo: 'Admin contribution for this month', + moderatorId: admin.id, + contributionType: 'ADMIN', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + amount: expect.decimalEqual(160), + memo: 'Admin contribution for the last month', + moderatorId: admin.id, + contributionType: 'ADMIN', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours( + new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()), + ), + amount: expect.decimalEqual(450), + memo: 'Admin contribution for two months ago', + moderatorId: admin.id, + contributionType: 'ADMIN', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours(now), + amount: expect.decimalEqual(400), + memo: 'Contribution for this month', + moderatorId: null, + contributionType: 'USER', + contributionStatus: 'PENDING', + }), + expect.objectContaining({ + userId: user.id, + contributionDate: setZeroHours( + new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + ), + amount: expect.decimalEqual(500), + memo: 'Contribution for the last month', + moderatorId: null, + contributionType: 'USER', + contributionStatus: 'PENDING', + }), + ]) + }) + + describe('call getUserCreation now', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 0)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + + describe('run forward in time one hour before next month', () => { + const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 0, 0) + + beforeAll(() => { + jest.useFakeTimers() + setTimeout(jest.fn(), targetDate.getTime() - now.getTime()) + jest.runAllTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('has the clock set correctly', () => { + expect(new Date().toISOString()).toContain( + `${targetDate.getFullYear()}-${targetDate.getMonth() + 1}-${targetDate.getDate()}T23:`, + ) + }) + + describe('call getUserCreation with UTC', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 0)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + }) + + describe('call getUserCreation with JST (GMT+0900)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([ + expect.decimalEqual(340), + expect.decimalEqual(350), + expect.decimalEqual(1000), + ]) + }) + }) + + describe('call getUserCreation with PST (GMT-0800)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 480, true)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + }) + + describe('run two hours forward to be in the next month in UTC', () => { + const nextMonthTargetDate = new Date() + nextMonthTargetDate.setTime(targetDate.getTime() + 2 * 60 * 60 * 1000) + + beforeAll(() => { + setTimeout(jest.fn(), 2 * 60 * 60 * 1000) + jest.runAllTimers() + }) + + it('has the clock set correctly', () => { + expect(new Date().toISOString()).toContain( + `${nextMonthTargetDate.getFullYear()}-${nextMonthTargetDate.getMonth() + 1}-01T01:`, + ) + }) + + describe('call getUserCreation with UTC', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 0, true)).resolves.toEqual([ + expect.decimalEqual(340), + expect.decimalEqual(350), + expect.decimalEqual(1000), + ]) + }) + }) + + describe('call getUserCreation with JST (GMT+0900)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([ + expect.decimalEqual(340), + expect.decimalEqual(350), + expect.decimalEqual(1000), + ]) + }) + }) + + describe('call getUserCreation with PST (GMT-0800)', () => { + it('returns the expected open contributions', async () => { + await expect(getUserCreation(user.id, 450, true)).resolves.toEqual([ + expect.decimalEqual(550), + expect.decimalEqual(340), + expect.decimalEqual(350), + ]) + }) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index 9987dfae6..eb4b6394d 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -1,4 +1,3 @@ -import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' import { backendLogger as logger } from '@/server/logger' import { getConnection } from '@dbTools/typeorm' import { Contribution } from '@entity/Contribution' @@ -14,9 +13,10 @@ export const validateContribution = ( creations: Decimal[], amount: Decimal, creationDate: Date, + timezoneOffset: number, ): void => { logger.trace('isContributionValid: ', creations, amount, creationDate) - const index = getCreationIndex(creationDate.getMonth()) + const index = getCreationIndex(creationDate.getMonth(), timezoneOffset) if (index < 0) { logger.error( @@ -38,10 +38,11 @@ export const validateContribution = ( export const getUserCreations = async ( ids: number[], + timezoneOffset: number, includePending = true, ): Promise => { logger.trace('getUserCreations:', ids, includePending) - const months = getCreationMonths() + const months = getCreationMonths(timezoneOffset) logger.trace('getUserCreations months', months) const queryRunner = getConnection().createQueryRunner() @@ -50,27 +51,27 @@ export const getUserCreations = async ( const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' logger.trace('getUserCreations dateFilter=', dateFilter) - const unionString = includePending - ? ` - UNION - SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions - WHERE user_id IN (${ids.toString()}) - AND contribution_date >= ${dateFilter} - AND confirmed_at IS NULL AND deleted_at IS NULL` - : '' - logger.trace('getUserCreations unionString=', unionString) + const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager + .createQueryBuilder(Contribution, 'c') + .select('month(contribution_date)', 'month') + .addSelect('user_id', 'userId') + .addSelect('sum(amount)', 'sum') + .where(`user_id in (${ids.toString()})`) + .andWhere(`contribution_date >= ${dateFilter}`) + .andWhere('deleted_at IS NULL') + .andWhere('denied_at IS NULL') + .groupBy('month') + .addGroupBy('userId') + .orderBy('month', 'DESC') - const unionQuery = await queryRunner.manager.query(` - SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM - (SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions - WHERE user_id IN (${ids.toString()}) - AND type_id = ${TransactionTypeId.CREATION} - AND creation_date >= ${dateFilter} - ${unionString}) AS result - GROUP BY month, userId - ORDER BY date DESC - `) - logger.trace('getUserCreations unionQuery=', unionQuery) + if (!includePending) { + sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL') + } + + const sumAmountContributionPerUserAndLast3Month = + await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany() + + logger.trace(sumAmountContributionPerUserAndLast3Month) await queryRunner.release() @@ -78,9 +79,9 @@ export const getUserCreations = async ( return { id, creations: months.map((month) => { - const creation = unionQuery.find( - (raw: { month: string; id: string; creation: number[] }) => - parseInt(raw.month) === month && parseInt(raw.id) === id, + const creation = sumAmountContributionPerUserAndLast3Month.find( + (raw: { month: string; userId: string; creation: number[] }) => + parseInt(raw.month) === month && parseInt(raw.userId) === id, ) return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) }), @@ -88,24 +89,29 @@ export const getUserCreations = async ( }) } -export const getUserCreation = async (id: number, includePending = true): Promise => { - logger.trace('getUserCreation', id, includePending) - const creations = await getUserCreations([id], includePending) +export const getUserCreation = async ( + id: number, + timezoneOffset: number, + includePending = true, +): Promise => { + logger.trace('getUserCreation', id, includePending, timezoneOffset) + const creations = await getUserCreations([id], timezoneOffset, includePending) logger.trace('getUserCreation creations=', creations) return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE } -export const getCreationMonths = (): number[] => { - const now = new Date(Date.now()) +const getCreationMonths = (timezoneOffset: number): number[] => { + const clientNow = new Date() + clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000) return [ - now.getMonth() + 1, - new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, - new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, - ].reverse() + new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1, + new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1, + clientNow.getMonth() + 1, + ] } -export const getCreationIndex = (month: number): number => { - return getCreationMonths().findIndex((el) => el === month + 1) +const getCreationIndex = (month: number, timezoneOffset: number): number => { + return getCreationMonths(timezoneOffset).findIndex((el) => el === month + 1) } export const isStartEndDateValid = ( @@ -129,8 +135,12 @@ export const isStartEndDateValid = ( } } -export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => { - const index = getCreationIndex(contribution.contributionDate.getMonth()) +export const updateCreations = ( + creations: Decimal[], + contribution: Contribution, + timezoneOffset: number, +): Decimal[] => { + const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset) if (index < 0) { throw new Error('You cannot create GDD for a month older than the last three months.') @@ -138,3 +148,7 @@ export const updateCreations = (creations: Decimal[], contribution: Contribution creations[index] = creations[index].plus(contribution.amount.toString()) return creations } + +export const isValidDateString = (dateString: string): boolean => { + return new Date(dateString).toString() !== 'Invalid Date' +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 4c08b422d..dc1bbb115 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import createServer from './server/createServer' +import { startDHT } from '@/federation/index' // config import CONFIG from './config' @@ -16,6 +17,11 @@ async function main() { console.log(`GraphIQL available at http://localhost:${CONFIG.PORT}`) } }) + + // start DHT hyperswarm when DHT_TOPIC is set in .env + if (CONFIG.DHT_TOPIC) { + await startDHT(CONFIG.DHT_TOPIC) // con, + } } main().catch((e) => { diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json new file mode 100644 index 000000000..6c270f148 --- /dev/null +++ b/backend/src/locales/de.json @@ -0,0 +1,15 @@ +{ + "emails": { + "accountMultiRegistration": { + "emailExists": "Es existiert jedoch zu deiner E-Mail-Adresse schon ein Konto.", + "emailReused": "Deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.", + "helloName": "Hallo {firstName} {lastName}", + "ifYouAreNotTheOne": "Wenn du nicht derjenige bist, der sich versucht hat erneut zu registrieren, wende dich bitte an unseren support:", + "onForgottenPasswordClickLink": "Klicke bitte auf den folgenden Link, falls du dein Passwort vergessen haben solltest:", + "onForgottenPasswordCopyLink": "oder kopiere den obigen Link in dein Browserfenster.", + "sincerelyYours": "Mit freundlichen Grüßen,", + "subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail", + "yourGradidoTeam": "dein Gradido-Team" + } + } +} \ No newline at end of file diff --git a/backend/src/locales/en.json b/backend/src/locales/en.json new file mode 100644 index 000000000..7655aae6a --- /dev/null +++ b/backend/src/locales/en.json @@ -0,0 +1,15 @@ +{ + "emails": { + "accountMultiRegistration": { + "emailExists": "However, an account already exists for your email address.", + "emailReused": "Your email address has just been used again to register an account with Gradido.", + "helloName": "Hello {firstName} {lastName}", + "ifYouAreNotTheOne": "If you are not the one who tried to register again, please contact our support:", + "onForgottenPasswordClickLink": "Please click on the following link if you have forgotten your password:", + "onForgottenPasswordCopyLink": "or copy the link above into your browser window.", + "sincerelyYours": "Sincerely yours,", + "subject": "Gradido: Try To Register Again With Your Email", + "yourGradidoTeam": "your Gradido team" + } + } +} \ No newline at end of file diff --git a/backend/src/mailer/sendAccountMultiRegistrationEmail.test.ts b/backend/src/mailer/sendAccountMultiRegistrationEmail.test.ts deleted file mode 100644 index bb37a196e..000000000 --- a/backend/src/mailer/sendAccountMultiRegistrationEmail.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import CONFIG from '@/config' -import { sendAccountMultiRegistrationEmail } from './sendAccountMultiRegistrationEmail' -import { sendEMail } from './sendEMail' - -jest.mock('./sendEMail', () => { - return { - __esModule: true, - sendEMail: jest.fn(), - } -}) - -describe('sendAccountMultiRegistrationEmail', () => { - beforeEach(async () => { - await sendAccountMultiRegistrationEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - }) - }) - - it('calls sendEMail', () => { - expect(sendEMail).toBeCalledWith({ - to: `Peter Lustig `, - subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail', - text: - expect.stringContaining('Hallo Peter Lustig') && - expect.stringContaining(CONFIG.EMAIL_LINK_FORGOTPASSWORD) && - expect.stringContaining('https://gradido.net/de/contact/'), - }) - }) -}) diff --git a/backend/src/mailer/sendAccountMultiRegistrationEmail.ts b/backend/src/mailer/sendAccountMultiRegistrationEmail.ts deleted file mode 100644 index 18928770b..000000000 --- a/backend/src/mailer/sendAccountMultiRegistrationEmail.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sendEMail } from './sendEMail' -import { accountMultiRegistration } from './text/accountMultiRegistration' -import CONFIG from '@/config' - -export const sendAccountMultiRegistrationEmail = (data: { - firstName: string - lastName: string - email: string -}): Promise => { - return sendEMail({ - to: `${data.firstName} ${data.lastName} <${data.email}>`, - subject: accountMultiRegistration.de.subject, - text: accountMultiRegistration.de.text({ - ...data, - resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD, - }), - }) -} diff --git a/backend/src/mailer/sendAddedContributionMessageEmail.test.ts b/backend/src/mailer/sendAddedContributionMessageEmail.test.ts index bed8f6214..9a2ec1aa1 100644 --- a/backend/src/mailer/sendAddedContributionMessageEmail.test.ts +++ b/backend/src/mailer/sendAddedContributionMessageEmail.test.ts @@ -26,12 +26,12 @@ describe('sendAddedContributionMessageEmail', () => { it('calls sendEMail', () => { expect(sendEMail).toBeCalledWith({ to: `Bibi Bloxberg `, - subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', + subject: 'Nachricht zu deinem Gemeinwohl-Beitrag', text: expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Peter Lustig') && expect.stringContaining( - 'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.', + 'du hast zu deinem Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Nachricht von Peter Lustig erhalten.', ) && expect.stringContaining('Was für ein Besen ist es geworden?') && expect.stringContaining('http://localhost/overview'), diff --git a/backend/src/mailer/sendContributionConfirmedEmail.test.ts b/backend/src/mailer/sendContributionConfirmedEmail.test.ts index 1935144fd..bd89afa69 100644 --- a/backend/src/mailer/sendContributionConfirmedEmail.test.ts +++ b/backend/src/mailer/sendContributionConfirmedEmail.test.ts @@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => { it('calls sendEMail', () => { expect(sendEMail).toBeCalledWith({ to: 'Bibi Bloxberg ', - subject: 'Schöpfung wurde bestätigt', + subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt', text: expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining( - 'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.', + 'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig bestätigt und in deinem Gradido-Konto gutgeschrieben.', ) && expect.stringContaining('Betrag: 200,00 GDD') && expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), diff --git a/backend/src/mailer/sendContributionRejectedEmail.test.ts b/backend/src/mailer/sendContributionRejectedEmail.test.ts new file mode 100644 index 000000000..be41ff15f --- /dev/null +++ b/backend/src/mailer/sendContributionRejectedEmail.test.ts @@ -0,0 +1,38 @@ +import Decimal from 'decimal.js-light' +import { sendContributionRejectedEmail } from './sendContributionRejectedEmail' +import { sendEMail } from './sendEMail' + +jest.mock('./sendEMail', () => { + return { + __esModule: true, + sendEMail: jest.fn(), + } +}) + +describe('sendContributionConfirmedEmail', () => { + beforeEach(async () => { + await sendContributionRejectedEmail({ + senderFirstName: 'Peter', + senderLastName: 'Lustig', + recipientFirstName: 'Bibi', + recipientLastName: 'Bloxberg', + recipientEmail: 'bibi@bloxberg.de', + contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!', + contributionAmount: new Decimal(200.0), + overviewURL: 'http://localhost/overview', + }) + }) + + it('calls sendEMail', () => { + expect(sendEMail).toBeCalledWith({ + to: 'Bibi Bloxberg ', + subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt', + text: + expect.stringContaining('Hallo Bibi Bloxberg') && + expect.stringContaining( + 'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde von Peter Lustig abgelehnt.', + ) && + expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), + }) + }) +}) diff --git a/backend/src/mailer/sendContributionRejectedEmail.ts b/backend/src/mailer/sendContributionRejectedEmail.ts new file mode 100644 index 000000000..9edb5ba2a --- /dev/null +++ b/backend/src/mailer/sendContributionRejectedEmail.ts @@ -0,0 +1,26 @@ +import { backendLogger as logger } from '@/server/logger' +import Decimal from 'decimal.js-light' +import { sendEMail } from './sendEMail' +import { contributionRejected } from './text/contributionRejected' + +export const sendContributionRejectedEmail = (data: { + senderFirstName: string + senderLastName: string + recipientFirstName: string + recipientLastName: string + recipientEmail: string + contributionMemo: string + contributionAmount: Decimal + overviewURL: string +}): Promise => { + logger.info( + `sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>, + subject=${contributionRejected.de.subject}, + text=${contributionRejected.de.text(data)}`, + ) + return sendEMail({ + to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`, + subject: contributionRejected.de.subject, + text: contributionRejected.de.text(data), + }) +} diff --git a/backend/src/mailer/sendEMail.test.ts b/backend/src/mailer/sendEMail.test.ts index 8e3b0c4a2..e062b71d8 100644 --- a/backend/src/mailer/sendEMail.test.ts +++ b/backend/src/mailer/sendEMail.test.ts @@ -38,7 +38,7 @@ describe('sendEMail', () => { }) }) - it('logs warining', () => { + it('logs warning', () => { expect(logger.info).toBeCalledWith('Emails are disabled via config...') }) diff --git a/backend/src/mailer/sendTransactionReceivedEmail.test.ts b/backend/src/mailer/sendTransactionReceivedEmail.test.ts index 9f2ba9938..ca813c033 100644 --- a/backend/src/mailer/sendTransactionReceivedEmail.test.ts +++ b/backend/src/mailer/sendTransactionReceivedEmail.test.ts @@ -26,7 +26,7 @@ describe('sendTransactionReceivedEmail', () => { it('calls sendEMail', () => { expect(sendEMail).toBeCalledWith({ to: `Peter Lustig `, - subject: 'Gradido Überweisung', + subject: 'Du hast Gradidos erhalten', text: expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('42,00 GDD') && diff --git a/backend/src/mailer/text/contributionConfirmed.ts b/backend/src/mailer/text/contributionConfirmed.ts index dc82d7615..106c3a4c5 100644 --- a/backend/src/mailer/text/contributionConfirmed.ts +++ b/backend/src/mailer/text/contributionConfirmed.ts @@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light' export const contributionConfirmed = { de: { - subject: 'Schöpfung wurde bestätigt', + subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt', text: (data: { senderFirstName: string senderLastName: string @@ -14,18 +14,17 @@ export const contributionConfirmed = { }): string => `Hallo ${data.recipientFirstName} ${data.recipientLastName}, -Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${ - data.senderFirstName - } ${data.senderLastName} bestätigt. +dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${ + data.senderLastName + } bestätigt und in deinem Gradido-Konto gutgeschrieben. Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD +Link zu deinem Konto: ${data.overviewURL} + Bitte antworte nicht auf diese E-Mail! -Mit freundlichen Grüßen, -dein Gradido-Team - - -Link zu deinem Konto: ${data.overviewURL}`, +Liebe Grüße +dein Gradido-Team`, }, } diff --git a/backend/src/mailer/text/contributionMessageReceived.ts b/backend/src/mailer/text/contributionMessageReceived.ts index af1cabb9f..301ebef22 100644 --- a/backend/src/mailer/text/contributionMessageReceived.ts +++ b/backend/src/mailer/text/contributionMessageReceived.ts @@ -1,6 +1,6 @@ export const contributionMessageReceived = { de: { - subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', + subject: 'Nachricht zu deinem Gemeinwohl-Beitrag', text: (data: { senderFirstName: string senderLastName: string @@ -14,15 +14,15 @@ export const contributionMessageReceived = { }): string => `Hallo ${data.recipientFirstName} ${data.recipientLastName}, -du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten. +du hast zu deinem Gemeinwohl-Beitrag "${data.contributionMemo}" eine Nachricht von ${data.senderFirstName} ${data.senderLastName} erhalten. -Bitte beantworte die Rückfrage in deinem Gradido-Konto im Menü "Gemeinschaft" im Tab "Meine Beiträge zum Gemeinwohl"! +Um die Nachricht zu sehen und darauf zu antworten, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"! Link zu deinem Konto: ${data.overviewURL} Bitte antworte nicht auf diese E-Mail! -Mit freundlichen Grüßen, +Liebe Grüße dein Gradido-Team`, }, } diff --git a/backend/src/mailer/text/contributionRejected.ts b/backend/src/mailer/text/contributionRejected.ts new file mode 100644 index 000000000..ff52c7b5a --- /dev/null +++ b/backend/src/mailer/text/contributionRejected.ts @@ -0,0 +1,28 @@ +import Decimal from 'decimal.js-light' + +export const contributionRejected = { + de: { + subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt', + text: (data: { + senderFirstName: string + senderLastName: string + recipientFirstName: string + recipientLastName: string + contributionMemo: string + contributionAmount: Decimal + overviewURL: string + }): string => + `Hallo ${data.recipientFirstName} ${data.recipientLastName}, + +dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde von ${data.senderFirstName} ${data.senderLastName} abgelehnt. + +Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"! + +Link zu deinem Konto: ${data.overviewURL} + +Bitte antworte nicht auf diese E-Mail! + +Liebe Grüße +dein Gradido-Team`, + }, +} diff --git a/backend/src/mailer/text/transactionLinkRedeemed.ts b/backend/src/mailer/text/transactionLinkRedeemed.ts index 4d8e89cae..a63e5d275 100644 --- a/backend/src/mailer/text/transactionLinkRedeemed.ts +++ b/backend/src/mailer/text/transactionLinkRedeemed.ts @@ -14,20 +14,20 @@ export const transactionLinkRedeemed = { memo: string overviewURL: string }): string => - `Hallo ${data.recipientFirstName} ${data.recipientLastName} + `Hallo ${data.recipientFirstName} ${data.recipientLastName}, - ${data.senderFirstName} ${data.senderLastName} (${ +${data.senderFirstName} ${data.senderLastName} (${ data.senderEmail }) hat soeben deinen Link eingelöst. - - Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD, - Memo: ${data.memo} - - Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} - - Bitte antworte nicht auf diese E-Mail! - - Mit freundlichen Grüßen, - dein Gradido-Team`, + +Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD, +Memo: ${data.memo} + +Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} + +Bitte antworte nicht auf diese E-Mail! + +Liebe Grüße +dein Gradido-Team`, }, } diff --git a/backend/src/mailer/text/transactionReceived.ts b/backend/src/mailer/text/transactionReceived.ts index ba61ae680..67758c0e1 100644 --- a/backend/src/mailer/text/transactionReceived.ts +++ b/backend/src/mailer/text/transactionReceived.ts @@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light' export const transactionReceived = { de: { - subject: 'Gradido Überweisung', + subject: 'Du hast Gradidos erhalten', text: (data: { senderFirstName: string senderLastName: string @@ -13,9 +13,9 @@ export const transactionReceived = { amount: Decimal overviewURL: string }): string => - `Hallo ${data.recipientFirstName} ${data.recipientLastName} + `Hallo ${data.recipientFirstName} ${data.recipientLastName}, -Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${ +du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${ data.senderLastName } (${data.senderEmail}) erhalten. @@ -23,7 +23,7 @@ Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} Bitte antworte nicht auf diese E-Mail! -Mit freundlichen Grüßen, +Liebe Grüße dein Gradido-Team`, }, } diff --git a/backend/src/password/EncryptorUtils.ts b/backend/src/password/EncryptorUtils.ts new file mode 100644 index 000000000..971b6a32e --- /dev/null +++ b/backend/src/password/EncryptorUtils.ts @@ -0,0 +1,71 @@ +import CONFIG from '@/config' +import { backendLogger as logger } from '@/server/logger' +import { User } from '@entity/User' +import { PasswordEncryptionType } from '@enum/PasswordEncryptionType' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sodium = require('sodium-native') + +// We will reuse this for changePassword +export const isValidPassword = (password: string): boolean => { + return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/) +} + +export const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => { + logger.trace('SecretKeyCryptographyCreateKey...') + const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex') + const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex') + if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) { + logger.error( + `ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, + ) + throw new Error( + `ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, + ) + } + + const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES) + sodium.crypto_hash_sha512_init(state) + sodium.crypto_hash_sha512_update(state, Buffer.from(salt)) + sodium.crypto_hash_sha512_update(state, configLoginAppSecret) + const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES) + sodium.crypto_hash_sha512_final(state, hash) + + const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES) + const opsLimit = 10 + const memLimit = 33554432 + const algo = 2 + sodium.crypto_pwhash( + encryptionKey, + Buffer.from(password), + hash.slice(0, sodium.crypto_pwhash_SALTBYTES), + opsLimit, + memLimit, + algo, + ) + + const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES) + sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey) + + return [encryptionKeyHash, encryptionKey] +} + +export const getUserCryptographicSalt = (dbUser: User): string => { + switch (dbUser.passwordEncryptionType) { + case PasswordEncryptionType.NO_PASSWORD: { + logger.error('Password not set for user ' + dbUser.id) + throw new Error('Password not set for user ' + dbUser.id) // user has no password + } + case PasswordEncryptionType.EMAIL: { + return dbUser.emailContact.email + break + } + case PasswordEncryptionType.GRADIDO_ID: { + return dbUser.gradidoID + break + } + default: + logger.error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`) + throw new Error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`) + } +} diff --git a/backend/src/password/PasswordEncryptor.ts b/backend/src/password/PasswordEncryptor.ts new file mode 100644 index 000000000..1735106c1 --- /dev/null +++ b/backend/src/password/PasswordEncryptor.ts @@ -0,0 +1,14 @@ +import { User } from '@entity/User' +// import { logger } from '@test/testSetup' getting error "jest is not defined" +import { getUserCryptographicSalt, SecretKeyCryptographyCreateKey } from './EncryptorUtils' + +export const encryptPassword = (dbUser: User, password: string): bigint => { + const salt = getUserCryptographicSalt(dbUser) + const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash + const passwordHash = keyBuffer[0].readBigUInt64LE() + return passwordHash +} + +export const verifyPassword = (dbUser: User, password: string): boolean => { + return dbUser.password.toString() === encryptPassword(dbUser, password).toString() +} diff --git a/backend/src/seeds/index.ts b/backend/src/seeds/index.ts index c5a55cb84..3675d381d 100644 --- a/backend/src/seeds/index.ts +++ b/backend/src/seeds/index.ts @@ -29,6 +29,7 @@ const context = { // eslint-disable-next-line @typescript-eslint/no-empty-function forEach: (): void => {}, }, + clientTimezoneOffset: 0, } export const cleanDB = async () => { diff --git a/backend/src/server/context.ts b/backend/src/server/context.ts index 5bfc22e72..8ba590dd3 100644 --- a/backend/src/server/context.ts +++ b/backend/src/server/context.ts @@ -9,7 +9,7 @@ export interface Context { setHeaders: { key: string; value: string }[] role?: Role user?: dbUser - clientRequestTime?: string + clientTimezoneOffset?: number // hack to use less DB calls for Balance Resolver lastTransaction?: dbTransaction transactionCount?: number @@ -19,7 +19,7 @@ export interface Context { const context = (args: ExpressContext): Context => { const authorization = args.req.headers.authorization - const clientRequestTime = args.req.headers.clientrequesttime + const clientTimezoneOffset = args.req.headers.clienttimezoneoffset const context: Context = { token: null, setHeaders: [], @@ -27,8 +27,8 @@ const context = (args: ExpressContext): Context => { if (authorization) { context.token = authorization.replace(/^Bearer /, '') } - if (clientRequestTime && typeof clientRequestTime === 'string') { - context.clientRequestTime = clientRequestTime + if (clientTimezoneOffset && typeof clientTimezoneOffset === 'string') { + context.clientTimezoneOffset = Number(clientTimezoneOffset) } return context } @@ -38,4 +38,14 @@ export const getUser = (context: Context): dbUser => { throw new Error('No user given in context!') } +export const getClientTimezoneOffset = (context: Context): number => { + if ( + (context.clientTimezoneOffset || context.clientTimezoneOffset === 0) && + Math.abs(context.clientTimezoneOffset) <= 27 * 60 + ) { + return context.clientTimezoneOffset + } + throw new Error('No valid client time zone offset in context!') +} + export default context diff --git a/backend/src/server/createServer.ts b/backend/src/server/createServer.ts index 8ae4675db..390ff1c6b 100644 --- a/backend/src/server/createServer.ts +++ b/backend/src/server/createServer.ts @@ -25,6 +25,9 @@ import { Connection } from '@dbTools/typeorm' import { apolloLogger } from './logger' import { Logger } from 'log4js' +// i18n +import { i18n } from './localization' + // TODO implement // import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity"; @@ -34,6 +37,7 @@ const createServer = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any context: any = serverContext, logger: Logger = apolloLogger, + localization: i18n.I18n = i18n, ): Promise => { logger.addContext('user', 'unknown') logger.debug('createServer...') @@ -63,6 +67,9 @@ const createServer = async ( // bodyparser urlencoded for elopage app.use(express.urlencoded({ extended: true })) + // i18n + app.use(localization.init) + // Elopage Webhook app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook) @@ -80,6 +87,7 @@ const createServer = async ( `running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`, ) logger.debug('createServer...successful') + return { apollo, app, con } } diff --git a/backend/src/server/localization.ts b/backend/src/server/localization.ts new file mode 100644 index 000000000..44fb1b526 --- /dev/null +++ b/backend/src/server/localization.ts @@ -0,0 +1,28 @@ +import path from 'path' +import { backendLogger } from './logger' +import i18n from 'i18n' + +i18n.configure({ + locales: ['en', 'de'], + defaultLocale: 'en', + retryInDefaultLocale: false, + directory: path.join(__dirname, '..', 'locales'), + // autoReload: true, // if this is activated the seeding hangs at the very end + updateFiles: false, + objectNotation: true, + logDebugFn: (msg) => backendLogger.debug(msg), + logWarnFn: (msg) => backendLogger.info(msg), + logErrorFn: (msg) => backendLogger.error(msg), + // this api is needed for email-template pug files + api: { + __: 't', // now req.__ becomes req.t + __n: 'tn', // and req.__n can be called as req.tn + }, + register: global, + mustacheConfig: { + tags: ['{', '}'], + disable: false, + }, +}) + +export { i18n } diff --git a/backend/src/util/communityUser.ts b/backend/src/util/communityUser.ts index e885b7043..298348f0f 100644 --- a/backend/src/util/communityUser.ts +++ b/backend/src/util/communityUser.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { PasswordEncryptionType } from '@/graphql/enum/PasswordEncryptionType' import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' import { User as dbUser } from '@entity/User' import { UserContact } from '@entity/UserContact' @@ -26,6 +27,8 @@ const communityDbUser: dbUser = { isAdmin: null, publisherId: 0, passphrase: '', + // default password encryption type + passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD, hasId: function (): boolean { throw new Error('Function not implemented.') }, diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 6e1856b63..7ee8e6052 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -16,6 +16,7 @@ const context = { push: headerPushMock, forEach: jest.fn(), }, + clientTimezoneOffset: 0, } export const cleanDB = async () => { @@ -25,8 +26,8 @@ export const cleanDB = async () => { } } -export const testEnvironment = async (logger?: any) => { - const server = await createServer(context, logger) +export const testEnvironment = async (logger?: any, localization?: any) => { + const server = await createServer(context, logger, localization) const con = server.con const testClient = createTestClient(server.apollo) const mutate = testClient.mutate @@ -46,3 +47,12 @@ export const resetEntity = async (entity: any) => { export const resetToken = () => { context.token = '' } + +// format date string as it comes from the frontend for the contribution date +export const contributionDateFormatter = (date: Date): string => { + return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}` +} + +export const setClientTimezoneOffset = (offset: number): void => { + context.clientTimezoneOffset = offset +} diff --git a/backend/test/testSetup.ts b/backend/test/testSetup.ts index a43335e55..06779674d 100644 --- a/backend/test/testSetup.ts +++ b/backend/test/testSetup.ts @@ -1,4 +1,5 @@ import { backendLogger as logger } from '@/server/logger' +import { i18n } from '@/server/localization' jest.setTimeout(1000000) @@ -19,4 +20,18 @@ jest.mock('@/server/logger', () => { } }) -export { logger } +jest.mock('@/server/localization', () => { + const originalModule = jest.requireActual('@/server/localization') + return { + __esModule: true, + ...originalModule, + i18n: { + init: jest.fn(), + // configure: jest.fn(), + // __: jest.fn(), + // setLocale: jest.fn(), + }, + } +}) + +export { logger, i18n } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index f81bf22d5..2e5a8b5b2 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -58,7 +58,7 @@ "@entity/*": ["../database/entity/*", "../../database/build/entity/*"] }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ + "typeRoots": ["src/federation/@types", "node_modules/@types"], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ diff --git a/backend/yarn.lock b/backend/yarn.lock index 1e5647b6f..940906cfa 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -192,11 +192,21 @@ dependencies: "@babel/types" "^7.15.4" +"@babel/helper-string-parser@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" + integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== + "@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.15.7": version "7.15.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389" integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w== +"@babel/helper-validator-identifier@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" + integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== + "@babel/helper-validator-option@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3" @@ -225,6 +235,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.8.tgz#7bacdcbe71bdc3ff936d510c15dcea7cf0b99016" integrity sha512-BRYa3wcQnjS/nqI8Ac94pYYpJfojHVvVXJ97+IDCImX4Jc8W8Xv1+47enbruk+q1etOpsQNwnfFcNGw+gtPGxA== +"@babel/parser@^7.6.0", "@babel/parser@^7.9.6": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.13.tgz#5b2dd21cae4a2c5145f1fbd8ca103f9313d3b7e4" + integrity sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg== + "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -348,6 +363,15 @@ "@babel/helper-validator-identifier" "^7.14.9" to-fast-properties "^2.0.0" +"@babel/types@^7.6.1", "@babel/types@^7.9.6": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.13.tgz#30aeb9e514f4100f7c1cb6e5ba472b30e48f519a" + integrity sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ== + dependencies: + "@babel/helper-string-parser" "^7.18.10" + "@babel/helper-validator-identifier" "^7.18.6" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -380,6 +404,18 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@hapi/boom@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.0.tgz#3624831d0a26b3378423b246f50eacea16e04a08" + integrity sha512-1YVs9tLHhypBqqinKQRqh7FUERIolarQApO37OWkzD+z6y6USi871Sv746zBPKcIOBuI6g6y4FrwX87mmJ90Gg== + dependencies: + "@hapi/hoek" "10.x.x" + +"@hapi/hoek@10.x.x": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-10.0.1.tgz#ee9da297fabc557e1c040a0f44ee89c266ccc306" + integrity sha512-CvlW7jmOhWzuqOqiJQ3rQVLMcREh0eel4IBnxDx2FAcK8g7qoJRQK4L1CPBASoCY6y8e6zuCy3f2g+HWdkzcMw== + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" @@ -394,6 +430,42 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== +"@hyperswarm/dht@^6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@hyperswarm/dht/-/dht-6.2.0.tgz#b2cb1218752b52fabb66f304e73448a108d1effd" + integrity sha512-AeyfRdAkfCz/J3vTC4rdpzEpT7xQ+tls87Zpzw9Py3VGUZD8hMT7pr43OOdkCBNvcln6K/5/Lxhnq5lBkzH3yw== + dependencies: + "@hyperswarm/secret-stream" "^6.0.0" + b4a "^1.3.1" + bogon "^1.0.0" + compact-encoding "^2.4.1" + compact-encoding-net "^1.0.1" + debugging-stream "^2.0.0" + dht-rpc "^6.0.0" + events "^3.3.0" + hypercore-crypto "^3.3.0" + noise-curve-ed "^1.0.2" + noise-handshake "^2.1.0" + record-cache "^1.1.1" + safety-catch "^1.0.1" + sodium-universal "^3.0.4" + udx-native "^1.1.0" + xache "^1.1.0" + +"@hyperswarm/secret-stream@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@hyperswarm/secret-stream/-/secret-stream-6.0.0.tgz#67db820308cc9fed899cb8f5e9f47ae819d5a4e3" + integrity sha512-0xuyJIJDe8JYk4uWUx25qJvWqybdjKU2ZIfP1GTqd7dQxwdR0bpYrQKdLkrn5txWSK4a28ySC2AjH0G3I0gXTA== + dependencies: + b4a "^1.1.0" + hypercore-crypto "^3.3.0" + noise-curve-ed "^1.0.2" + noise-handshake "^2.1.0" + sodium-secretstream "^1.0.0" + sodium-universal "^3.0.4" + streamx "^2.10.2" + timeout-refresh "^2.0.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -584,6 +656,72 @@ resolved "https://registry.yarnpkg.com/@josephg/resolvable/-/resolvable-1.0.1.tgz#69bc4db754d79e1a2f17a650d3466e038d94a5eb" integrity sha512-CtzORUwWTTOTqfVtHaKRJ0I1kNQd1bpn3sUh8I3nJDVY+5/M/Oe1DnEWzPQvqq/xPIIkzzzIP7mfCoAjFRvDhg== +"@ladjs/country-language@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@ladjs/country-language/-/country-language-0.2.1.tgz#553f776fa1eb295d0344ed06525a945f94cdafaa" + integrity sha512-e3AmT7jUnfNE6e2mx2+cPYiWdFW3McySDGRhQEYE6SksjZTMj0PTp+R9x1xG89tHRTsyMNJFl9J4HtZPWZzi1Q== + dependencies: + underscore "~1.13.1" + underscore.deep "~0.5.1" + +"@ladjs/country-language@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@ladjs/country-language/-/country-language-1.0.2.tgz#438facd9ca5312381dccfd0bbd565103d8471e4c" + integrity sha512-hqexlNFTu0NN4TGu17rO/k2l8XRMLgqLwcY9i3Rabls946vnqee8TT2qbhUJ+CiiaE0ShC9yKPdcKJ1veNMmJA== + +"@ladjs/i18n@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@ladjs/i18n/-/i18n-8.0.1.tgz#fb6ae221b627e7a4d499f336a09f03ded2ab523b" + integrity sha512-7+C6IIf/THrrAhSPPlmd3DIl6Ias7YFr37MeIUxXaipLxNcMnQ7oHIRnznwJ78ZwnhcViTa27rfshbtaH9uD5g== + dependencies: + "@hapi/boom" "^10.0.0" + "@ladjs/country-language" "^1.0.1" + boolean "3.2.0" + i18n "^0.15.0" + i18n-locales "^0.0.5" + lodash "^4.17.21" + multimatch "5" + punycode "^2.1.1" + qs "^6.11.0" + titleize "2" + tlds "^1.231.0" + +"@messageformat/core@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.0.1.tgz#191e12cf9643704d1fd32e592a3fbdc194dd588e" + integrity sha512-yxj2+0e46hcZqJfNf0ZYbC2q6WlcGoh4g11mCyRtTueR0AD8F9z4JMYAS1aOiFG8Vl1LZg/h5hZHKmWTAyZq8g== + dependencies: + "@messageformat/date-skeleton" "^1.0.0" + "@messageformat/number-skeleton" "^1.0.0" + "@messageformat/parser" "^5.0.0" + "@messageformat/runtime" "^3.0.1" + make-plural "^7.0.0" + safe-identifier "^0.4.1" + +"@messageformat/date-skeleton@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@messageformat/date-skeleton/-/date-skeleton-1.0.1.tgz#980b8babe21a11433b6e1e8f6dc8c4cae4f5f56b" + integrity sha512-jPXy8fg+WMPIgmGjxSlnGJn68h/2InfT0TNSkVx0IGXgp4ynnvYkbZ51dGWmGySEK+pBiYUttbQdu5XEqX5CRg== + +"@messageformat/number-skeleton@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@messageformat/number-skeleton/-/number-skeleton-1.1.0.tgz#eb636738da8abbd35ccbeb84f7d84d63302aeb61" + integrity sha512-F0Io+GOSvFFxvp9Ze3L5kAoZ2NnOAT0Mr/jpGNd3fqo8A0t4NxNIAcCdggtl2B/gN2ErkIKSBVPrF7xcW1IGvA== + +"@messageformat/parser@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@messageformat/parser/-/parser-5.0.0.tgz#5737e69d7d4a469998b527710f1891174fc1b262" + integrity sha512-WiDKhi8F0zQaFU8cXgqq69eYFarCnTVxKcvhAONufKf0oUxbqLMW6JX6rV4Hqh+BEQWGyKKKHY4g1XA6bCLylA== + dependencies: + moo "^0.5.1" + +"@messageformat/runtime@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@messageformat/runtime/-/runtime-3.0.1.tgz#94d1f6c43265c28ef7aed98ecfcc0968c6c849ac" + integrity sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg== + dependencies: + make-plural "^7.0.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -658,6 +796,14 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= +"@selderee/plugin-htmlparser2@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz#27e994afd1c2cb647ceb5406a185a5574188069d" + integrity sha512-J3jpy002TyBjd4N/p6s+s90eX42H2eRhK3SbsZuvTDv977/E8p2U3zikdiehyJja66do7FlxLomZLPlvl2/xaA== + dependencies: + domhandler "^4.2.0" + selderee "^0.6.0" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -792,6 +938,15 @@ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.10.tgz#61cc8469849e5bcdd0c7044122265c39cec10cf4" integrity sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ== +"@types/email-templates@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@types/email-templates/-/email-templates-10.0.1.tgz#88f218564a6341092f447fbe110047f6bf3e955a" + integrity sha512-IHdgtoOUfMB4t5y5wgm8G0i2/U90GeJPxIEAViMaLlJPCJzaYSlVHXI8bx3qbgbD6gxyOsSRyrFvBSTgNEQc+g== + dependencies: + "@types/html-to-text" "*" + "@types/nodemailer" "*" + juice "^8.0.0" + "@types/express-serve-static-core@^4.17.18", "@types/express-serve-static-core@^4.17.21": version "4.17.24" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07" @@ -838,6 +993,11 @@ dependencies: "@types/node" "*" +"@types/html-to-text@*": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-8.1.1.tgz#0c5573207c14f618f24da5a2910c510285573094" + integrity sha512-QFcqfc7TiVbvIX8Fc2kWUxakruI1Ay6uitaGCYHzI5M0WHQROV5D2XeSaVrK0FmvssivXum4yERVnJsiuH61Ww== + "@types/http-assert@*": version "1.5.3" resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661" @@ -848,6 +1008,11 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.1.tgz#e81ad28a60bee0328c6d2384e029aec626f1ae67" integrity sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q== +"@types/i18n@^0.13.4": + version "0.13.4" + resolved "https://registry.yarnpkg.com/@types/i18n/-/i18n-0.13.4.tgz#fe3d27d08337f9d4a972d1f460d1471d6f79e163" + integrity sha512-PN4ZsplbpHZ2eaYixFNWkZKN51pcB02K2UKvqHVbrzq2jTO0sChPMuKKYAW1ZbElyHUvPgFeYsz9rqktChGyMw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -940,7 +1105,7 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== -"@types/minimatch@*": +"@types/minimatch@*", "@types/minimatch@^3.0.3": version "3.0.5" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== @@ -960,6 +1125,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.21.tgz#6359d8cf73481e312a43886fa50afc70ce5592c6" integrity sha512-zv8ukKci1mrILYiQOwGSV4FpkZhyxQtuFWGya2GujWg+zVAeRQ4qbaMmWp9vb9889CFA8JECH7lkwCL6Ygg8kA== +"@types/nodemailer@*": + version "6.4.6" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.6.tgz#ce21b4b474a08f672f182e15982b7945dde1f288" + integrity sha512-pD6fL5GQtUKvD2WnPmg5bC2e8kWCAPDwMPmHe/ohQbW+Dy0EcHgZ2oCSuPlWNqk74LS5BVMig1SymQbFMPPK3w== + dependencies: + "@types/node" "*" + "@types/nodemailer@^6.4.4": version "6.4.4" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" @@ -1404,6 +1576,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +array-differ@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b" + integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg== + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -1434,6 +1611,21 @@ array.prototype.flat@^1.2.4: define-properties "^1.1.3" es-abstract "^1.19.0" +arrify@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +assert-never@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/assert-never/-/assert-never-1.2.1.tgz#11f0e363bf146205fb08193b5c7b90f4d1cf44fe" + integrity sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw== + astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -1458,6 +1650,11 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" +b4a@^1.0.1, b4a@^1.1.0, b4a@^1.1.1, b4a@^1.3.0, b4a@^1.3.1, b4a@^1.5.0: + version "1.5.3" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.5.3.tgz#56293b5607aeda3fd81c481e516e9f103fc88341" + integrity sha512-1aCQIzQJK7G0z1Una75tWMlwVAR8o+QHoAlnWc5XAxRVBESY9WsitfBgM5nPyDBP5HrhPU1Np4Pq2Y7CJQ+tVw== + babel-jest@^27.2.5: version "27.2.5" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.2.5.tgz#6bbbc1bb4200fe0bfd1b1fbcbe02fc62ebed16aa" @@ -1519,6 +1716,13 @@ babel-preset-jest@^27.2.0: babel-plugin-jest-hoist "^27.2.0" babel-preset-current-node-syntax "^1.0.0" +babel-walk@3.0.0-canary-5: + version "3.0.0-canary-5" + resolved "https://registry.yarnpkg.com/babel-walk/-/babel-walk-3.0.0-canary-5.tgz#f66ecd7298357aee44955f235a6ef54219104b11" + integrity sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw== + dependencies: + "@babel/types" "^7.9.6" + backo2@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" @@ -1534,6 +1738,27 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blake2b-wasm@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/blake2b-wasm/-/blake2b-wasm-2.4.0.tgz#9115649111edbbd87eb24ce7c04b427e4e2be5be" + integrity sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w== + dependencies: + b4a "^1.0.1" + nanoassert "^2.0.0" + +blake2b@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/blake2b/-/blake2b-2.1.4.tgz#817d278526ddb4cd673bfb1af16d1ad61e393ba3" + integrity sha512-AyBuuJNI64gIvwx13qiICz6H6hpmjvYS5DGkG6jbXMOT8Z3WUJ3V1X0FlhIoT1b/5JtHE3ki+xjtMvu1nn+t9A== + dependencies: + blake2b-wasm "^2.4.0" + nanoassert "^2.0.0" + +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + body-parser@1.19.0, body-parser@^1.18.3: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" @@ -1550,6 +1775,21 @@ body-parser@1.19.0, body-parser@^1.18.3: raw-body "2.4.0" type-is "~1.6.17" +bogon@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/bogon/-/bogon-1.0.0.tgz#66b8cdd269f790e3aa988e157bb34d4ba75ee586" + integrity sha512-mXxtlBtnW8koqFWPUBtKJm97vBSKZRpOvxvMRVun33qQXwMNfQzq9eTcQzKzqEoNUhNqF9t8rDc/wakKCcHMTg== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +boolean@3.2.0, boolean@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b" + integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== + boxen@^5.0.0: version "5.1.2" resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" @@ -1672,6 +1912,13 @@ caniuse-lite@^1.0.30001264: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz" integrity sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg== +chacha20-universal@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/chacha20-universal/-/chacha20-universal-1.0.4.tgz#e8a33a386500b1ce5361b811ec5e81f1797883f5" + integrity sha512-/IOxdWWNa7nRabfe7+oF+jVkGjlr2xUL4J8l/OvzZhj+c9RpMqoo3Dq+5nU1j/BflRV4BKnaQ4+4oH1yBpQG1Q== + dependencies: + nanoassert "^2.0.0" + chalk@^2.0.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1694,6 +1941,37 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +character-parser@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/character-parser/-/character-parser-2.2.0.tgz#c7ce28f36d4bcd9744e5ffc2c5fcde1c73261fc0" + integrity sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw== + dependencies: + is-regex "^1.0.3" + +cheerio-select@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.6.0.tgz#489f36604112c722afa147dedd0d4609c09e1696" + integrity sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g== + dependencies: + css-select "^4.3.0" + css-what "^6.0.1" + domelementtype "^2.2.0" + domhandler "^4.3.1" + domutils "^2.8.0" + +cheerio@1.0.0-rc.10: + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.10.tgz#2ba3dcdfcc26e7956fc1f440e61d51c643379f3e" + integrity sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw== + dependencies: + cheerio-select "^1.5.0" + dom-serializer "^1.3.2" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" + chokidar@^3.2.2: version "3.5.2" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" @@ -1719,6 +1997,11 @@ ci-info@^3.1.1: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.2.0.tgz#2876cb948a498797b5236f0095bc057d0dca38b6" integrity sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A== +ci-info@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.2.tgz#6d2967ffa407466481c6c90b6e16b3098f080128" + integrity sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg== + cjs-module-lexer@^1.0.0: version "1.2.2" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" @@ -1795,11 +2078,30 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^2.20.3: +commander@^2.19.0, commander@^2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +compact-encoding-net@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/compact-encoding-net/-/compact-encoding-net-1.0.1.tgz#4da743d52721f5d0cc73a6d00556a96bc9b9fa1b" + integrity sha512-N9k1Qwg9b1ENk+TZsZhthzkuMtn3rn4ZinN75gf3/LplE+uaTCKjyaau5sK0m2NEUa/MmR77VxiGfD/Qz1ar0g== + dependencies: + compact-encoding "^2.4.1" + +compact-encoding@^2.1.0, compact-encoding@^2.4.1, compact-encoding@^2.5.1: + version "2.7.0" + resolved "https://registry.yarnpkg.com/compact-encoding/-/compact-encoding-2.7.0.tgz#e6a0df408c25cbcdf7d619c97527074478cafd06" + integrity sha512-2I0A+pYKXYwxewbLxj26tU4pJyKlFNjadzjZ+36xJ5HwTrnhD9KcMQk3McEQRl1at6jrwA8E7UjmBdsGhEAPMw== + dependencies: + b4a "^1.3.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1817,6 +2119,21 @@ configstore@^5.0.1: write-file-atomic "^3.0.0" xdg-basedir "^4.0.0" +consolidate@^0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" + integrity sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ== + dependencies: + bluebird "^3.7.2" + +constantinople@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-4.0.1.tgz#0def113fa0e4dc8de83331a5cf79c8b325213151" + integrity sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw== + dependencies: + "@babel/parser" "^7.6.0" + "@babel/types" "^7.6.1" + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -1871,6 +2188,17 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1880,11 +2208,34 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-random-string@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-3.3.1.tgz#13cee94cac8001e4842501608ef779e0ed08f82d" + integrity sha512-5j88ECEn6h17UePrLi6pn1JcLtAiANa3KExyr9y9Z5vo2mv56Gh3I4Aja/B9P9uyMwyxNHAHWv+nE72f30T5Dg== + dependencies: + type-fest "^0.8.1" + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css-select@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" + integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== + dependencies: + boolbase "^1.0.0" + css-what "^6.0.1" + domhandler "^4.3.1" + domutils "^2.8.0" + nth-check "^2.0.1" + +css-what@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + cssfilter@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" @@ -1942,13 +2293,20 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.4: +debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" +debugging-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/debugging-stream/-/debugging-stream-2.0.0.tgz#515cad5a35299cf4b4bc0afcbd69d52c809c84ce" + integrity sha512-xwfl6wB/3xc553uwtGnSa94jFxnGOc02C0WU2Nmzwr80gzeqn1FX4VcbvoKIhe8L/lPq4BTQttAbrTN94uN8rA== + dependencies: + streamx "^2.12.4" + decimal.js-light@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" @@ -2028,6 +2386,23 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dht-rpc@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/dht-rpc/-/dht-rpc-6.1.1.tgz#a292a22aa19b05136978d33528cb571d6e32502f" + integrity sha512-wo0nMXwn/rhxVz62V0d+l/0HuikxLQh6lkwlUIdoaUzGl9DobFj4epSScD3/lTMwKts+Ih0DFNqP+j0tYwdajQ== + dependencies: + b4a "^1.3.1" + compact-encoding "^2.1.0" + compact-encoding-net "^1.0.1" + events "^3.3.0" + fast-fifo "^1.0.0" + kademlia-routing-table "^1.0.0" + nat-sampler "^1.0.1" + sodium-universal "^3.0.4" + streamx "^2.10.3" + time-ordered-set "^1.0.2" + udx-native "^1.1.0" + dicer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.0.tgz#eacd98b3bfbf92e8ab5c2fdb71aaac44bb06b872" @@ -2052,6 +2427,19 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +discontinuous-range@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" + integrity sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ== + +display-notification@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/display-notification/-/display-notification-2.0.0.tgz#49fad2e03289b4f668c296e1855c2cf8ba893d49" + integrity sha512-TdmtlAcdqy1NU+j7zlkDdMnCL878zriLaBmoD9quOoq1ySSSGv03l0hXK5CvIFZlIfFI/hizqdQuW+Num7xuhw== + dependencies: + escape-string-applescript "^1.0.0" + run-applescript "^3.0.0" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -2066,6 +2454,25 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +doctypes@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" + integrity sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ== + +dom-serializer@^1.0.1, dom-serializer@^1.3.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.0" + entities "^2.0.0" + +domelementtype@^2.0.1, domelementtype@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -2073,6 +2480,29 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +domhandler@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" + integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== + dependencies: + domelementtype "^2.0.1" + +domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== + dependencies: + domelementtype "^2.2.0" + +domutils@^2.4.2, domutils@^2.5.2, domutils@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + dot-prop@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -2107,6 +2537,20 @@ electron-to-chromium@^1.3.857: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.864.tgz#6a993bcc196a2b8b3df84d28d5d4dd912393885f" integrity sha512-v4rbad8GO6/yVI92WOeU9Wgxc4NA0n4f6P1FvZTY+jyY7JHEhw3bduYu60v3Q1h81Cg6eo4ApZrFPuycwd5hGw== +email-templates@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/email-templates/-/email-templates-10.0.1.tgz#00ed3d394c3b64fa7b8127027e52b01d70c468d4" + integrity sha512-LNZKS0WW9XQkjuDZd/4p/1Q/pwqaqXOP3iDxTIVIQY9vuHlIUEcRLFo8/Xh3GtZCBnm181VgvOXIABKTVyTePA== + dependencies: + "@ladjs/i18n" "^8.0.1" + consolidate "^0.16.0" + get-paths "^0.0.7" + html-to-text "^8.2.0" + juice "^8.0.0" + lodash "^4.17.21" + nodemailer "^6.7.7" + preview-email "^3.0.7" + emittery@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860" @@ -2122,6 +2566,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encoding-japanese@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encoding-japanese/-/encoding-japanese-2.0.0.tgz#fa0226e5469e7b5b69a04fea7d5481bd1fa56936" + integrity sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ== + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -2136,6 +2585,11 @@ enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -2188,11 +2642,21 @@ escape-goat@^2.0.0: resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== +escape-goat@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-3.0.0.tgz#e8b5fb658553fe8a3c4959c316c6ebb8c842b19c" + integrity sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw== + escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= +escape-string-applescript@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/escape-string-applescript/-/escape-string-applescript-1.0.0.tgz#6f1c2294245d82c63bc03338dc19a94aa8428892" + integrity sha512-4/hFwoYaC6TkpDn9A3pTC52zQPArFeXuIfhUtCGYdauTzXVP9H3BDr3oO/QzQehMpLDC7srvYgfwvImPFGfvBA== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -2430,6 +2894,24 @@ eventemitter3@^3.1.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +execa@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" + integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw== + dependencies: + cross-spawn "^6.0.0" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -2513,6 +2995,11 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== +fast-fifo@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.1.0.tgz#17d1a3646880b9891dfa0c54e69c5fef33cad779" + integrity sha512-Kl29QoNbNvn4nhDsLYjyIAaIqaJB6rBx5p3sL9VjaefJ+eMFBWVZiaoguaoZfzEKr5RhAti0UgM8703akGPJ6g== + fast-glob@^3.1.1: version "3.2.7" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1" @@ -2534,6 +3021,13 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-printf@^1.6.9: + version "1.6.9" + resolved "https://registry.yarnpkg.com/fast-printf/-/fast-printf-1.6.9.tgz#212f56570d2dc8ccdd057ee93d50dd414d07d676" + integrity sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg== + dependencies: + boolean "^3.1.4" + fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -2704,6 +3198,23 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-paths@^0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/get-paths/-/get-paths-0.0.7.tgz#15331086752077cf130166ccd233a1cdbeefcf38" + integrity sha512-0wdJt7C1XKQxuCgouqd+ZvLJ56FQixKoki9MrFaO4EriqzXOiH9gbukaDE1ou08S8Ns3/yDzoBAISNPqj6e6tA== + dependencies: + pify "^4.0.1" + +get-port@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" + integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + integrity sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ== + get-stream@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -2893,6 +3404,20 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +he@1.2.0, he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +hmac-blake2b@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hmac-blake2b/-/hmac-blake2b-2.0.0.tgz#09494e5d245d7afe45d157093080b159f7bacf15" + integrity sha512-JbGNtM1YRd8EQH/2vNTAP1oy5lJVPlBFYZfCJTu3k8sqOUm0rRIf/3+MCd5noVykETwTbun6jEOc+4Tu78ubHA== + dependencies: + nanoassert "^1.1.0" + sodium-native "^3.1.1" + sodium-universal "^3.0.0" + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -2910,6 +3435,50 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +html-to-text@8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-8.2.0.tgz#8b35e280ba7fc27710b7aa76d4500aab30731924" + integrity sha512-CLXExYn1b++Lgri+ZyVvbUEFwzkLZppjjZOwB7X1qv2jIi8MrMEvxWX5KQ7zATAzTvcqgmtO00M2kCRMtEdOKQ== + dependencies: + "@selderee/plugin-htmlparser2" "^0.6.0" + deepmerge "^4.2.2" + he "^1.2.0" + htmlparser2 "^6.1.0" + minimist "^1.2.6" + selderee "^0.6.0" + +html-to-text@^8.2.0: + version "8.2.1" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-8.2.1.tgz#4a75b8a1b646149bd71c50527adb568990bf459b" + integrity sha512-aN/3JvAk8qFsWVeE9InWAWueLXrbkoVZy0TkzaGhoRBC2gCFEeRLDDJN3/ijIGHohy6H+SZzUQWN/hcYtaPK8w== + dependencies: + "@selderee/plugin-htmlparser2" "^0.6.0" + deepmerge "^4.2.2" + he "^1.2.0" + htmlparser2 "^6.1.0" + minimist "^1.2.6" + selderee "^0.6.0" + +htmlparser2@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7" + integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ== + dependencies: + domelementtype "^2.0.1" + domhandler "^3.3.0" + domutils "^2.4.2" + entities "^2.0.0" + +htmlparser2@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + http-cache-semantics@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" @@ -2970,6 +3539,46 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +hypercore-crypto@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/hypercore-crypto/-/hypercore-crypto-3.3.0.tgz#03ab5b44608a563e131f629f671c6f90a83c52e6" + integrity sha512-zAWbDqG7kWwS6rCxxTUeB/OeFAz3PoOmouKaoMubtDJYJsLHqXtA3wE2mLsw+E2+iYyom5zrFyBTFVYxmgwW6g== + dependencies: + b4a "^1.1.0" + compact-encoding "^2.5.1" + sodium-universal "^3.0.0" + +i18n-locales@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/i18n-locales/-/i18n-locales-0.0.5.tgz#8f587e598ab982511d7c7db910cb45b8d93cd96a" + integrity sha512-Kve1AHy6rqyfJHPy8MIvaKBKhHhHPXV+a/TgMkjp3UBhO3gfWR40ZQn8Xy7LI6g3FhmbvkFtv+GCZy6yvuyeHQ== + dependencies: + "@ladjs/country-language" "^0.2.1" + +i18n@^0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/i18n/-/i18n-0.15.0.tgz#dca7a498a4371874db01f6610381a412897306eb" + integrity sha512-TUOkuFbl8Y/q7zF0tHdtpk1/TtxH0T+Drp2NFrHhmN1Qs0Sob9/0uVLS2BPVkEXNh2jZrimOiFJk+tkaOumzog== + dependencies: + "@messageformat/core" "^3.0.0" + debug "^4.3.3" + fast-printf "^1.6.9" + make-plural "^7.0.0" + math-interval-parser "^2.0.1" + mustache "^4.2.0" + +i18n@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/i18n/-/i18n-0.15.1.tgz#68fb8993c461cc440bc2485d82f72019f2b92de8" + integrity sha512-yue187t8MqUPMHdKjiZGrX+L+xcUsDClGO0Cz4loaKUOK9WrGw5pgan4bv130utOwX7fHE9w2iUeHFalVQWkXA== + dependencies: + "@messageformat/core" "^3.0.0" + debug "^4.3.3" + fast-printf "^1.6.9" + make-plural "^7.0.0" + math-interval-parser "^2.0.1" + mustache "^4.2.0" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -2977,7 +3586,7 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.2: +iconv-lite@0.6.3, iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -3120,6 +3729,13 @@ is-core-module@^2.2.0, is-core-module@^2.6.0: dependencies: has "^1.0.3" +is-core-module@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" + integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== + dependencies: + has "^1.0.3" + is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" @@ -3127,6 +3743,19 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" +is-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-expression@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-4.0.0.tgz#c33155962abf21d0afd2552514d67d2ec16fd2ab" + integrity sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A== + dependencies: + acorn "^7.1.1" + object-assign "^4.1.1" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -3194,12 +3823,17 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-promise@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + is-property@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" integrity sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ= -is-regex@^1.1.4: +is-regex@^1.0.3, is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -3212,6 +3846,11 @@ is-shared-array-buffer@^1.0.1: resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6" integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA== +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -3243,6 +3882,13 @@ is-weakref@^1.0.1: dependencies: call-bind "^1.0.0" +is-wsl@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + is-yarn-global@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" @@ -3710,6 +4356,11 @@ jest@^27.2.4: import-local "^3.0.2" jest-cli "^27.2.5" +js-stringify@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/js-stringify/-/js-stringify-1.0.2.tgz#1736fddfd9724f28a3682adc6230ae7e4e9679db" + integrity sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3825,6 +4476,25 @@ jsonwebtoken@^8.5.1: ms "^2.1.1" semver "^5.6.0" +jstransformer@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" + integrity sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A== + dependencies: + is-promise "^2.0.0" + promise "^7.0.1" + +juice@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/juice/-/juice-8.1.0.tgz#4ea23362522fe06418229943237ee3751a4fca70" + integrity sha512-FLzurJrx5Iv1e7CfBSZH68dC04EEvXvvVvPYB7Vx1WAuhCp1ZPIMtqxc+WTWxVkpTIC2Ach/GAv0rQbtGf6YMA== + dependencies: + cheerio "1.0.0-rc.10" + commander "^6.1.0" + mensch "^0.3.4" + slick "^1.12.2" + web-resource-inliner "^6.0.1" + jwa@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -3842,6 +4512,11 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +kademlia-routing-table@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/kademlia-routing-table/-/kademlia-routing-table-1.0.1.tgz#6f18416f612e885a8d4df128f04c490a90d772f6" + integrity sha512-dKk19sC3/+kWhBIvOKCthxVV+JH0NrswSBq4sA4eOkkPMqQM1rRuOWte1WSKXeP8r9Nx4NuiH2gny3lMddJTpw== + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -3882,11 +4557,38 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +libbase64@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-1.2.1.tgz#fb93bf4cb6d730f29b92155b6408d1bd2176a8c8" + integrity sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew== + +libmime@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/libmime/-/libmime-5.1.0.tgz#d9a1c4a85c982fa4e64c2c841f95e3827c3f71d2" + integrity sha512-xOqorG21Va+3CjpFOfFTU7SWohHH2uIX9ZY4Byz6J+lvpfvc486tOAT/G9GfbrKtJ9O7NCX9o0aC2lxqbnZ9EA== + dependencies: + encoding-japanese "2.0.0" + iconv-lite "0.6.3" + libbase64 "1.2.1" + libqp "1.1.0" + libphonenumber-js@^1.9.7: version "1.9.37" resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.9.37.tgz#944f59a3618a8f85d9b619767a0b6fb87523f285" integrity sha512-RnUR4XwiVhMLnT7uFSdnmLeprspquuDtaShAgKTA+g/ms9/S4hQU3/QpFdh3iXPHtxD52QscXLm2W2+QBmvYAg== +libqp@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/libqp/-/libqp-1.1.0.tgz#f5e6e06ad74b794fb5b5b66988bf728ef1dedbe8" + integrity sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA== + +linkify-it@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-4.0.0.tgz#4f2d16879adc637cdfe9056cbc02de30e88ffa32" + integrity sha512-QAxkXyzT/TXgwGyY4rTgC95Ex6/lZ5/lYTV9nug6eJt93BCBQGOE47D/g2+/m5J1MrVLr2ot97OXkBZ9bBpR4A== + dependencies: + uc.micro "^1.0.1" + load-json-file@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" @@ -3972,7 +4674,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= -lodash@4.x, lodash@^4.7.0: +lodash@4.x, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4023,6 +4725,30 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +mailparser@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/mailparser/-/mailparser-3.5.0.tgz#5b333b0ef2f063a7db9d24ed95f29efb464cbef3" + integrity sha512-mdr2DFgz8LKC0/Q6io6znA0HVnzaPFT0a4TTnLeZ7mWHlkfnm227Wxlq7mHh7AgeP32h7gOUpXvyhSfJJIEeyg== + dependencies: + encoding-japanese "2.0.0" + he "1.2.0" + html-to-text "8.2.0" + iconv-lite "0.6.3" + libmime "5.1.0" + linkify-it "4.0.0" + mailsplit "5.3.2" + nodemailer "6.7.3" + tlds "1.231.0" + +mailsplit@5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/mailsplit/-/mailsplit-5.3.2.tgz#c344c019f631be4f54d5213509637127e3e3dd66" + integrity sha512-coES12hhKqagkuBTJoqERX+y9bXNpxbxw3Esd07auuwKYmcagouVlgucyIVRp48fnswMKxcUtLoFn/L1a75ynQ== + dependencies: + libbase64 "1.2.1" + libmime "5.1.0" + libqp "1.1.0" + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -4035,6 +4761,11 @@ make-error@1.x, make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +make-plural@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-7.1.0.tgz#8a0381ff8c9be4f074e0acdc42ec97639c2344f9" + integrity sha512-PKkwVlAxYVo98NrbclaQIT4F5Oy+X58PZM5r2IwUSCe3syya6PXkIRCn2XCdz7p58Scgpp50PBeHmepXVDG3hg== + makeerror@1.0.x: version "1.0.11" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" @@ -4042,11 +4773,21 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" +math-interval-parser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/math-interval-parser/-/math-interval-parser-2.0.1.tgz#e22cd6d15a0a7f4c03aec560db76513da615bed4" + integrity sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +mensch@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/mensch/-/mensch-0.3.4.tgz#770f91b46cb16ea5b204ee735768c3f0c491fecd" + integrity sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -4092,6 +4833,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^2.4.6: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -4114,6 +4860,16 @@ minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +moo@^0.5.0, moo@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" + integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -4134,6 +4890,22 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multimatch@5: + version "5.0.0" + resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" + integrity sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA== + dependencies: + "@types/minimatch" "^3.0.3" + array-differ "^3.0.0" + array-union "^2.1.0" + arrify "^2.0.1" + minimatch "^3.0.4" + +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + mysql2@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-2.3.0.tgz#600f5cc27e397dfb77b59eac93666434f88e8079" @@ -4155,16 +4927,58 @@ named-placeholders@^1.1.2: dependencies: lru-cache "^4.1.3" +nanoassert@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/nanoassert/-/nanoassert-1.1.0.tgz#4f3152e09540fde28c76f44b19bbcd1d5a42478d" + integrity sha512-C40jQ3NzfkP53NsO8kEOFd79p4b9kDXQMwgiY1z8ZwrDZgUyom0AHwGegF4Dm99L+YoYhuaB0ceerUcXmqr1rQ== + +nanoassert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nanoassert/-/nanoassert-2.0.0.tgz#a05f86de6c7a51618038a620f88878ed1e490c09" + integrity sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA== + +napi-macros@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" + integrity sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg== + +nat-sampler@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/nat-sampler/-/nat-sampler-1.0.1.tgz#2b68338ea6d4c139450cd971fd00a4ac1b33d923" + integrity sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +nearley@^2.20.1: + version "2.20.1" + resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" + integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ== + dependencies: + commander "^2.19.0" + moo "^0.5.0" + railroad-diagrams "^1.0.0" + randexp "0.4.6" + negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +node-fetch@^2.6.0: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^2.6.1: version "2.6.5" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd" @@ -4172,10 +4986,10 @@ node-fetch@^2.6.1: dependencies: whatwg-url "^5.0.0" -node-gyp-build@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" - integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== +node-gyp-build@^4.3.0, node-gyp-build@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.5.0.tgz#7a64eefa0b21112f89f58379da128ac177f20e40" + integrity sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg== node-int64@^0.4.0: version "0.4.0" @@ -4192,11 +5006,21 @@ node-releases@^1.1.77: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.77.tgz#50b0cfede855dd374e7585bf228ff34e57c1c32e" integrity sha512-rB1DUFUNAN4Gn9keO2K1efO35IDK7yKHCdCaIMvFO7yUYmmZYeDjnGKle26G4rwj+LKRQpjyUUvMkPglwGCYNQ== +nodemailer@6.7.3: + version "6.7.3" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018" + integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g== + nodemailer@^6.6.5: version "6.6.5" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.5.tgz#f9f6953cee5cfe82cbea152eeddacf7a0442049a" integrity sha512-C/v856DBijUzHcHIgGpQoTrfsH3suKIRAGliIzCstatM2cAa+MYX3LuyCrABiO/cdJTxgBBHXxV1ztiqUwst5A== +nodemailer@^6.7.7: + version "6.7.8" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.8.tgz#9f1af9911314960c0b889079e1754e8d9e3f740a" + integrity sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g== + nodemon@^2.0.7: version "2.0.13" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.13.tgz#67d40d3a4d5bd840aa785c56587269cfcf5d24aa" @@ -4213,6 +5037,25 @@ nodemon@^2.0.7: undefsafe "^2.0.3" update-notifier "^5.1.0" +noise-curve-ed@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/noise-curve-ed/-/noise-curve-ed-1.0.4.tgz#8ae83f5d2d2e31d0c9c069271ca6e462d31cd884" + integrity sha512-plUUSEOU66FZ9TaBKpk4+fgQeeS+OLlThS2o8a1TxVpMWV2v1izvEnjSpFV9gEPZl4/1yN+S5KqLubFjogqQOw== + dependencies: + b4a "^1.1.0" + nanoassert "^2.0.0" + sodium-universal "^3.0.4" + +noise-handshake@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/noise-handshake/-/noise-handshake-2.2.0.tgz#24c98f502d49118770e1ec2af2894b8789f0ac7c" + integrity sha512-+0mFUc5YSnOPI+4K/7nr6XDGduITaUasPVurzrH03sk6yW+udKxP/qjEwEekRwIpnvcCKYnjiZ9HJenJv9ljZg== + dependencies: + b4a "^1.1.0" + hmac-blake2b "^2.0.0" + nanoassert "^2.0.0" + sodium-universal "^3.0.4" + nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -4240,6 +5083,13 @@ normalize-url@^4.1.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw== + dependencies: + path-key "^2.0.0" + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -4247,12 +5097,19 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + nwsapi@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== -object-assign@^4: +object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -4321,6 +5178,14 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@7: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -4350,6 +5215,18 @@ p-cancelable@^1.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== +p-event@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-event/-/p-event-4.2.0.tgz#af4b049c8acd91ae81083ebd1e6f5cae2044c1b5" + integrity sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ== + dependencies: + p-timeout "^3.1.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -4378,6 +5255,13 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-timeout@^3.0.0, p-timeout@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -4388,6 +5272,13 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +p-wait-for@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-wait-for/-/p-wait-for-3.2.0.tgz#640429bcabf3b0dd9f492c31539c5718cb6a3f1f" + integrity sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA== + dependencies: + p-timeout "^3.0.0" + package-json@^6.3.0: version "6.5.0" resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" @@ -4413,11 +5304,26 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" -parse5@6.0.1: +parse5-htmlparser2-tree-adapter@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" + +parse5@6.0.1, parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parseley@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.7.0.tgz#9949e3a0ed05c5072adb04f013c2810cf49171a8" + integrity sha512-xyOytsdDu077M3/46Am+2cGXEKM9U9QclBDv7fimY7e+BBlxh2JcBp2mgNsmkyA9uvgyTjVzDi7cP1v4hcFxbw== + dependencies: + moo "^0.5.1" + nearley "^2.20.1" + parseurl@^1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -4438,12 +5344,17 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== + path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.6: +path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -4480,6 +5391,11 @@ pify@^3.0.0: resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + pirates@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" @@ -4545,11 +5461,35 @@ pretty-format@^27.0.0, pretty-format@^27.2.5: ansi-styles "^5.0.0" react-is "^17.0.1" +preview-email@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/preview-email/-/preview-email-3.0.7.tgz#b43e997294367f9c7437150bbe61a52e6bc7dca4" + integrity sha512-WGko2NiS3d8qoGcC981sXotm7noW/dcv4Cp4wo+X95ek2WwJ4A+aDpw/MzMjMW/johihvmfrfUdUWBbh+HnxCw== + dependencies: + ci-info "^3.3.2" + crypto-random-string "3.3.1" + display-notification "2.0.0" + get-port "5.1.1" + mailparser "^3.5.0" + nodemailer "^6.7.7" + open "7" + p-event "4.2.0" + p-wait-for "3.2.0" + pug "^3.0.2" + uuid "^8.3.2" + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise@^7.0.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -4581,6 +5521,109 @@ pstree.remy@^1.1.7: resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== +pug-attrs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-3.0.0.tgz#b10451e0348165e31fad1cc23ebddd9dc7347c41" + integrity sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA== + dependencies: + constantinople "^4.0.1" + js-stringify "^1.0.2" + pug-runtime "^3.0.0" + +pug-code-gen@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-3.0.2.tgz#ad190f4943133bf186b60b80de483100e132e2ce" + integrity sha512-nJMhW16MbiGRiyR4miDTQMRWDgKplnHyeLvioEJYbk1RsPI3FuA3saEP8uwnTb2nTJEKBU90NFVWJBk4OU5qyg== + dependencies: + constantinople "^4.0.1" + doctypes "^1.1.0" + js-stringify "^1.0.2" + pug-attrs "^3.0.0" + pug-error "^2.0.0" + pug-runtime "^3.0.0" + void-elements "^3.1.0" + with "^7.0.0" + +pug-error@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pug-error/-/pug-error-2.0.0.tgz#5c62173cb09c34de2a2ce04f17b8adfec74d8ca5" + integrity sha512-sjiUsi9M4RAGHktC1drQfCr5C5eriu24Lfbt4s+7SykztEOwVZtbFk1RRq0tzLxcMxMYTBR+zMQaG07J/btayQ== + +pug-filters@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/pug-filters/-/pug-filters-4.0.0.tgz#d3e49af5ba8472e9b7a66d980e707ce9d2cc9b5e" + integrity sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A== + dependencies: + constantinople "^4.0.1" + jstransformer "1.0.0" + pug-error "^2.0.0" + pug-walk "^2.0.0" + resolve "^1.15.1" + +pug-lexer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pug-lexer/-/pug-lexer-5.0.1.tgz#ae44628c5bef9b190b665683b288ca9024b8b0d5" + integrity sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w== + dependencies: + character-parser "^2.2.0" + is-expression "^4.0.0" + pug-error "^2.0.0" + +pug-linker@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/pug-linker/-/pug-linker-4.0.0.tgz#12cbc0594fc5a3e06b9fc59e6f93c146962a7708" + integrity sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw== + dependencies: + pug-error "^2.0.0" + pug-walk "^2.0.0" + +pug-load@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pug-load/-/pug-load-3.0.0.tgz#9fd9cda52202b08adb11d25681fb9f34bd41b662" + integrity sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ== + dependencies: + object-assign "^4.1.1" + pug-walk "^2.0.0" + +pug-parser@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/pug-parser/-/pug-parser-6.0.0.tgz#a8fdc035863a95b2c1dc5ebf4ecf80b4e76a1260" + integrity sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw== + dependencies: + pug-error "^2.0.0" + token-stream "1.0.0" + +pug-runtime@^3.0.0, pug-runtime@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/pug-runtime/-/pug-runtime-3.0.1.tgz#f636976204723f35a8c5f6fad6acda2a191b83d7" + integrity sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg== + +pug-strip-comments@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz#f94b07fd6b495523330f490a7f554b4ff876303e" + integrity sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ== + dependencies: + pug-error "^2.0.0" + +pug-walk@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pug-walk/-/pug-walk-2.0.0.tgz#417aabc29232bb4499b5b5069a2b2d2a24d5f5fe" + integrity sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ== + +pug@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/pug/-/pug-3.0.2.tgz#f35c7107343454e43bc27ae0ff76c731b78ea535" + integrity sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw== + dependencies: + pug-code-gen "^3.0.2" + pug-filters "^4.0.0" + pug-lexer "^5.0.1" + pug-linker "^4.0.0" + pug-load "^3.0.0" + pug-parser "^6.0.0" + pug-runtime "^3.0.1" + pug-strip-comments "^2.0.0" + pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -4606,11 +5649,36 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.0.tgz#011104793a3309ae86bfeddd54e251dc94a36725" + integrity sha512-ULWhjjE8BmiICGn3G8+1L9wFpERNxkf8ysxkAer4+TFdRefDaXOCV5m92aMB9FtBVmn/8sETXLXY6BfW7hyaWQ== + +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A== + +randexp@0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" + integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== + dependencies: + discontinuous-range "1.0.0" + ret "~0.1.10" + random-bigint@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/random-bigint/-/random-bigint-0.0.1.tgz#684de0a93784ab7448a441393916f0e632c95df9" @@ -4670,6 +5738,13 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +record-cache@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/record-cache/-/record-cache-1.2.0.tgz#e601bc4f164d58330cc00055e27aa4682291c882" + integrity sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw== + dependencies: + b4a "^1.3.1" + reflect-metadata@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" @@ -4729,6 +5804,15 @@ resolve@^1.10.0, resolve@^1.10.1, resolve@^1.20.0: is-core-module "^2.2.0" path-parse "^1.0.6" +resolve@^1.15.1, resolve@^1.17.0: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + responselike@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -4736,6 +5820,11 @@ responselike@^1.0.2: dependencies: lowercase-keys "^1.0.0" +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + retry@0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -4758,6 +5847,13 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +run-applescript@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-3.2.0.tgz#73fb34ce85d3de8076d511ea767c30d4fdfc918b" + integrity sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg== + dependencies: + execa "^0.10.0" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -4775,11 +5871,21 @@ safe-buffer@^5.0.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-identifier@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" + integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +safety-catch@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/safety-catch/-/safety-catch-1.0.2.tgz#d64cbd57fd601da91c356b6ab8902f3e449a7a4b" + integrity sha512-C1UYVZ4dtbBxEtvOcpjBaaD27nP8MlvyAQEp2fOTOEe6pfUpk1cDUxij6BR1jZup6rSyUTaBBplK7LanskrULA== + saxes@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" @@ -4787,6 +5893,13 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +selderee@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.6.0.tgz#f3bee66cfebcb6f33df98e4a1df77388b42a96f7" + integrity sha512-ibqWGV5aChDvfVdqNYuaJP/HnVBhlRGSRrlbttmlMpHcLuTqqbMH36QkSs9GEgj5M88JDYLI8eyP94JaQ8xRlg== + dependencies: + parseley "^0.7.0" + semver-diff@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" @@ -4794,7 +5907,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -"semver@2 || 3 || 4 || 5", semver@^5.6.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -4863,6 +5976,45 @@ sha.js@^2.4.11: inherits "^2.0.1" safe-buffer "^5.0.1" +sha256-universal@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sha256-universal/-/sha256-universal-1.2.1.tgz#051d92decce280cd6137d42d496eac88da942c0e" + integrity sha512-ghn3muhdn1ailCQqqceNxRgkOeZSVfSE13RQWEg6njB+itsFzGVSJv+O//2hvNXZuxVIRyNzrgsZ37SPDdGJJw== + dependencies: + b4a "^1.0.1" + sha256-wasm "^2.2.1" + +sha256-wasm@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/sha256-wasm/-/sha256-wasm-2.2.2.tgz#4940b6c9ba28f3f08b700efce587ef36d4d516d4" + integrity sha512-qKSGARvao+JQlFiA+sjJZhJ/61gmW/3aNLblB2rsgIxDlDxsJPHo8a1seXj12oKtuHVgJSJJ7QEGBUYQN741lQ== + dependencies: + b4a "^1.0.1" + nanoassert "^2.0.0" + +sha512-universal@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sha512-universal/-/sha512-universal-1.2.1.tgz#829505a7586530515cc1a10b78815c99722c4df0" + integrity sha512-kehYuigMoRkIngCv7rhgruLJNNHDnitGTBdkcYbCbooL8Cidj/bS78MDxByIjcc69M915WxcQTgZetZ1JbeQTQ== + dependencies: + b4a "^1.0.1" + sha512-wasm "^2.3.1" + +sha512-wasm@^2.3.1: + version "2.3.4" + resolved "https://registry.yarnpkg.com/sha512-wasm/-/sha512-wasm-2.3.4.tgz#b86b37112ff6d1fc3740f2484a6855f17a6e1300" + integrity sha512-akWoxJPGCB3aZCrZ+fm6VIFhJ/p8idBv7AWGFng/CZIrQo51oQNsvDbTSRXWAzIiZJvpy16oIDiCCPqTe21sKg== + dependencies: + b4a "^1.0.1" + nanoassert "^2.0.0" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== + dependencies: + shebang-regex "^1.0.0" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -4870,6 +6022,11 @@ shebang-command@^2.0.0: dependencies: shebang-regex "^3.0.0" +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== + shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" @@ -4884,11 +6041,23 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +signal-exit@^3.0.0: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.5" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f" integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ== +siphash24@^1.0.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/siphash24/-/siphash24-1.3.1.tgz#7f87fd2c5db88d8d46335a68f780f281641c8b22" + integrity sha512-moemC3ZKiTzH29nbFo3Iw8fbemWWod4vNs/WgKbQ54oEs6mE6XVlguxvinYjB+UmaE0PThgyED9fUkWvirT8hA== + dependencies: + nanoassert "^2.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -4908,13 +6077,55 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" -sodium-native@^3.3.0: +slick@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7" + integrity sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A== + +sodium-javascript@~0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/sodium-javascript/-/sodium-javascript-0.8.0.tgz#0a94d7bb58ab17be82255f3949259af59778fdbc" + integrity sha512-rEBzR5mPxPES+UjyMDvKPIXy9ImF17KOJ32nJNi9uIquWpS/nfj+h6m05J5yLJaGXjgM72LmQoUbWZVxh/rmGg== + dependencies: + blake2b "^2.1.1" + chacha20-universal "^1.0.4" + nanoassert "^2.0.0" + sha256-universal "^1.1.0" + sha512-universal "^1.1.0" + siphash24 "^1.0.1" + xsalsa20 "^1.0.0" + +sodium-native@^3.1.1, sodium-native@^3.2.0, sodium-native@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/sodium-native/-/sodium-native-3.3.0.tgz#50ee52ac843315866cce3d0c08ab03eb78f22361" integrity sha512-rg6lCDM/qa3p07YGqaVD+ciAbUqm6SoO4xmlcfkbU5r1zIGrguXztLiEtaLYTV5U6k8KSIUFmnU3yQUSKmf6DA== dependencies: node-gyp-build "^4.3.0" +sodium-secretstream@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/sodium-secretstream/-/sodium-secretstream-1.0.2.tgz#ae6fec16555f1a1d9fd2460b41256736d5044e13" + integrity sha512-AsWztbBHhHid+w5g28ftXA0mTrS52Dup7FYI0GR7ri1TQTlVsw0z//FNlhIqWsgtBctO/DxQosacbElCpmdcZw== + dependencies: + b4a "^1.1.1" + sodium-universal "^3.0.4" + +sodium-universal@^3.0.0, sodium-universal@^3.0.4: + version "3.1.0" + resolved "https://registry.yarnpkg.com/sodium-universal/-/sodium-universal-3.1.0.tgz#f2fa0384d16b7cb99b1c8551a39cc05391a3ed41" + integrity sha512-N2gxk68Kg2qZLSJ4h0NffEhp4BjgWHCHXVlDi1aG1hA3y+ZeWEmHqnpml8Hy47QzfL1xLy5nwr9LcsWAg2Ep0A== + dependencies: + blake2b "^2.1.1" + chacha20-universal "^1.0.4" + nanoassert "^2.0.0" + resolve "^1.17.0" + sha256-universal "^1.1.0" + sha512-universal "^1.1.0" + siphash24 "^1.0.1" + sodium-javascript "~0.8.0" + sodium-native "^3.2.0" + xsalsa20 "^1.0.0" + source-map-support@^0.5.6: version "0.5.20" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.20.tgz#12166089f8f5e5e8c56926b377633392dd2cb6c9" @@ -5000,6 +6211,14 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +streamx@^2.10.2, streamx@^2.10.3, streamx@^2.12.0, streamx@^2.12.4: + version "2.12.4" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.12.4.tgz#0369848b20b8f79c65320735372df17cafcd9aff" + integrity sha512-K3xdIp8YSkvbdI0PrCcP0JkniN8cPCyeKlcZgRFSl1o1xKINCYM93FryvTSOY57x73pz5/AjO5B8b9BYf21wWw== + dependencies: + fast-fifo "^1.0.0" + queue-tick "^1.0.0" + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -5050,6 +6269,11 @@ strip-bom@^4.0.0: resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q== + strip-final-newline@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" @@ -5105,6 +6329,11 @@ supports-hyperlinks@^2.0.0: has-flag "^4.0.0" supports-color "^7.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + symbol-observable@^1.0.4: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -5154,6 +6383,26 @@ throat@^6.0.1: resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375" integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w== +time-ordered-set@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/time-ordered-set/-/time-ordered-set-1.0.2.tgz#3bd931fc048234147f8c2b8b1ebbebb0a3ecb96f" + integrity sha512-vGO99JkxvgX+u+LtOKQEpYf31Kj3i/GNwVstfnh4dyINakMgeZCpew1e3Aj+06hEslhtHEd52g7m5IV+o1K8Mw== + +timeout-refresh@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/timeout-refresh/-/timeout-refresh-2.0.1.tgz#f8ec7cf1f9d93b2635b7d4388cb820c5f6c16f98" + integrity sha512-SVqEcMZBsZF9mA78rjzCrYrUs37LMJk3ShZ851ygZYW1cMeIjs9mL57KO6Iv5mmjSQnOe/29/VAfGXo+oRCiVw== + +titleize@2: + version "2.1.0" + resolved "https://registry.yarnpkg.com/titleize/-/titleize-2.1.0.tgz#5530de07c22147a0488887172b5bd94f5b30a48f" + integrity sha512-m+apkYlfiQTKLW+sI4vqUkwMEzfgEUEYSqljx1voUE3Wz/z1ZsxyzSxvH2X8uKVrOp7QkByWt0rA6+gvhCKy6g== + +tlds@1.231.0, tlds@^1.231.0: + version "1.231.0" + resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.231.0.tgz#93880175cd0a06fdf7b5b5b9bcadff9d94813e39" + integrity sha512-L7UQwueHSkGxZHQBXHVmXW64oi+uqNtzFt2x6Ssk7NVnpIbw16CRs4eb/jmKOZ9t2JnqZ/b3Cfvo97lnXqKrhw== + tmpl@1.0.x: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -5181,6 +6430,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +token-stream@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/token-stream/-/token-stream-1.0.0.tgz#cc200eab2613f4166d27ff9afc7ca56d49df6eb4" + integrity sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg== + touch@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" @@ -5278,6 +6532,11 @@ tslib@^2.0.1, tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.2.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -5314,6 +6573,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + type-graphql@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/type-graphql/-/type-graphql-1.1.1.tgz#dc0710d961713b92d3fee927981fa43bf71667a4" @@ -5348,6 +6612,21 @@ typescript@^4.3.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324" integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA== +uc.micro@^1.0.1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + +udx-native@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/udx-native/-/udx-native-1.2.1.tgz#a229b8bfab8c9c9eea05c7e0d68e671ab70d562d" + integrity sha512-hLoJ3rE1PuqO/A1YENG8oYNuAGltdwXofzavYwXbg2yk/qQgGBDpUQd/qtdENxkawad5cEEdJEdwvchslDl7OA== + dependencies: + b4a "^1.5.0" + napi-macros "^2.0.0" + node-gyp-build "^4.4.0" + streamx "^2.12.0" + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" @@ -5365,6 +6644,16 @@ undefsafe@^2.0.3: dependencies: debug "^2.2.0" +underscore.deep@~0.5.1: + version "0.5.3" + resolved "https://registry.yarnpkg.com/underscore.deep/-/underscore.deep-0.5.3.tgz#210969d58025339cecabd2a2ad8c3e8925e5c095" + integrity sha512-4OuSOlFNkiVFVc3khkeG112Pdu1gbitMj7t9B9ENb61uFmN70Jq7Iluhi3oflcSgexkKfDdJ5XAJET2gEq6ikA== + +underscore@~1.13.1: + version "1.13.4" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee" + integrity sha512-BQFnUDuAQ4Yf/cYY5LNrK9NCJFKriaRbD9uR1fTeXnBeoa97W0i41qkZfGO9pSo8I5KzjAcSY2XYtdf0oKd7KQ== + unique-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" @@ -5461,6 +6750,11 @@ v8-to-istanbul@^8.1.0: convert-source-map "^1.6.0" source-map "^0.7.3" +valid-data-url@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/valid-data-url/-/valid-data-url-3.0.1.tgz#826c1744e71b5632e847dd15dbd45b9fb38aa34f" + integrity sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA== + validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -5479,6 +6773,11 @@ vary@^1, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +void-elements@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -5500,6 +6799,18 @@ walker@^1.0.7: dependencies: makeerror "1.0.x" +web-resource-inliner@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz#df0822f0a12028805fe80719ed52ab6526886e02" + integrity sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A== + dependencies: + ansi-colors "^4.1.1" + escape-goat "^3.0.0" + htmlparser2 "^5.0.0" + mime "^2.4.6" + node-fetch "^2.6.0" + valid-data-url "^3.0.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -5555,6 +6866,13 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -5569,6 +6887,16 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" +with@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/with/-/with-7.0.2.tgz#ccee3ad542d25538a7a7a80aad212b9828495bac" + integrity sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w== + dependencies: + "@babel/parser" "^7.9.6" + "@babel/types" "^7.9.6" + assert-never "^1.2.1" + babel-walk "3.0.0-canary-5" + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" @@ -5603,6 +6931,11 @@ write-file-atomic@^3.0.0: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== +xache@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/xache/-/xache-1.1.0.tgz#afc20dec9ff8b2260eea03f5ad9422dc0200c6e9" + integrity sha512-RQGZDHLy/uCvnIrAvaorZH/e6Dfrtxj16iVlGjkj4KD2/G/dNXNqhk5IdSucv5nSSnDK00y8Y/2csyRdHveJ+Q== + xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" @@ -5618,6 +6951,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xsalsa20@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/xsalsa20/-/xsalsa20-1.2.0.tgz#e5a05cb26f8cef723f94a559102ed50c1b44c25c" + integrity sha512-FIr/DEeoHfj7ftfylnoFt3rAIRoWXpx2AoDfrT2qD2wtp7Dp+COajvs/Icb7uHqRW9m60f5iXZwdsJJO3kvb7w== + xss@^1.0.8: version "1.0.10" resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.10.tgz#5cd63a9b147a755a14cb0455c7db8866120eb4d2" diff --git a/database/Dockerfile b/database/Dockerfile index 8ffe8e432..4069ffcd8 100644 --- a/database/Dockerfile +++ b/database/Dockerfile @@ -18,7 +18,7 @@ ENV NODE_ENV="production" # Labels LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.name="gradido:database" -LABEL org.label-schema.description="Gradido GraphQL Backend" +LABEL org.label-schema.description="Gradido Database Migration Service" LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md" LABEL org.label-schema.url="https://gradido.net" LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/database" diff --git a/database/entity/0052-add_updated_at_to_contributions/Contribution.ts b/database/entity/0052-add_updated_at_to_contributions/Contribution.ts new file mode 100644 index 000000000..2242a753f --- /dev/null +++ b/database/entity/0052-add_updated_at_to_contributions/Contribution.ts @@ -0,0 +1,95 @@ +import Decimal from 'decimal.js-light' +import { + BaseEntity, + Column, + Entity, + PrimaryGeneratedColumn, + DeleteDateColumn, + JoinColumn, + ManyToOne, + OneToMany, +} from 'typeorm' +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' +import { User } from '../User' +import { ContributionMessage } from '../ContributionMessage' + +@Entity('contributions') +export class Contribution extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ unsigned: true, nullable: false, name: 'user_id' }) + userId: number + + @ManyToOne(() => User, (user) => user.contributions) + @JoinColumn({ name: 'user_id' }) + user: User + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) + createdAt: Date + + @Column({ type: 'datetime', nullable: false, name: 'contribution_date' }) + contributionDate: Date + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ unsigned: true, nullable: true, name: 'moderator_id' }) + moderatorId: number + + @Column({ unsigned: true, nullable: true, name: 'contribution_link_id' }) + contributionLinkId: number + + @Column({ unsigned: true, nullable: true, name: 'confirmed_by' }) + confirmedBy: number + + @Column({ nullable: true, name: 'confirmed_at' }) + confirmedAt: Date + + @Column({ unsigned: true, nullable: true, name: 'denied_by' }) + deniedBy: number + + @Column({ nullable: true, name: 'denied_at' }) + deniedAt: Date + + @Column({ + name: 'contribution_type', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionType: string + + @Column({ + name: 'contribution_status', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionStatus: string + + @Column({ unsigned: true, nullable: true, name: 'transaction_id' }) + transactionId: number + + @Column({ nullable: true, name: 'updated_at' }) + updatedAt: Date + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date | null + + @DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' }) + deletedBy: number + + @OneToMany(() => ContributionMessage, (message) => message.contribution) + @JoinColumn({ name: 'contribution_id' }) + messages?: ContributionMessage[] +} diff --git a/database/entity/0053-change_password_encryption/User.ts b/database/entity/0053-change_password_encryption/User.ts new file mode 100644 index 000000000..2a3332925 --- /dev/null +++ b/database/entity/0053-change_password_encryption/User.ts @@ -0,0 +1,127 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToMany, + JoinColumn, + OneToOne, +} from 'typeorm' +import { Contribution } from '../Contribution' +import { ContributionMessage } from '../ContributionMessage' +import { UserContact } from '../UserContact' + +@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class User extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ + name: 'gradido_id', + length: 36, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + gradidoID: string + + @Column({ + name: 'alias', + length: 20, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + alias: string + + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) + pubKey: Buffer + + @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) + privKey: Buffer + + @Column({ + type: 'text', + name: 'passphrase', + collation: 'utf8mb4_unicode_ci', + nullable: true, + default: null, + }) + passphrase: string + + @OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user) + @JoinColumn({ name: 'email_id' }) + emailContact: UserContact + + @Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null }) + emailId: number | null + + @Column({ + name: 'first_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + firstName: string + + @Column({ + name: 'last_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + lastName: string + + @Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @DeleteDateColumn({ name: 'deleted_at', nullable: true }) + deletedAt: Date | null + + @Column({ type: 'bigint', default: 0, unsigned: true }) + password: BigInt + + @Column({ + name: 'password_encryption_type', + type: 'int', + unsigned: true, + nullable: false, + default: 0, + }) + passwordEncryptionType: number + + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) + language: string + + @Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null }) + isAdmin: Date | null + + @Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null }) + referrerId?: number | null + + @Column({ + name: 'contribution_link_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + contributionLinkId?: number | null + + @Column({ name: 'publisher_id', default: 0 }) + publisherId: number + + @OneToMany(() => Contribution, (contribution) => contribution.user) + @JoinColumn({ name: 'user_id' }) + contributions?: Contribution[] + + @OneToMany(() => ContributionMessage, (message) => message.user) + @JoinColumn({ name: 'user_id' }) + messages?: ContributionMessage[] + + @OneToMany(() => UserContact, (userContact: UserContact) => userContact.user) + @JoinColumn({ name: 'user_id' }) + userContacts?: UserContact[] +} diff --git a/database/entity/0053-change_password_encryption/UserContact.ts b/database/entity/0053-change_password_encryption/UserContact.ts new file mode 100644 index 000000000..97b12d4cd --- /dev/null +++ b/database/entity/0053-change_password_encryption/UserContact.ts @@ -0,0 +1,60 @@ +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToOne, +} from 'typeorm' +import { User } from './User' + +@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class UserContact extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ + name: 'type', + length: 100, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + type: string + + @OneToOne(() => User, (user) => user.emailContact) + user: User + + @Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false }) + userId: number + + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + + @Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true }) + emailVerificationCode: BigInt + + @Column({ name: 'email_opt_in_type_id' }) + emailOptInTypeId: number + + @Column({ name: 'email_resend_count' }) + emailResendCount: number + + // @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + // emailHash: Buffer + + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + + @Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' }) + phone: string + + @Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' }) + updatedAt: Date | null + + @DeleteDateColumn({ name: 'deleted_at', nullable: true }) + deletedAt: Date | null +} diff --git a/database/entity/Contribution.ts b/database/entity/Contribution.ts index 4b1dfc9c8..0441e7a1f 100644 --- a/database/entity/Contribution.ts +++ b/database/entity/Contribution.ts @@ -1 +1 @@ -export { Contribution } from './0051-add_delete_by_to_contributions/Contribution' +export { Contribution } from './0052-add_updated_at_to_contributions/Contribution' diff --git a/database/entity/User.ts b/database/entity/User.ts index d073f428a..b3c00a9b4 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0049-add_user_contacts_table/User' +export { User } from './0053-change_password_encryption/User' diff --git a/database/entity/UserContact.ts b/database/entity/UserContact.ts index a368bb7ca..dd74e65c4 100644 --- a/database/entity/UserContact.ts +++ b/database/entity/UserContact.ts @@ -1 +1 @@ -export { UserContact } from './0049-add_user_contacts_table/UserContact' +export { UserContact } from './0053-change_password_encryption/UserContact' diff --git a/database/migrations/0052-add_updated_at_to_contributions.ts b/database/migrations/0052-add_updated_at_to_contributions.ts new file mode 100644 index 000000000..e7cc5b7b4 --- /dev/null +++ b/database/migrations/0052-add_updated_at_to_contributions.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + `ALTER TABLE \`contributions\` ADD COLUMN \`updated_at\` datetime DEFAULT NULL AFTER \`transaction_id\`;`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`updated_at\`;`) +} diff --git a/database/migrations/0053-change_password_encryption.ts b/database/migrations/0053-change_password_encryption.ts new file mode 100644 index 000000000..635109430 --- /dev/null +++ b/database/migrations/0053-change_password_encryption.ts @@ -0,0 +1,24 @@ +/* MIGRATION TO ADD ENCRYPTION TYPE TO PASSWORDS + * + * This migration adds and renames columns in the table `users` + */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE users RENAME COLUMN created TO created_at;') + await queryFn('ALTER TABLE users RENAME COLUMN deletedAt TO deleted_at;') + // alter table emp rename column emp_name to name + await queryFn( + 'ALTER TABLE users ADD COLUMN password_encryption_type int(10) NOT NULL DEFAULT 0 AFTER password;', + ) + await queryFn(`UPDATE users SET password_encryption_type = 1 WHERE id IN + (SELECT user_id FROM user_contacts WHERE email_checked = 1)`) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE users RENAME COLUMN created_at TO created;') + await queryFn('ALTER TABLE users RENAME COLUMN deleted_at TO deletedAt;') + await queryFn('ALTER TABLE users DROP COLUMN password_encryption_type;') +} diff --git a/database/package.json b/database/package.json index 0a97b5135..6216a25fb 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "gradido-database", - "version": "1.13.1", + "version": "1.14.1", "description": "Gradido Database Tool to execute database migrations", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/database", diff --git a/deployment/bare_metal/.env.dist b/deployment/bare_metal/.env.dist index b090908e1..1d0e96455 100644 --- a/deployment/bare_metal/.env.dist +++ b/deployment/bare_metal/.env.dist @@ -26,7 +26,7 @@ COMMUNITY_REDEEM_CONTRIBUTION_URL=https://stage1.gradido.net/redeem/CL-{code} COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community" # backend -BACKEND_CONFIG_VERSION=v10.2022-09-20 +BACKEND_CONFIG_VERSION=v11.2022-10-27 JWT_EXPIRES_IN=10m GDT_API_URL=https://gdt.gradido.net @@ -59,6 +59,10 @@ WEBHOOK_ELOPAGE_SECRET=secret # EventProtocol EVENT_PROTOCOL_DISABLED=false +## DHT +## if you set this value, the DHT hyperswarm will start to announce and listen +## on an hash created from this tpoic +# DHT_TOPIC=GRADIDO_HUB # database DATABASE_CONFIG_VERSION=v1.2022-03-18 @@ -86,4 +90,4 @@ SUPPORT_MAIL=support@supportmail.com ADMIN_CONFIG_VERSION=v1.2022-03-18 WALLET_AUTH_URL=https://stage1.gradido.net/authenticate?token={token} -WALLET_URL=https://stage1.gradido.net/login \ No newline at end of file +WALLET_URL=https://stage1.gradido.net/login diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 79ee46906..790bd468d 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,6 +1,25 @@ version: "3.4" services: + ######################################################## + # FRONTEND ############################################# + ######################################################## + frontend: + image: gradido/frontend:test + build: + target: test + environment: + - NODE_ENV="test" + + ######################################################## + # ADMIN INTERFACE ###################################### + ######################################################## + admin: + image: gradido/admin:test + build: + target: test + environment: + - NODE_ENV="test" ######################################################## # BACKEND ############################################## @@ -21,15 +40,19 @@ services: # DATABASE ############################################# ######################################################## database: + image: gradido/database:test_up build: context: ./database target: test_up + environment: + - NODE_ENV="test" # restart: always # this is very dangerous, but worth a test for the delayed mariadb startup at first run ######################################################### ## MARIADB ############################################## ######################################################### mariadb: + image: gradido/mariadb:test networks: - internal-net - external-net @@ -51,6 +74,12 @@ services: - external-net volumes: - /sessions + + ######################################################### + ## NGINX ################################################ + ######################################################### + nginx: + image: gradido/nginx:test networks: external-net: diff --git a/docu/Concepts/BusinessRequirements/UC_Manuel_User_Registration.md b/docu/Concepts/BusinessRequirements/UC_Manuel_User_Registration.md new file mode 100644 index 000000000..97dae661f --- /dev/null +++ b/docu/Concepts/BusinessRequirements/UC_Manuel_User_Registration.md @@ -0,0 +1,275 @@ +# Manuelle User-Registrierung + +## Motivation + +Bei einer Veranstaltung o.ä. sollen neue Mitglieder geworben werden. Dabei ist ungewiss, ob sie ein Endgerät dabei haben bzw. dieses korrekt bedienen können (QR-Code, E-Mail-Zugang etc.). Es soll nun ohne Einsatz zusätzlicher Technologien eine schnelle und unkomplizierte Möglichkeit geschaffen werden, dass ein Moderator im Admin-Interface zusätzliche Funktionen zur Unterstützung des User-Registrierungsprozesses erhält: + +1. manuelle Aktivierung eines User-Accounts ohne Email-Bestätigung und setzen eines (vorläufigen) Passworts +2. vollständige User-Registrierung mit Daten-Erfassung, Account-Aktivierung und setzen eines (vorläufigen) Passworts + +## 1. Unterstützung einer User-Registrierung + +Ein neuer User hat schon selbständig mit seiner Registrierung bei Gradido begonnen, aber in dem Moment keinen Zugriff auf seine Emails. Somit kann er seine erhaltene Bestätigungs-Email mit dem Link zur Konto-Aktivierung nicht abrufen und die Registrierung nicht abschließen. + +Für diesen Fall wird im Admin-Interface eine neue Funktionalität zur "manuellen Aktivierung eines User-Accounts" bereitgestellt. Diese "manuelle Aktivierung" durch den Admin soll den neuen User kurzfristig ermächtigen auf sein Konto zugreifen zu können. Das heißt, dieser Admin-Prozess muss + +* das Konto des neuen Users als aktiviert kennzeichnen +* für den User ein (vorläufiges) Passwort generieren +* die Kennzeichnung beibehalten, dass die Email-Adresse des Users noch nicht bestätigt ist +* dem User ein Login-Prozess ermöglichen, in dem der User das (vorläufige) Passwort verwenden kann +* den User nach dem erfolgreichen Login mit dem (vorläufigen) Passwort direkt zur Eingabe eines eigenen Passwortes bringen + +### 1.1 Starten des Registrierungsunterstützungs-Prozesses + +#### Vorbedingungen + +Nach dem der neue User für sich schon die Erfassung seiner persönlichen Daten im Registrierungsdialog durchgeführt und gespeichert hat, schickt die Anwendung dem User eine Confirmation-Email an seine angegebene Email-Adresse. Der User kommt aber aktuell nicht an seine Emails bzw. benötigt Unterstützung, wie er jetzt weiter machen soll, um sich anzumelden. Mit diesem Bedarf nach Unterstützung wendet der User sich an einen Moderator mit entsprechenden Admin-Rechten. + +#### Manuelle Aktivierung und One-Time-Passwort + +Der Admin navigiert in seinem angemeldeten Gradido-Account auf das Admin-Interface. Dort öffnet er den Dialog "Nutzersuche". + +Neu in diesem Dialog sind nun die neuen Checkboxen "noch nicht aktiviertes Konto" und "unbestätigte Email-Adresse". Im Normalfall sind diese beiden Checkboxen nicht selektiert, so dass mit der üblichen Nutzersuche alle User wie bisher ermittelt werden können. + +![img](./image/Admin-UserSearch.png) + +Um nun schneller einen neuen User mit "noch nicht aktiviertem Konto" und noch "unbestätigter Email-Adresse" für die Registrierungsunterstützung zu finden, kann der Admin die neuen Filter-Checkboxen selektieren. Diese schränken die User-Suche zusätzlich zur üblichen Namens-Eingabe ein, dh. ohne Eingabe eines einschränkenden Namens werden alle User-Accounts gelistet, die ein "noch nicht aktiviertes Konto" und noch eine "unbestätigte Email-Adresse" haben. + +![img](./image/Admin-UserSearch_inaktivAccount.png) + +Sobald der gewünschte User-Account in der Liste gefunden wurde, kann der Detail-Dialog zu diesem User per Klick geöffnet werden. + +![img](./image/Admin-UserAccount-Details.png) + +Der geöffnete Detail-Dialog zeigt einen neuen Reiter "Registrierung", in dem die Informationen über das User-Konto stehen: wann wurde es erzeugt und wie ist der Status der "Konto-Aktivierung" und der "Email-Bestätigung". + +Der Admin kann nun entweder manuell ein One-Time-Passwort in das Eingabefeld eingeben oder über den "erzeugen"-Button eines kreieren lassen. Dieses wird dann über den Button "speichern & Konto aktivieren" in die Datenbank geschrieben, wobei damit gleichzeitig der Status des User-Kontos auf *aktiviert* gesetzt wird. + +Der Admin kann nun das One-Time-Passwort dem User mitteilen, so dass dieser sich über den Login-Prozess in seinen Account ohne vorherige Email-Bestätigung anmelden kann. Der Login-Prozess mit einem One-Time-Passwort muss nach erfolgreicher Anmeldung den User sofort auf den Passwort-Ändern-Dialog führen, um den User direkt die Möglichkeit zu geben sein eigenes Passwort zu vergeben. + +Mit Öffnen des Passwort-Ändern-Dialogs für einen User-Account mit One-Time-Passwort kann nicht mit Sicherheit davon ausgegangen werden, dass der User selbst der Datenschutzerklärung zugestimmt hat - dies könnte durch die Unterstützung der Moderators beim User untergegangen sein. Daher muss in diesem Fall in dem Dialog eine Checkbox zur Bestätigung der Datenschutzerklärung eingeblendet sein. Erst wenn der User sein neues Passwort gemäß den Passwort-Richtlinien gesetzt und den Datenschutzbestimmungen zugestimmt hat, wird der "Speichern"-Button enabled und die Daten können gespeichert werden. + +#### One-Time-Passwort anzeigen oder ändern + +Falls ein neuer User sein erhaltenes One-Time-Passwort noch nicht für einen Login verwendet hat und dieses erneut vom Admin erfragen möchte, dann kann der Administrator dieses erneut im Admin-Interface über die Nutzer-Suche anzeigen lassen. Dazu kann er die Filter-Checkbox "noch nicht aktiviertes Konto" deselektieren, aber die Checkbox "unbestätigte Email-Adresse" selektiert lassen. Dann bekommt er alle User deren Email-Bestätigung noch offen ist und nur User-Konten, die schon aktiviert sind. + +![img](./image/Admin-UserAccount-ActivatedOneTimePasswort.png) + +Beim Öffnen der Userkonto-Details im Reiter "Registrierung" ist dann zu sehen, dass das "Konto schon aktiv", aber die "Email-Bestätigung noch offen" ist. Im Eingabefeld des One-Time-Passwortes ist das zuvor schon gespeicherte Passwort zu lesen, so dass der Admin dieses dem User erneut mitteilen kann. Der Admin kann aber auch über den "erzeugen"-Button oder manuell das vorhandene Passwort ändern. Über den "speichern"-Button, der aufgrund der vorherigen Konto-Aktivierung nun nicht mehr "speichern & Konto aktivieren" heißt, kann die Passwort-Änderung in die Datenbank geschrieben werden. + +### 1.2 Starten einer manuellen Admin-User-Registrierung + +Im Admin-Interface wird im Menü ein neuer Reiter "Registrierung" angezeigt. Mit Auswahl dieses Reiters kann der Moderator den Dialog zur "Manuellen User-Registrierung" öffnen. + +![img](./image/Admin-CreateUser.png) + +Dabei kann der Moderator die Attribute Vorname, Nachname, Email-Adresse und ein One-Time-Passwort eingeben. Mit dem "speichern & Konto aktivieren"-Button wird im Backend zunächst eine Prüfung durchgeführt, ob die eingegebene Email-Adresse ggf. schon von einem anderen existierenden User verwendet wird. Sollte dies der Fall sein, dann wird eine entsprechend aussagekräftige Fehlermeldung ausgegeben und die zuvor eingegebenen Daten werden in dem "Manuelle User-Registrierung" erneut angezeigt. Sind alle Daten soweit valide, dann werden die eingegebenen Daten in der Datenbank gespeichert und der Konto-Status auf *aktiviert* gesetzt. + +Es wird auch hier eine Email zur Emailadress-Bestätigung verschickt. Der Status "email_checked" bleibt auf false, weil der User seine Confirmation-Email zwar bekommen, aber noch nicht bestätigt hat oder eben nicht zeitnah bestätigen kann. Durch das One-Time-Passwort, das der Moderator dem User mitteilen kann, hat der User direkt die Möglichkeit sich über den Login-Prozess anzumelden, ohne vorher den Email-Bestätigungslink aktivieren zu müssen. + +### 1.3 User-Login mit One-Time-Passwort + +Sobald der User selbst oder durch den Moderator ein neues User-Konto angelegt und ein One-Time-Passwort vergeben ist, dann kann der User selbst sich über den üblichen Login-Prozess anmelden. + +Die Anwendung erkennt, dass der Login über ein One-Time-Passwort erfolgte, so dass der User direkt nach dem erfolgreichen Login auf die Passwort-Ändern-Seite geführt wird. + +![img](./image/One-Time-Passwort-Login.png) + +Auf dieser Seite muss der User dann sein neues, nur ihm persönlich bekanntes Passwort eingeben und zur Kontrolle wiederholen. Da der User-Account über eine One-Time-Passwort Registrierung erstellt wurde, hatte der User sehr wahrscheinlich nicht die Gelegenheit der Datenschutzerklärung selbst zuzustimmen. Daher wird hier im Passwort-Ändern-Dialog dies nachgeholt, indem erst mit der Zustimmung zur Datenschutzerklärung der "Passwort ändern"-Button aktiviert wird. + +## 2. Implementierung und Anpassungen + +### 2.1 Datenbank + +Für diese fachlichen Anforderungen müssen folgende Informationen neu in der Datenbank aufgenommen und gespeichert werden: + +* Merkmal, dass für den User ein Konto vorhanden, aber dieses noch nicht aktiviert ist +* Merkmal, dass für den User ein Konto vorhanden und dieses schon aktiviert ist +* One-Time-Passwort, das von der Anwendung im Original angezeigt werden kann - unverschlüsselt oder ohne Interaktion zu entschlüsseln +* Merkmal, ob bzw. wann der User der Datenschutzerklärung zugestimmt hat + +Es stellt sich die Frage, ob mit diesem UseCase gleich die schon sowieso geplante neue Tabelle `accounts `erstellt wird oder die obigen Merkmale erst einmal in die users-Tabelle einfließen? + +**Empfehlung:** erstellen der `accounts`-Tabelle + +In dieser `accounts`-Tabelle werden dann alle Account spezifischen Daten gespeichert und ein `accounts`-Eintrag ist über die Spalte `user_id` dem User in der `users`-Tabelle zugeordnet. + +Ansonsten werden aber keine weiteren Datenbank-Migrationen, wie Zuordnung der Transaktionen oder Contributions zur `accounts`-Tabelle durchgeführt. Dies muss in einem separaten Issue migriert werden. + +#### accounts-Tabelle + +| Column | Type | Description | +| ----------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| id | unsigned int(10) | technical unique key | +| user_id | unsigned int(10) | foreign key to users entry | +| type | enum | account type: AGE (default), AGW, AUF | +| created_at | datetime(3) | the point of time the entry was created | +| activated | tinyint(4) | switch if account is active or inactive | +| creations_allowed | tinyint(4) | switch if account allows to create gradidos or not; necessary for type AGW and AUF | +| decay | tinyint(4) | switch if account supports decay or not; in case the GDT will be shiftet as a separate account type here in the application | +| balance | decimal(40, 20) | amount of gradidos at the updated_at point of time | +| updated_at | datetime(3) | the point of time the entry was updated, especially important for the balance | + +Die letzten vier Spalten sind ersteinmal rein informativ, was ein `accounts`-Eintrag zukünftig enthalten wird und für diesen Usecase optional. Sie könnten auch auf ein zukünftiges Migrations-Issue verschoben werden. + +#### users-tabelle + +| Column | Type | Description | +| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------- | +| privacy_policy_at | datetime(3) | point of time the user agreed with the privacy policy - during registration or one-time-password change | +| password_encryption_type | enum | defines the type of encrypting the password: 0 = one-time, 1 = email (default), 2 = gradidoID, ... | + +Um zu vermeiden, dass in Bezug auf das One-Time-Passwort und der anstehenden Migration der Passwort-Verschlüsselung ohne Email und stattdessen per GradidoID, es hier zu unnötigen Tabellen-Migrationen kommt, wird mit diesem Usecase die Spalte *password_encryption_type* eingeführt. Damit ist dann erkennbar, ob es sich bei dem gespeicherten Passwort um ein One-Time-Passwort handelt oder um ein anderweitig verschlüsseltes Passwort. + +Sollte das Issue zur Migration der Passwort-Verschlüsselung schon vor diesem Usecase umgesetzt sein, dann existiert in der `users`-Tabelle schon die Spalte `passphrase_encryption_type`. Dann sollte diese in `password_encryption_type` umbenannt und dem Enum der Wert 0 für One-Time-Passwort hinzugefügt werden. Die Bezeichnung *passphrase_encryption_type* ist irreführend, da in der Tabelle eine Spalte `passphrase `existiert. Doch die Verschlüsselung wird auf die Spalte `password `und nicht auf `passphrase` angewendet. + +#### Migration + +Mit den zuvor beschriebenen Datenbankänderungen muss eine Datenbankmigration auf die bestehenden Daten durchgeführt werden. Nachdem die strukturellen Änderungen wie neue `accounts`-Tabelle anlegen und bestehende `users`-Tabelle ändern durchgeführt wurde, erfolgt nun die eigentliche Migration der Daten: + +* erzeugen der neuen `accounts`-Tabelle wie oben beschrieben +* ändern der bestehenden `users`-Tabelle wie oben beschrieben mit folgenden Default-Initialisierungen + * privacy_policy_at = created_at + * passwort_encryption_type = Enum `PasswordEncryptionType.EMAIL` oder Wert=1 +* Insert pro Eintrag aus der `users`-Tabelle jeweils einen Eintrag in die `accounts`-Tabelle mit folgenden Initialisierungen: + * `accounts.user_id` = `users.id` + * `accounts.type` = Enum `AccountType.AGE` + * `accounts.created_at` = `users.created_at` + * `accounts.activated` = `users.emailContact.email_checked` + * `accounts.creations_allowed` = TRUE (weil es ein account type = AGE ist) + * `accounts.decay` = TRUE (weil es ein account type = AGE ist) + * `accounts.balance` = null (dieses Attribut wird in separatem Issue "Update Account-Balance during writing a Transaction" bedient) + * `account.updated_at` = null (dieses Attribut wird in separatem Issue "Update Account-Balance during writing a Transaction" bedient) + +### 2.2 Admin-Interface + +#### searchUsers + +Der Service *AdminResolver.searchUsers* muss die Filterkriterien "aktiviertes Konto" und "bestätigte Email" getrennt von einander unterstützen. Bisher gibt es in den *SearchUserFilters* das Filterkriterium "byActivated", doch dieses wird auf das Flag `email_checked` in der `user_contacts`-Tabelle angewendet. Das entspricht aber dann dem FilterKriterium "bestätigte Email". + +Somit muss das schon existierende Fitlerkriterium "aktiviertes Konto" auf die Spalte "`activated`" in der `accounts`-Tabelle angewendet werden und ein zusätzliches Filterkriterium "bestätigte Email", das auf die Spalte `email_checked` in der `user_contacts`-Tabelle filtert. + +Der ErgebnisTyp `SearchUsersResult `des Service *searchUsers* muss um die Informationen erweitert werden, die in dem oben aufgezeigten Detail-Dialog der *Nutzer-Suche* auf dem Reiter "Registrierung" zur Anzeige gebracht werden: + +* Zeitpunkt der Konto-Erstellung (`accounts.created_at`) +* Status des Kontos (`accounts.activated`) +* Status der Email-Bestätigung (`user_contacts.email_checked`) +* falls `users.password_encryption_type` = 0, dann das One-Time-Passwort (`users.password`) + +#### adminCreateUser + +Im *AdminResolver* muss aus Berechtigungsgründen ein neuer Service *adminCreateUser* erstellt werden, da im *UserResolver* der Service *createUser* für jeden offen ist, ohne dass eine vorherige Authentifizierung per Login stattgefunden hat. + +Dieser neue Service benötigt folgende Signatur als Eingabeparameter: + +| Argument | Type | Bezeichnung | +| --------------- | ------ | ------------------------------------- | +| vorname | String | der Vorname des neuen Users | +| nachname | String | der Nachname des neuen Users | +| email | String | die Email-Adresse des neuen Users | +| oneTimePassword | String | das One-Time-Passwort des neuen Users | + + Der neue Service entspricht der internen Logik weitestgehend dem exitierenden Service `UserResolver.create`. + +* prüfen ob Email schon existiert und wenn ja, dann an diese Email eine Info-Nachricht und Abruch mit Fehlermeldung +* neues User-Objekt initialisieren mit + * GradidoID + * Vorname + * Nachname + * One-Time-Passwort mit gleichzeitigem Setzen von `password_encryption_type` = Enum `PasswordEncryptionType.ONETIME` +* das neue User-Objekt speichern +* neues UserContact-Objekt initialisieren mit + * Email + * vorherige userID +* das neue UserContact-Objekt speichern +* die erhaltene ID des neuen UserContact-Eintrags in den vorher erzeugten User-Eintrag als `emailId` schreiben +* einen EventProtokoll-Eintrag schreiben vom Typ *EventAdminRegister*, der neu anzulegen ist und von `EventBasicUserId `abgeleitet wird, aber zusätzlich die *UserId* des Moderators in das Attribut `xUserId `einträgt. +* die Confirmation-Email zur Bestätigung der Email-Adresse verschicken +* alle fachlich sonst notwendigen Eventprotokolle schreiben + +Alle logischen Schritte bzgl. einer PublisherID oder eines Redeem-Links bleiben hier in diesem Service aussen vor. + +Als Rückgabe sind erst einmal keine weiteren fachlichen Daten geplant, ausser einem Boolean=TRUE für eine evtl. Erfolgsmeldung. Im Fehlerfall wird der Service mit einer Exception beendet. + +#### adminUpdateUser + +Im *AdminResolver* wird der neue Service *adminUpdateUser* eingeführt, um für einen schon existierenden User das One-Time-Passwort zu aktualisieren. Über die vorher durchgeführte Nutzer-Suche sind die aktuell gespeicherten Userdaten schon ermittelt worden. Damit ergibt sich als Signatur für diesen Service folgendes: + +| Argument | Typ | Beschreibung | +| -------- | ------ | -------------------------------------------------------- | +| userId | number | der technisch eindeutige Identifer des betroffenen Users | +| password | String | das geänderte One-Time-Passwort | + +Dieser Service führt mit der übergebenen *userId* ein update auf dem *User* aus. Dazu wird bei der Aktualisierung das Kriterium `passwort_encryption_type` = Enum `PasswordEncryptionType.ONETIME` sichergestellt und das Attribut `password `mit dem übergebenen Parameter *password* sowie das Flag `activated `= TRUE gesetzt. Abschließend erfolgt das Schreiben eines EventProtokoll-Eintrags vom Typ *EventAdminPasswortChange*, der neu anzulegen ist und von `EventBasicUserId `abgeleitet wird, aber zusätzlich die *UserId* des Moderators in das Attribut `xUserId `einträgt. + +Als Rückgabe sind erst einmal keine weiteren fachlichen Daten geplant, ausser einem Boolean=TRUE für eine evtl. Erfolgsmeldung. Im Fehlerfall wird der Service mit einer Exception beendet. + +### 2.3 User-Interface + +#### login + +Im *UserResolver* muss der Service *login* angepasst werden, um eine Anmeldung per One-Time-Passwort zu erlauben. + +Dabei wird zuerst per übergebener *email* der User aus der Datenbank ermittelt. Bevor die Prüfung auf das Flag `user.emailContact.email_checked` erfolgt, muss eine Prüfung auf das Attribut `user.password_encryption_type` durchgeführt werden. Ist die Passwort-Verschlüsselung dieses Users auf dem Wert `PasswordEncryptionType.ONETIME`, dann wird die Prüfung des Flags `user.emailContact.email_checked` übersprungen. + +Durch den Wert des Attributs `user.password_encryption_type` wird die Passwort-Entschlüsselungsart und Prüfung gesteuert. Beim Wert `PasswordEncryptionType.ONETIME` ist das Passwort selbst für die Anwendung kein Geheimnis, da dieses durch einen Moderator und nicht geheim durch den User eingegeben wurde und jederzeit durch einen Moderator im Klartext wieder angezeigt werden kann. + +Wenn zuvor es sich um ein Login per One-Time-Passwort handelte, dann erfolgt keine Überprüfung des EloPage-Status und Aktuallisierung der PublisherId. + +Mit erfolgreicher Beendigung des Login-Service wird der User mit seinen aktuellen Attributwerten zurückgeliefert. Dabei ist nun im Frontend sicherzustellen, dass wenn im User das Attribut `user.password_encryption_type` den Wert `PasswordEncryptionType.ONETIME` hat, dass dann mit Verlassen des Login-Dialogs der Anwender direkt nur auf die Passwort-Ändern-Seite geführt wird. Dem Einstieg in den Passwort-Ändern-Dialog muss aus dem Login-Dialog die Information mitgeteilt werden, dass es sich hier um ein One-Time-Passwort Login handelte, damit der Passwort-Ändern-Dialog die entsprechenden Änderungen in Bezug auf diesen UseCase durchführen kann. + +#### changePassword + +Im *UserResolver* ist der neue Service *changePassword* zu erstellen. Dieser bekommt als Eingabesignatur folgende Parameter: + +| Argument | Type | Bezeichnung | +| ------------- | ------- | ----------------------------------------------------------------------------------------- | +| userId | number | eindeutiger Identifier des Users, der zuvor beim Login an das Frontend übermittelt wurde | +| oldPassword | String | altes Passwort des angemeldeten Users | +| newPassword | String | neues Passwort des angemeldeten Users | +| privacyPolicy | boolean | optional: Flag zur Zustimmung der Datenschutzerklärung | + +Die übergebenen Parameter müssen im ersten Schritt auf ihre Validität untersucht werden. Das heißt sind alle mandatorischen Parameter übergeben und entsprechen ihre Werte den formellen Anforderungen (z.B. sind die Passworte gemäß den Passwort-Regeln). + +Mit der übergebenen *userId* wird der zugehörige User aus der Datenbank ermittelt. Bevor die eigentliche Logik zur Passwort-Änderung durchgeführt wird, erfolgt zuvor noch eine Prüfung auf den Userkonto-Status im Attribut `accounts.activated`. Nur wenn das Userkonto im Status *aktiviert* - per One-Time-Passwort durch den Moderator oder per Email-Confirmation durch den User selbst - ist, kann das Passwort geändert werden. + +In Abhängigkeit des im User gespeicherten Typs der Passwort-Verschlüsselung, muss das übergebene *oldPassword* gegen die im User gespeicherten Passwort-Daten überprüft werden: + +* *EncryptionType = One-Time-Passwort:* das im User gespeicherte Passwort muss dem übergebenen *oldPassword* entsprechen. Je nach technischer Implementierung könnte ggf. eine Entschlüsselungslogik auf das gespeicherte Passwort notwendig sein, da ein Passwort niemals im Klartext in der Datenbank gespeichert sein soll. Das heißt das im User gespeicherte Passwort muss nach einer evtl. Entschlüsselung mit dem übergebenen Passwort übereinstimmen. Ein One-Way-Hashing ist zwar sicherer aber reicht hier nicht, da das One-Time-Passwort im Admin-Interface im Klartext angezeigt werden können muss. +* *EncryptionType = EMAIL:* das im User gespeicherte Passwort wurde mit der im User gespeicherten Email als Salt verschlüsselt und als EmailHash gespeichert. Daher muss mit der im User gespeichert Email und dem übergebenen *oldPassword* ein EmailHash erzeugt werden, um diesen mit dem im User gespeicherten Emailhash zu vergleichen. +* *EncryptionType = GradidoID:* das im User gespeicherte Passwort wurde mit der im User gespeicherten *GradidoID* als Salt verschlüsselt und als EmailHash gespeichert. Daher muss mit der im User gespeicherten *GradidoID* und dem übergebenen *oldPassword* ein EmailHash erzeugt werden, um diesen mit dem im User gespeicherten EmailHash zu vergleichen. + +Ist die Überprüfung des alten Passwortes in Abhängigkeit des Verschlüsselungstyps erfolgreich, dann kann mit der Verschlüsselung des *newPassword* weiter gemacht werden. Als Verschlüsselungstyp wird immer der höchsten Wert des Verschlüsselungs-Enums eingesetzt, auch wenn zuvor ein niedrigerer Typ verwendet wurde. Damit erfolgt immer ein implizites Upgrade auf den aktuellsten Verschlüsselungstyp. Somit wird das übergebene *newPassword* mit der *GradidoID* des Users zu einem EmailHash verschlüsselt und zusammen mit dem Attribut `user.password_encryption_type` = `PasswordEncryptionType.GRADIDO_ID` in die Datenbank geschrieben. Je nach Wert des übergebenen Flags `privacyPolicy` wird im User das Attribut `privacy_policy_at` mit dem aktuellen Zeitpunkt ebenfalls aktualisiert. + +War die Passwort-Änderung erfolgreich wird ein boolean=TRUE zurückgegeben, ansonsten eine Exception mit aussagekräftiger Fehlermeldung. + +## Brainstorming von Bernd + +Damit wir ohne zusätzliche Technologie möglichst schnell und unkompliziert eine Lösung bekommen, dass wir neue User direkt vor Ort registrieren können, schlage ich folgende zwei Funktionen im Admin-Bereich vor: + +1. Manuell bestätigen und (vorläufiges) Passwort setzen +2. Neuen User registrieren + +### Usecase + +Bei einer Veranstaltung o.ä. sollen neue Mitglieder geworben werden. Dabei ist ungewiss, ob sie ein Endgerät dabei haben bzw. dieses korrekt bedienen können (QR-Code, E-Mail-Zugang etc.) + +#### Lösung: + +Bei der Veranstaltung ist ein Moderator vor Ort, oder der Veranstalter bekommt vorübergehend Moderatoren-Rechte. + +Der Moderator hat auf einem Browser sein Gradido-Konto (Admin-Interface) laufen. Auf einem anderen Browser (oder einem anderen Gerät) können sich ggf. User einloggen. + +##### Variante 1: + +Der Interessent registriert sich über Link/QR-Code, hat aber keinen Zugang zu seinen E-Mails. Der Moderator bestätigt ihn und gibt ihm ein vorläufiges Passwort (oder lässt den User im Backend selbst ein Passwort eintippen). + +##### Variante 2: + +Der Moderator registriert den Interessenten und gibt ihm ein vorläufiges Passwort (oder lässt den User im Backend selbst ein Passwort eintippen). + +Das vorläufige Passwort kann so lange vom Moderator geändert werden, bis der User über die Mail sein Passwort neu gesetzt hat. Dadurch wird erreicht, dass der Moderator den User so lange unterstützen kann (z.B. wenn er sein PW vergessen hat), bis er Mail-Zugang hat und sein Passwort selbst setzen kann. + +##### Weitere Anwendungsfälle: + +Wenn eine (zukünftige) Community beschließt, dass neue Mitglieder nur durch persönliche Einladung aufgenommen werden. Für diesen Fall müsste dann noch die User-Registrierung abgeschaltet werden können. diff --git a/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md b/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md index ab04efc7f..ac982fa53 100644 --- a/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md +++ b/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md @@ -2,6 +2,18 @@ Die Idee besteht darin, dass ein Administrator eine Contribution mit all seinen Attributen und Regeln im System erfasst. Dabei kann er unter anderem festlegen, ob für diese ein Link oder ein QR-Code generiert und über andere Medien wie Email oder Messenger versendet werden kann. Der Empfänger kann diesen Link bzw QR-Code dann über die Gradido-Anwendung einlösen und bekommt dann den Betrag der Contribution als Schöpfung auf seinem Konto gutgeschrieben. +## Ausbaustufen + +Die beschriebenen Anforderungen werden in mehrere Ausbaustufen eingeteilt. Damit können nach und nach die Dialoge und Businesslogik schrittweise in verschiedene Releases gegossen und ausgeliefert werden. + +### Ausbaustufe 1 + +Diese Ausbaustufe wird gezielt für die "Dokumenta" im Juni 2022 zusammengestellt. Details siehe weiter unten im speziellen Kapitel "Ausbaustufe 1". + +### Ausbaustufe 2 + +Diese Ausbaustufe wird gezielt für die Anforderungen für das Medidationsportal von "Abraham" zusammegestellt. Details siehe weiter unten im speziellen Kapitel "Ausbaustufe 2". + ## Logischer Ablauf Der logische Ablauf für das Szenario "Activity-Confirmation and booking of Creations " wird in der nachfolgenden Grafik dargestellt. Dabei wird links das Szenario der "interactive Confirmation and booking of Creations" und rechts "automatic Confirmation and booking of Creations" dargestellt. Ziel dieser Grafik ist neben der logischen Ablaufsübersicht auch die Gemeinsamkeiten und Unterschiede der beiden Szenarien herauszuarbeiten. @@ -28,11 +40,11 @@ Der Gültigkeitsstart wird als Default mit dem aktuellen Erfassungszeitpunkt vor Wie häufig ein User für diese Contribution eine Schöpfung gutgeschrieben bekommen kann, wird über die Auswahl eines Zyklus - stündlich, 2-stündlich, 4-stündlich, etc. - und innerhalb dieses Zyklus eine Anzahl an Wiederholungen definiert. Voreinstellung sind 1x täglich. -![Zyklus](./image/UC_Send_Contribution_Admin-new ContributionZyklus.png) +![img](./image/UC_Send_Contribution_Admin-new_ContributionZyklus.png) Ob die Contribution über einen versendeten Link bzw. QR-Code geschöpft werden kann, wird mittels der Auswahl "Versenden möglich als" bestimmt. -![send](./image/UC_Send_Contribution_Admin-new ContributionSend.png) +![img](./image/UC_Send_Contribution_Admin-new_ContributionSend.png) Für die Schöpfung der Contribution können weitere Regeln definiert werden: @@ -44,11 +56,11 @@ Für die Schöpfung der Contribution können weitere Regeln definiert werden: ![new](./image/UC_Send_Contribution_Admin-newContribution.png) -### Ausbaustufe-1: +## Ausbaustufe-1: -Die Ausbaustufe-1 wird gezielt auf die Anforderungen der "Dokumenta" im Juni 2022 abgestimmt. +Die Ausbaustufe-1 wird gezielt auf die Anforderungen der "Dokumenta" im Juni 2022 abgestimmt. -#### Contribution-Erfassungsdialog (Adminbereich) +### Contribution-Erfassungsdialog (Adminbereich) Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gestellt: @@ -64,14 +76,12 @@ Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gest | VersendenMöglich | - hier wird "als Link / QR-Code" voreingestellt | | alle weiteren Attribute | - entfallen für diese Ausbaustufe
- die GUI-Komponenten können optional schon im Dialog eingebaut und angezeigt werden
- diese GUI-Komponenten müssen wenn sichtbar disabled sein und dürfen damit keine Eingaben entgegen nehmen | - -#### Ablauflogik +### Ablauflogik Für die Ausbaustufe-1 wird gemäß der Beschreibung aus dem Kapitel "Logischer Ablauf" nur die "automatic Confirmation and booking of Creations" umgesetzt. Die interaktive Variante - sprich Ablösung des EloPage Prozesses - mit "interactive Confirmation and booking of Creations" bleibt für eine spätere Ausbaustufe aussen vor. Das Regelwerk in der Businesslogik wird gemäß der reduzierten Contribution-Attribute aus dem Erfassungsdialog, den vordefinierten Initialwerten und der daraus resultierenden Variantenvielfalt vereinfacht. - #### Kriterien "Dokumenta" * Es soll eine "Dokumenta"-Contribution im Admin-Bereich erfassbar sein und in der Datenbank als ContributionLink gespeichert werden. @@ -91,6 +101,66 @@ Das Regelwerk in der Businesslogik wird gemäß der reduzierten Contribution-Att * es erfolgt eine übliche Schöpfungstransaktion nach der Bestätigung der Contribution * die Schöpfungstransaktion schreibt den Betrag der Contribution dem Kontostand des Users gut +## Ausbaustufe-2 + +Die Ausbaustufe-2 wird gezielt auf die Anforderungen zur Anbindung des Meditationsportals von Abraham im Oktober 2022 abgestimmt. + +### Contribution-Erfassungsdialog (Adminbereich) + +Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gestellt: + +| Attribut | Beschreibung | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GültigBis | - das Datum, wie lange die Contribution gültig und damit einlösbar ist
- für diese Ausbaustufe soll ein offenes Ende möglich sein, daher bleibt dieses Attribut leer | +| Zyklus | - Angabe wie häufig eine Contribution gutgeschrieben werden kann
- als Auswahlliste (Combobox) geplant, aber für diese Ausbaustufe nur mit dem Wert "täglich" vorbelegt | +| Wiederholungen | - Anzahl an Wiederholungen pro Zyklus
- für diese Ausbaustufe wird der Wert "1" vorbelegt -> somit gilt: ein User kann diese Contribution nur 1x täglich einlösen | +| alle weiteren noch nicht vorhandenen Attribute | - entfallen für diese Ausbaustufe
- die GUI-Komponenten können optional schon im Dialog eingebaut und angezeigt werden
- diese GUI-Komponenten müssen wenn sichtbar disabled sein und dürfen damit keine Eingaben entgegen nehmen | + +### Ablauflogik + +Für die Ausbaustufe-2 und der inzwischen umgesetzten Ablösung des "EloPage Contribution Erfassungsprozesses" wird gemäß der Beschreibung aus dem Kapitel "Logischer Ablauf" die "automatic Confirmation and booking of Creations" sowie die interaktive Variante "interactive Confirmation and booking of Creations" mit berücksichtigt. + +Das Regelwerk in der Businesslogik wird gemäß der noch nicht vollumfänglich geplanten Contribution-Attribute aus dem Erfassungsdialog, den vordefinierten Initialwerten und der daraus resultierenden Variantenvielfalt vereinfacht. + +#### Kriterien "Meditationsportal (Abraham)" + +* Es soll eine "GlobalMeditation"-Contribution nur im Admin-Bereich erfassbar sein und in der Datenbank als ContributionLink gespeichert werden. +* Es wird ein offenes Ende als Gesamtlaufzeit dieser Contribution benötigt, was durch ein leeres GültigBis-Datum ausgedrückt bzw. erfasst werden soll. +* Die "Meditationsportal"-Contribution kann von einem User maximal 1x täglich aktiviert werden. Dies wird über die Erfassung des Attributes "Zyklus" = täglich und des Attributes "Wiederholungen" = 1 ermöglicht. +* Ein User kann mit diesem Link nur die Menge an GDDs schöpfen, die in der Contribution als "Betrag" festgelegt ist +* Die "GlobalMeditation"-Contribution kann als Link / QR-Code erzeugt, angezeigt und in die Zwischenablage kopiert werden +* Jeder beliebige User kann den Link / QR-Code aktivieren +* der Link führt auf eine Gradido-Seite, wo der User sich anmelden oder registrieren kann +* mit erfolgreichem Login bzw. Registrierung wird der automatische Bestätigungs- und Schöpfungsprozess getriggert +* es erfolgt eine Überprüfung der definierten Contribution-Regeln für den angemeldeten User: + * Gültigkeit: liegt die Aktivierung im Gültigkeitszeitraum der Contribution + * Zyklus und WIederholungen: bei einem Zyklus-Wert = "täglich" und einem Wiederholungswert = 1 darf der User den Betrag dieser Contribution nur einmal am Tag schöpfen. Es gibt keine Überprüfung eines zeitlichen Mindestabstandes zwischen zwei Schöpfungen an zwei aufeinanderfolgenden Tagen. + * max. schöpfbarer Gradido-Betrag pro Monat: wenn der Betrag der Contribution plus der Betrag, den der User in diesem Monat schon geschöpft hat, den maximal schöpfbaren Betrag pro Monat von 1000 GDD übersteigt, dann wird die Schöpfung dieser Contribution abgelehnt +* mit erfolgreich durchlaufenen Regelprüfungen wird ein "besätigter" aber "noch nicht gebuchten" Eintrag in der "Contributions"-Tabelle erzeugt +* ein "bestätigter" aber "noch nicht gebuchter" "Contributions"-Eintrag stößt eine Schöpfungstransaktion für den User an +* es erfolgt eine übliche Schöpfungstransaktion mit automatischer Bestätigung der Contribution +* die Schöpfungstransaktion schreibt den Betrag der Contribution dem Kontostand des Users gut + +## Ausbaustufe-3 + +### Änderungen im Registrierungsprozess + +Aktuell treten Probleme mit der Aktivierung des ContributionLinks während des Registrierungsprozesses auf. Sobald der User bei der Registrierung sein Konto zwar angelegt, aber die erhaltene Email-Confirmation nicht abgeschlossen und damit sein Konto noch nicht aktiviert hat, kann derzeit der Redeem-Link nicht als Transaktion durchgeführt werden. Die Gültigkeitsdauer des Redeemlink reicht meist nicht bis der User sein Konto aktiviert. Daher wird nun die Idee verfolgt die Einlösung des Redeemlinks schon während der Anlage des inaktiven Kontos als "pendingRedeem Contribution" anzulegen. Sobald dann der User sein Konto per Email-Confirmation aktiviert, soll die "pendingRedeem Contribution" automatisch zu einer Tranaktion überführt und der Betrag des Redeemlinks auf das Konto des Users gebucht werden. + +Folgende Schritte und Änderungen sind dabei vorgesehen (siehe in der Grafik rechts im orange markierten Bereich im Vergleich zur Grafik im Kapitel "Logischer Ablauf"): + +![img](./image/Ablauf_manuelle_auto_Creations_2.png) + +* Der User landet mit Aktivierung eines Redeem-Links wie bisher auf der Login/Registrierungsseite, wobei wie bisher schon der Redeemlink als Parameter in den Registrierungsprozess übergeben wird. +* Mit der Anlage des neuen aber noch inaktiven User-Kontos und einer Übergabe eines Redeemlinks wird der Redeemlink zu einer "pendingRedeem Contribution" für den neuen User angelegt, aber noch nicht als Transaktion gebucht + * nach Anlage des inaktiven User-Kontos und bevor die Confirmation-Email abgeschickt wird, erfolgt das Schreiben eines neuen Contribution-Eintrages mit den Daten des Redeem-Links. + * Die neu angelegte Contribution wird im Status "pendingRedeem" gespeichert. Dieser neue Status ist notwendig, um im AdminInterface die normalen "pending Contributions" von den "pendingRedeem Contributions" zu unterscheiden. Denn der Admin soll zum Einen diese "pendingRedeem Contributions" weder bestätigen noch ablehnen können und zum Anderen sollen die "pendingRedeem Contributions" automatisiert bestätigt und gebucht werden können. Daher wird eine Unterscheidung zwischen den interaktiv angelegten Contributions im Status pending und den per Redeem-Link angelegten Contributions im Status pending benötigt. + * Damit endet erst einmal die weitere Verarbeitung der Redeem-Link-Aktivierung +* Mit Aktivierung des Links in der Email-Confirmation und damit der Aktivierung des User-Kontos erfolgt automatisch die Buchung der "pendingRedeem Contribution" und führt damit zur eigentlichen Buchung des Redeem-Betrages auf das User Konto. + * mit Erhalt der Email-Confirmation Aktivierung wird das User-Konto aktiviert + * Nach der Aktivierung des User-Kontos erfolgt eine Prüfung auf schon vorhandene "pendingRedeem Contributions" aus vorherigen Redeem-Link-Aktivierungen + * Jede vorhandene "pendingRedeem Contribution" wird jetzt automatisch bestätigt und zu einer Transaktion überführt + * Mit der bestätigten Contribution und daraus überführten Transaktion erhält der User eine Bestätigungsemail mit den Contribution spezifischen Daten. ## Datenbank-Modell @@ -100,34 +170,36 @@ Das nachfolgende Bild zeigt das Datenmodell vor der Einführung und Migration au ![Datenbankmodell](./image/DB-Diagramm_20220518.png) -### Datenbank-Änderungen +### Ausbaustufe-1 + +#### Datenbank-Änderungen Die Datenbank wird in ihrer vollständigen Ausprägung trotz Ausbaustufe-1 wie folgt beschrieben umgesetzt. -#### neue Tabellen +##### neue Tabellen -##### contribution_links - Tabelle +###### contribution_links - Tabelle | Name | Typ | Nullable | Default | Kommentar | | ------------------------------- | ------------ | :------: | :------------: | -------------------------------------------------------------------------------------------------------------------------------------- | | id | INT UNSIGNED | NOT NULL | auto increment | PrimaryKey | -| name | varchar(100) | NOT NULL | | unique Name | -| description | varchar(255) | | | | +| name | varchar(100) | NOT NULL | | unique Name | +| description | varchar(255) | | | | | valid_from | DATETIME | NOT NULL | NOW | | -| valid_to | DATETIME | | NULL | | -| amount | DECIMAL | NOT NULL | | | +| valid_to | DATETIME | | NULL | | +| amount | DECIMAL | NOT NULL | | | | cycle | ENUM | NOT NULL | ONCE | ONCE, HOUR, 2HOUR, 4HOUR, 8HOUR, HALFDAY, DAY, 2DAYS, 3DAYS, 4DAYS, 5DAYS, 6DAYS, WEEK, 2WEEKS, MONTH, 2MONTH, QUARTER, HALFYEAR, YEAR | | max_per_cycle | INT UNSIGNED | NOT NULL | 1 | | -| max_amount_per_month | DECIMAL | | NULL | | -| total_max_count_of_contribution | INT UNSIGNED | | NULL | | -| max_account_balance | DECIMAL | | NULL | | -| min_gap_hours | INT UNSIGNED | | NULL | | -| created_at | DATETIME | | NOW | | -| deleted_at | DATETIMEBOOL | | NULL | | -| code | varchar(24) | | NULL | | -| link_enabled | BOOL | | NULL | | +| max_amount_per_month | DECIMAL | | NULL | | +| total_max_count_of_contribution | INT UNSIGNED | | NULL | | +| max_account_balance | DECIMAL | | NULL | | +| min_gap_hours | INT UNSIGNED | | NULL | | +| created_at | DATETIME | | NOW | | +| deleted_at | DATETIMEBOOL | | NULL | | +| code | varchar(24) | | NULL | | +| link_enabled | BOOL | | NULL | | -##### contributions -Tabelle +###### contributions -Tabelle | Name | Typ | Nullable | Default | Kommentar | | --------------------- | ------------ | -------- | -------------- | -------------------------------------------------------------------------------- | @@ -145,9 +217,9 @@ Die Datenbank wird in ihrer vollständigen Ausprägung trotz Ausbaustufe-1 wie f | booked_at | DATETIME | | NULL | date, when the system has booked the amount of the activity on the users account | | deleted_at | DATETIME | | NULL | soft delete | -#### zu migrierende Tabellen +##### zu migrierende Tabellen -##### Tabelle admin_pending_creations +###### Tabelle admin_pending_creations Diese Tabelle wird im Rahmen dieses UseCase migriert in die neue Tabelle contributions... @@ -168,6 +240,18 @@ Diese Tabelle wird im Rahmen dieses UseCase migriert in die neue Tabelle contrib ...und kann nach Übernahme der Daten in die neue Tabelle gelöscht werden oder es erfolgen die Änderungen sofort auf der Ursprungstabelle. -### Zielmodell +#### Zielmodell ![Contributions-DB](./image/DB-Diagramm_Contributions.png) + +### Ausbaustufe-2 + +Für die Ausbaustufe-2 sind keine Datenbank-Änderungen notwendig. Gemäß dem Zielmodell sind alle notwendigen Tabellen und Attribute schon vorhanden. + +#### Zielmodell + +![img](./image/DB-Diagramm_Contributions_Stufe_2.png) + +### Ausbaustufe-3 + +Für die Ausbaustufe-3 dürften im Grunde ebenfalls keine zusätzlichen Datenbankänderungen notwendig sein. Denn für eine "pending Contribution" und deren Confirmation mit Tranaktionsüberführng sind ebenfalls schon alle Attribute vorhanden. diff --git a/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio b/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio index b4f1f45ea..3c2ae0559 100644 --- a/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio +++ b/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio @@ -1,6 +1,6 @@ - + @@ -183,31 +183,31 @@ - + - + - + - + - + - + @@ -317,15 +317,15 @@ - + - + - + @@ -334,10 +334,10 @@ - + - + @@ -351,7 +351,7 @@ - + @@ -359,8 +359,76 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr b/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr index 94c259f9c..86ffadba4 100644 Binary files a/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr and b/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr differ diff --git a/docu/Concepts/BusinessRequirements/graphics/UC_ManuelleUserRegistrierung.bmpr b/docu/Concepts/BusinessRequirements/graphics/UC_ManuelleUserRegistrierung.bmpr new file mode 100644 index 000000000..885ce6762 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/graphics/UC_ManuelleUserRegistrierung.bmpr differ diff --git a/docu/Concepts/BusinessRequirements/image/Ablauf_manuelle_auto_Creations_2.png b/docu/Concepts/BusinessRequirements/image/Ablauf_manuelle_auto_Creations_2.png new file mode 100644 index 000000000..4211f65cf Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Ablauf_manuelle_auto_Creations_2.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-CreateUser.png b/docu/Concepts/BusinessRequirements/image/Admin-CreateUser.png new file mode 100644 index 000000000..5b15b27e2 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-CreateUser.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-ActivatedOneTimePasswort.png b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-ActivatedOneTimePasswort.png new file mode 100644 index 000000000..1f6ad0b73 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-ActivatedOneTimePasswort.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-Details.png b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-Details.png new file mode 100644 index 000000000..ce2cd2ede Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-Details.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserSearch.png b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch.png new file mode 100644 index 000000000..3d59dd7cb Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserSearch_inaktivAccount.png b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch_inaktivAccount.png new file mode 100644 index 000000000..e8ddb5f36 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch_inaktivAccount.png differ diff --git a/docu/Concepts/BusinessRequirements/image/DB-Diagramm_Contributions_Stufe_2.png b/docu/Concepts/BusinessRequirements/image/DB-Diagramm_Contributions_Stufe_2.png new file mode 100644 index 000000000..eee7fa23f Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/DB-Diagramm_Contributions_Stufe_2.png differ diff --git a/docu/Concepts/BusinessRequirements/image/One-Time-Passwort-Login.png b/docu/Concepts/BusinessRequirements/image/One-Time-Passwort-Login.png new file mode 100644 index 000000000..3e275db94 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/One-Time-Passwort-Login.png differ diff --git a/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionSend.png b/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionSend.png similarity index 100% rename from docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionSend.png rename to docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionSend.png diff --git a/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionZyklus.png b/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionZyklus.png similarity index 100% rename from docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionZyklus.png rename to docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionZyklus.png diff --git a/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md b/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md index 9d607ba97..a6ca83bfc 100644 --- a/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md +++ b/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md @@ -10,30 +10,27 @@ Additionally the Gradido-ID allows to administrade any user account data like ch The formalized definition of the Gradido-ID can be found in the document [BenutzerVerwaltung#Gradido-ID](../BusinessRequirements/BenutzerVerwaltung#Gradido-ID). -## Steps of Introduction +## 1st Stage -To Introduce the Gradido-ID there are several steps necessary. The first step is to define a proper database schema with additional columns and tables followed by data migration steps to add or initialize the new columns and tables by keeping valid data at all. +The 1st stage of introducing the Gradido-ID contains several steps. The first step is to define a proper database schema with additional columns and tables followed by data migration steps to add or initialize the new columns and tables by keeping valid data at all. -The second step is to decribe all concerning business logic processes, which have to be adapted by introducing the Gradido-ID. +The second step is to decribe all concerning business logic processes, which have to be adapted by introducing the Gradido-ID and handling the attributes of the new user_contacts table. ### Database-Schema #### Users-Table -The entity users has to be changed by adding the following columns. +The entity users has to be changed by adding the following columns. The column State gives a hint about the working state including the ticket number. -| Column | Type | Description | -| ------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------- | -| gradidoID | String | technical unique key of the user as UUID (version 4) | -| alias | String | a business unique key of the user | -| passphraseEncryptionType | int | defines the type of encrypting the passphrase: 1 = email (default), 2 = gradidoID, ... | -| emailID | int | technical foreign key to the entry with type Email and contactChannel=maincontact of the new entity UserContacts | +| State | Column | Type | Description | +| -------------- | --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| *done #2125* | gradidoID | String | technical unique key of the user as UUID (version 4) | +| *done #2125* | alias | String | a business unique key of the user | +| *done #2165* | emailID | int | technical foreign key to the UserContacts-Table with the entry of type Email, which will be interpreted as the maincontact from the Users table point of view | ##### Email vs emailID -The existing column `email`, will now be changed to the primary email contact, which will be stored as a contact entry in the new `UserContacts` table. It is necessary to decide if the content of the `email `will be changed to the foreign key `emailID `to the contact entry with the email address or if the email itself will be kept as a denormalized and duplicate value in the `users `table. - -The preferred and proper solution will be to add a new column `Users.emailId `as foreign key to the `UsersContact `entry and delete the `Users.email` column after the migration of the email address in the `UsersContact `table. +The existing column `email`, will now be changed to the primary email contact, which will be stored as a contact entry in the new `UserContacts` table. #### new UserContacts-Table @@ -55,17 +52,21 @@ A new entity `UserContacts `is introduced to store several contacts of different | phone | String | defines the address of a contact entry of type Phone | | contactChannels | String | define the contact channel as comma separated list for which this entry is confirmed by the user e.g. main contact (default), infomail, contracting, advertisings, ... | +##### ToDo: + +The UserContacts, expecially the email contacts, will for future be categorized to communication channels for example to allow the user to define which information he will get on which email-contact (aspects of administration, contract, advertising, etc.) + ### Database-Migration After the adaption of the database schema and to keep valid consistent data, there must be several steps of data migration to initialize the new and changed columns and tables. -#### Initialize GradidoID +#### Initialize GradidoID (done #2125) In a one-time migration create for each entry of the `Users `tabel an unique UUID (version4). -#### Primary Email Contact +#### Primary Email Contact (done #1798) -In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email`, select from the table `login_email_opt_in` the entry with the `login_email_opt_in.user_id` = `Users.id` and create a new entry in the `UsersContact `table, by initializing the contact-values with: +In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email` and create for it a new entry in the `UserContacts `table, by initializing the contact-values with: * id = new technical key * type = Enum-Email @@ -88,72 +89,130 @@ After this one-time migration and a verification, which ensures that all data ar The following logic or business processes has to be adapted for introducing the Gradido-ID -#### Read-Write Access of Users-Table especially Email +#### Capturing of alias + +To avoid using the email as primary identifier it is necessary to introduce a capturing of the alias. It is not a good solution to create for existing users an individual alias by a migration. So each user should capture his own alias during registration- and/or login-process. + +These requirements are described in the concept document [../BusinessRequirements/UC_Set_UserAlias.md]() **(done #2144)** and the implementation of these requirements will be the prerequisite for changing the login-process from single email-identifier to the future identifiers alias / gradidoID / email. + +#### Read-Write Access of Users-Table especially Email (done #1798) The ORM mapping has to be adapted to the changed and new database schema. -#### Registration Process +#### Create and Update User Processes -The logic of the registration process has to be adapted by +The logic of the create and update user process has to be adapted by -* initializing the `Users.userID` with a unique UUID -* creating a new `UsersContact `entry with the given email address and *maincontact* as `usedChannel ` -* set `emailID `in the `Users `table as foreign key to the new `UsersContact `entry -* set `Users.passphraseEncrpytionType = 2` and encrypt the passphrase with the `Users.userID` instead of the `UsersContact.email` +* creating a new User including with a unique UUID-V4 **(done #2125)** +* creating a new `UserContacts `entry with the given email address **(#2165)** +* set `emailID `in the `Users `table as foreign key to the new `UserContacts `entry **(#2165)** +* handling the new emailXXX attributes in the `user_contacts `table previously in the `email_opt_in `table **(#2165)** -#### Login Process +#### Search User Processes (#2165) -The logic of the login process has to be adapted by +The logic of all processes where the user is searched has to be adapted by -* search the users data by reading the `Users `and the `UsersContact` table with the email (or alias as soon as the user can maintain his profil with an alias) as input -* depending on the `Users.passphraseEncryptionType` decrypt the stored password - * = 1 : with the email - * = 2 : with the userID +* always search a *user* with its relation "emailContact" to load the associated userContact with his email +* a search user by *email* has to be implemented by searching a `userContact `for the given *email* and its relation "user" to load the associated user to this email + +#### Password Processes (#2165) + +The logic of all password processes has to be adapted by + +* read the *emailXXX* attributes out of the `user_contacts `table instead of previoulsy from the `email_opt_in `table +* writing or updating the *emailXXX* attributes now in the `user_contact `table instead of previously in the `email_opt_in `table +* the logic how to de/encrypt the password will not part of this 1st stage of introduction of the gradidoID. This will be part of the 2nd stage + +## 2nd Stage + +In the 2nd stage of this topic the password handling during registration and login process will be changed. These change must keep the current active password handling where the email is part of the encryption as long as all users are shifted to the new logic of password handling where the gradidoID will part of the encryption. This means there must be a kind of versioning which type of password encryption is used. Because some users will not login for a long time, which causes to use the old password encryption at their login process or in the future there could be the requirement to change the password handling to newer and safer algorithms. + +### Database-Schema + +#### Users-Table + +The entity *users* has to be changed by + +| Action | Column | Type | Description | +| :----: | ---------------------- | ---------- | ----------------------------------------------------------------------------------- | +| add | passwordEncryptionType | int | defines the type of encrypting the password: default 1 = email, 2 = gradidoID, ... | +| delete | public_key | binary(32) | before deletion verify and ensure that realy not in use even for encryption type 1 | +| delete | privkey | binary(80) | before deletion verify and ensure that realy not in use even for encryption type 1 | +| delete | email_hash | binary(32) | before deletion verify and ensure that realy not in use even for encryption type 1 | +| delete | passphrase | text | before deletion verify and ensure that realy not in use even for encryption type 1 | + +### Adaption of BusinessLogic #### Password En/Decryption -The logic of the password en/decryption has to be adapted by encapsulate the logic to be controlled with an input parameter. The input parameter can be the email or the userID. +The logic of the existing password en/decryption has to be shifted out of the ***UserResolver.js*** file in separated file(s). This separated file will be placed in the package-directory `backend/src/password` and named ***emailEncryptor.js***. As the name express the password encryption uses the `email `attribute. + +For the new password encryption logic a new file named ***gradidoIDEncryptor.js*** has to be created in the package-directory `backend/src/password`, which uses the *gradidoID* instead of the *email* for the password encryption. As soon as a user is changed to this encryption type with the *gradidoID*, it will be possible for him to change his *email* in his gradido-profile without any effect on his password encryption. + +For possible future requirements of newer and safer encryption logic additional files can be placed in the same directory with an expressiv file name for the new encryption type. + +All these `xxxEncryptor `files has to implement the following API, but with possibly different parameter types, depending on the encryption requirements: + +| API | emailEncryptor | gradidoIDEncryptor | return | description | +| ------------------------- | ---------------- | ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **encryptPassword** | dbUser, password | dbUser, password | encrypted password | process the encryption with
the encryptor specific attributs
out of the dbUser and the original 
password entered by the user | +| **verifyPassword** | dbUser, password | dbUser, password | boolean | process the decryption with
the encryptor specific attributs
out of the dbUser and the original
password entrered by the user | +| **isPassword** | password | password | boolean | verifiy the formal rules of the original
password entered by the user | + +Which of the *xxxEncryptor* implementations will be used, depends on the value of the attribute `user.passwordEncryptionType`, which has to be interpreted before. To encapsulate this logic from the general business logic the ***Encryptor.js*** will be created with the same API as the specific *encryptor* classes, but it will interpret the attribute `dbUser.passwordEncryptionType` to select and invoke the correct *encryptor* implementation and to decide if an upgrade to a newer *encryptor* class should be done. + +The new Enum `PasswordEncryptionType `with the increasing values: + +* 1 = emailEncryptor +* 2 = gradidoIDEncryptor +* ... = ? + +will be used to define the order, which encryptor implementation is the oldest and the newest. That means if a user is still not using the newest *encryptor* for his password encryption the logic will implicit start a change to the newest *encryptor*. In all business processes, where the user enters his password the invokation of the ***Encryptor.js*** has to be introduced, because without the original entered password from the user no *encryptor* upgrade can be done. + +#### Registration Process + +The backend logic of the registration process has to be adapted + +* the ***UserResolver.createUser*** logic has to be changed by setting for a new user the attribut `Users.passwordEncrpytionType = 2` +* As soon as the user activates the email-confirmation link `https://gradido.net/checkEmail/` the application frontend invokes + + * at first the ***UserResolver.queryOptIn*** method, which will not be necessary, because the same checks about the given *emailOptIn*-code will be done a 2nd time in the invocation of *UserResolver.setPassword* + * at second the ***UserResolver.setPassword*** method, which has to be changed + * to use the new ***Encryptor.isPassword*** to validate the formal rules of the given password + * to remove all cryptographic logic like passphrase and key pair generation and password hashing to the new ***emailEncryptor.js*** + * to introduce the invocation of the new ***Encryptor.encryptPassword*** in the existing logic flow + +#### Login Process + +The logic of the login process has to be adapted in frontend and backend + +* Frontend + * The login dialog has to be changed at the email input component + * the new label contains "Email / Alias / GradidoID" + * the validation of the input field has to be changed to accept the input of one of these three possible values + * in case of failed validation an expressiv error message for the specific given input has to be shown (for more details about the rules for alias and gradidoID see the concepts [UC_SetUserAlias.md](../BusinessRequirements/UC_SetUserAlias.md) and [BenutzerVerwaltung#Gradido-ID](../BusinessRequirements/BenutzerVerwaltung#Gradido-ID)). + * The signature of the backend invocation ***UserResolver.login*** has to be changed to accept all three variants of identifiers + * depending on the implemented backend solution the frontend has to detect and initialize the correct parameter settings +* Backend + * The signature of the backend invocation ***UserResolver.login*** has to be changed to accept all three variants of identifiers + * solution-A: the first parameter *email* is renamed to *identifier* and the backend has to detect which type of identifier is given + * solution-B: two additional parameters *alias* and *gradidoID* are inserted in the type ***UnsecureLoginArgs*** and the frontend has to decide, which type of identifier is given and initialize the correct parameter + * **TODO**: solution-A is preferred? + * The logic of ***UserResolver.login*** has to be changed by + * in case of solution-A for the signature, the given identifier has to be detected for the correct user searching + * the user to be searched by the given identifier (email / alias / gradidoID) + * if a user could be found all the existing checks will be done as is, except the public and private key check, which will be removed + * for the password check the new ***Encryptor.isPassword*** and ***Encryptor.verifyPassword*** has to be invoked; all existing cryptographic logic has to be deleted #### Change Password Process -The logic of change password has to be adapted by +There are two ways to change a user password. -* if the `Users.passphraseEncryptionType` = 1, then +The first one is the *Forget-Password process*, which will use the same backend invocation with activating the email link like the *Registration Process* to set the password; for details see description above. - * read the users email address from the `UsersContact `table - * give the email address as input for the password decryption of the existing password - * use the `Users.userID` as input for the password encryption for the new password - * change the `Users.passphraseEnrycptionType` to the new value =2 -* if the `Users.passphraseEncryptionType` = 2, then +The second one is the *Update-Userinfo process*, which invokes the ***UserResolver.updateUserInfos***. This method has to be changed in the *password check block* by - * give the `Users.userID` as input for the password decryption of the existing password - * use the `Users.userID` as input for the password encryption fo the new password - -#### Search- and Access Logic - -A new logic has to be introduced to search the user identity per different input values. That means searching the user data must be possible by - -* searching per email (only with maincontact as contactchannel) -* searching per userID -* searching per alias - -#### Identity-Mapping - -A new mapping logic will be necessary to allow using unmigrated APIs like GDT-servers api. So it must be possible to give this identity-mapping logic the following input to get the respective output: - -* email -> userID -* email -> gradidoID -* email -> alias -* userID -> gradidoID -* userID -> email -* userID -> alias -* alias -> gradidoID -* alias -> email -* alias -> userID -* gradidoID -> email -* gradidoID -> userID -* gradidoID -> alias - -#### GDT-Access - -To use the GDT-servers api the used identifier for GDT has to be switch from email to userID. +* removing all the cryptographic logic and +* invoke the new ***Encryptor.isPassword*** for the given *newPassword* and if valid then +* invoke the new ***Encryptor.verifyPassword*** for the given *oldPassword* and if valid then +* invoke the new ***Encryptor.encryptPassword*** for the given *newPassword* diff --git a/e2e-tests/cypress/tests/cypress.config.ts b/e2e-tests/cypress/tests/cypress.config.ts index ad6a8d7de..9621b7a00 100644 --- a/e2e-tests/cypress/tests/cypress.config.ts +++ b/e2e-tests/cypress/tests/cypress.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ supportFile: "cypress/support/index.ts", viewportHeight: 720, viewportWidth: 1280, + video: false, retries: { runMode: 2, openMode: 0, diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a9d7572f2..a93199fad 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -20,10 +20,10 @@ ENV PORT="3000" # Labels LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.name="gradido:frontend" -LABEL org.label-schema.description="Gradido Vue Webwallet" -LABEL org.label-schema.usage="https://github.com/gradido/gradido_vue_wallet/blob/master/README.md" +LABEL org.label-schema.description="Gradido Wallet Interface" +LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md" LABEL org.label-schema.url="https://gradido.net" -LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido_vue_wallet/tree/master/backend" +LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/frontend" LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}" LABEL org.label-schema.vendor="gradido Community" LABEL org.label-schema.version="${BUILD_VERSION}" diff --git a/frontend/package.json b/frontend/package.json index 2fc892f9f..cfc12630e 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap-vue-gradido-wallet", - "version": "1.13.1", + "version": "1.14.1", "private": true, "scripts": { "start": "node run/server.js", diff --git a/frontend/src/assets/scss/custom/gradido-custom/_color.scss b/frontend/src/assets/scss/custom/gradido-custom/_color.scss index 20fcbefd6..f42555adf 100644 --- a/frontend/src/assets/scss/custom/gradido-custom/_color.scss +++ b/frontend/src/assets/scss/custom/gradido-custom/_color.scss @@ -33,7 +33,9 @@ $indigo: #5603ad !default; $purple: #8965e0 !default; $pink: #f3a4b5 !default; $red: #f5365c !default; -$orange: #fb6340 !default; + +// $orange: #fb6340 !default; +$orange: #8c0505 !default; $yellow: #ffd600 !default; $green: #2dce89 !default; $teal: #11cdef !default; diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesFormular.spec.js b/frontend/src/components/ContributionMessages/ContributionMessagesFormular.spec.js index aba5abc34..42deac9cb 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesFormular.spec.js +++ b/frontend/src/components/ContributionMessages/ContributionMessagesFormular.spec.js @@ -67,9 +67,9 @@ describe('ContributionMessagesFormular', () => { await wrapper.find('form').trigger('submit') }) - it('emitted "get-list-contribution-messages" with data', async () => { + it('emitted "get-list-contribution-messages" with false', async () => { expect(wrapper.emitted('get-list-contribution-messages')).toEqual( - expect.arrayContaining([expect.arrayContaining([42])]), + expect.arrayContaining([expect.arrayContaining([false])]), ) }) diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesFormular.vue b/frontend/src/components/ContributionMessages/ContributionMessagesFormular.vue index 1a5928cc3..c601de4f5 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesFormular.vue +++ b/frontend/src/components/ContributionMessages/ContributionMessagesFormular.vue @@ -51,7 +51,7 @@ export default { }, }) .then((result) => { - this.$emit('get-list-contribution-messages', this.contributionId) + this.$emit('get-list-contribution-messages', false) this.$emit('update-state', this.contributionId) this.form.text = '' this.toastSuccess(this.$t('message.reply')) diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesList.spec.js b/frontend/src/components/ContributionMessages/ContributionMessagesList.spec.js index 7798532b7..c5c26a2c0 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesList.spec.js +++ b/frontend/src/components/ContributionMessages/ContributionMessagesList.spec.js @@ -40,16 +40,6 @@ describe('ContributionMessagesList', () => { expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true) }) - describe('get List Contribution Messages', () => { - beforeEach(() => { - wrapper.vm.getListContributionMessages() - }) - - it('emits getListContributionMessages', async () => { - expect(wrapper.vm.$emit('get-list-contribution-messages')).toBeTruthy() - }) - }) - describe('update State', () => { beforeEach(() => { wrapper.vm.updateState() diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesList.vue b/frontend/src/components/ContributionMessages/ContributionMessagesList.vue index 5f1c03b22..e9262c073 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesList.vue +++ b/frontend/src/components/ContributionMessages/ContributionMessagesList.vue @@ -5,15 +5,23 @@ - -
- - {{ $t('form.close') }} + + + + +
+ + + {{ $t('form.close') }} +
@@ -42,9 +50,6 @@ export default { }, }, methods: { - getListContributionMessages() { - this.$emit('get-list-contribution-messages', this.contributionId) - }, updateState(id) { this.$emit('update-state', id) }, @@ -55,4 +60,7 @@ export default { .temp-message { margin-top: 50px; } +.clearboth { + clear: both; +} diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js index 2dc9fb3ce..1a918747f 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js +++ b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js @@ -5,9 +5,11 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue' const localVue = global.localVue let wrapper +const dateMock = jest.fn((d) => d) + const mocks = { $t: jest.fn((t) => t), - $d: jest.fn((d) => d), + $d: dateMock, $store: { state: { firstName: 'Peter', @@ -239,4 +241,63 @@ and here is the link to the repository: https://github.com/gradido/gradido`) }) }) }) + + describe('contribution message type HISTORY', () => { + const propsData = { + message: { + id: 111, + message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time) +--- +This message also contains a link: https://gradido.net/de/ +--- +350.00`, + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'HISTORY', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const itemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('render HISTORY message', () => { + beforeEach(() => { + jest.clearAllMocks() + wrapper = itemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)') + }) + + it('renders the date', () => { + expect(dateMock).toBeCalledWith( + new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'), + 'short', + ) + }) + + it('renders the amount', () => { + expect(messageField.text()).toContain('350.00 GDD') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toContain( + 'This message also contains a link: https://gradido.net/de/', + ) + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + }) }) diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue index 9c7a3a0f2..5862f97f5 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue +++ b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue @@ -4,25 +4,25 @@ {{ message.userFirstName }} {{ message.userLastName }} {{ $d(new Date(message.createdAt), 'short') }} - +
{{ message.userFirstName }} {{ message.userLastName }} {{ $d(new Date(message.createdAt), 'short') }} {{ $t('community.moderator') }} - +