Merge branch 'master' into refactor_dlt_connector_modern_stack

This commit is contained in:
einhornimmond 2025-09-03 14:42:07 +02:00
commit b17c381f6c
100 changed files with 5230 additions and 26070 deletions

View File

@ -10,6 +10,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION FRONTEND ###################################### # JOB: DOCKER BUILD PRODUCTION FRONTEND ######################################
############################################################################## ##############################################################################
build_production_frontend: build_production_frontend:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - Frontend name: Docker Build Production - Frontend
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -47,6 +48,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION ADMIN ######################################### # JOB: DOCKER BUILD PRODUCTION ADMIN #########################################
############################################################################## ##############################################################################
build_production_admin: build_production_admin:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - Admin name: Docker Build Production - Admin
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -84,6 +86,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION BACKEND ####################################### # JOB: DOCKER BUILD PRODUCTION BACKEND #######################################
############################################################################## ##############################################################################
build_production_backend: build_production_backend:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - Backend name: Docker Build Production - Backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -121,6 +124,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION DHT-NODE ###################################### # JOB: DOCKER BUILD PRODUCTION DHT-NODE ######################################
############################################################################## ##############################################################################
build_production_dht-node: build_production_dht-node:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - DHT-Node name: Docker Build Production - DHT-Node
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -158,6 +162,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION FEDERATION ###################################### # JOB: DOCKER BUILD PRODUCTION FEDERATION ######################################
############################################################################## ##############################################################################
build_production_federation: build_production_federation:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - Federation name: Docker Build Production - Federation
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -195,6 +200,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION DATABASE UP ################################### # JOB: DOCKER BUILD PRODUCTION DATABASE UP ###################################
############################################################################## ##############################################################################
build_production_database_up: build_production_database_up:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - Database up name: Docker Build Production - Database up
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -221,6 +227,7 @@ jobs:
# JOB: DOCKER BUILD PRODUCTION NGINX ######################################### # JOB: DOCKER BUILD PRODUCTION NGINX #########################################
############################################################################## ##############################################################################
build_production_nginx: build_production_nginx:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Docker Build Production - Nginx name: Docker Build Production - Nginx
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] #needs: [nothing]
@ -258,6 +265,7 @@ jobs:
# JOB: UPLOAD TO DOCKERHUB ################################################### # JOB: UPLOAD TO DOCKERHUB ###################################################
############################################################################## ##############################################################################
upload_to_dockerhub: upload_to_dockerhub:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Upload to Dockerhub name: Upload to Dockerhub
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build_production_frontend, build_production_backend, build_production_database_up, build_production_nginx] needs: [build_production_frontend, build_production_backend, build_production_database_up, build_production_nginx]
@ -315,8 +323,6 @@ jobs:
path: /tmp path: /tmp
- name: Load Docker Image - name: Load Docker Image
run: docker load < /tmp/database_up.tar run: docker load < /tmp/database_up.tar
- name: Load Docker Image
run: docker load < /tmp/mariadb.tar
- name: Download Docker Image (Nginx) - name: Download Docker Image (Nginx)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@ -348,6 +354,7 @@ jobs:
############################################################################## ##############################################################################
############################################################################## ##############################################################################
github_tag: github_tag:
if: startsWith(github.event.head_commit.message, 'chore(release):')
name: Tag latest version on Github name: Tag latest version on Github
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [upload_to_dockerhub] needs: [upload_to_dockerhub]

View File

@ -32,7 +32,7 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Database | Build image - name: Database | Build image
run: docker build --target build -t "gradido/database:build" -f database/Dockerfile . run: docker build --target up -t "gradido/database:up" -f database/Dockerfile .
database_migration_test: database_migration_test:
if: needs.files-changed.outputs.database == 'true' || needs.files-changed.outputs.docker-compose == 'true' || needs.files-changed.outputs.mariadb == 'true' || needs.files-changed.outputs.shared == 'true' if: needs.files-changed.outputs.database == 'true' || needs.files-changed.outputs.docker-compose == 'true' || needs.files-changed.outputs.mariadb == 'true' || needs.files-changed.outputs.shared == 'true'

View File

@ -4,8 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [2.6.0](https://github.com/gradido/gradido/compare/2.3.1...2.6.0) #### [2.6.1](https://github.com/gradido/gradido/compare/2.3.1...2.6.1)
- refactor(frontend): transaction and contribution form [`#3519`](https://github.com/gradido/gradido/pull/3519)
- fix(federation): fix some attack vectors in communities handshake [`#3517`](https://github.com/gradido/gradido/pull/3517)
- fix(other): start sh when called from webhook [`#3515`](https://github.com/gradido/gradido/pull/3515)
- feat(backend): introduce encrypted jwts in backend federation communication [`#3510`](https://github.com/gradido/gradido/pull/3510)
- feat(other): write playwright tests [`#3509`](https://github.com/gradido/gradido/pull/3509)
- feat(frontend): keep branding project in browser url bar [`#3512`](https://github.com/gradido/gradido/pull/3512)
- feat(other): add clear command for yarn and turbo [`#3513`](https://github.com/gradido/gradido/pull/3513)
- fix(other): in deploy run only core count tasks with turbo at the same time [`#3511`](https://github.com/gradido/gradido/pull/3511)
- refactor(federation): move code for checking pending transactions [`#3508`](https://github.com/gradido/gradido/pull/3508)
- refactor(other): add shared module [`#3507`](https://github.com/gradido/gradido/pull/3507)
- refactor(other): centralize logging code, use log4js config-generator [`#3506`](https://github.com/gradido/gradido/pull/3506)
- fix(frontend): fix password labels [`#3504`](https://github.com/gradido/gradido/pull/3504)
- fix(backend): update contribution frontend link [`#3502`](https://github.com/gradido/gradido/pull/3502)
- refactor(database): move database connection into database module [`#3503`](https://github.com/gradido/gradido/pull/3503)
- chore(release): v2.6.0 beta [`#3501`](https://github.com/gradido/gradido/pull/3501)
- fix(frontend): fix contribution link [`#3500`](https://github.com/gradido/gradido/pull/3500) - fix(frontend): fix contribution link [`#3500`](https://github.com/gradido/gradido/pull/3500)
- feat(other): disable index html caching, reenable limits [`#3497`](https://github.com/gradido/gradido/pull/3497) - feat(other): disable index html caching, reenable limits [`#3497`](https://github.com/gradido/gradido/pull/3497)
- fix(admin): fix accidently remove user states [`#3496`](https://github.com/gradido/gradido/pull/3496) - fix(admin): fix accidently remove user states [`#3496`](https://github.com/gradido/gradido/pull/3496)

View File

@ -174,6 +174,49 @@ turbo frontend#dev backend#start admin#start --env-mode=loose
Tip: for local setup use a local nginx server with similar config like docker nginx [nginx.conf](./nginx/gradido.conf) but replace docker image name with localhost Tip: for local setup use a local nginx server with similar config like docker nginx [nginx.conf](./nginx/gradido.conf) but replace docker image name with localhost
### Testing
This project uses a mocked `log4js` logger for tests.
- **clearLogs()**: Clears all collected logs. Call in `beforeEach` to start with a clean slate.
- **printLogs()**: Prints all collected logs since the last call to clearLogs for debugging purposes.
- Supports log levels: `trace`, `debug`, `info`, `warn`, `error`, `fatal`.
- Logs include context and additional arguments.
- Example:
```ts
import { clearLogs, printLogs } from 'config-schema/test/testSetup'
beforeEach(() => {
clearLogs()
})
describe('test', () => {
it('test', () => {
expect(functionCall()).toBe(true)
printLogs()
})
})
```
#### Include Paths by test framework:
- jest (backend, dht-node, federation):
```ts
import { clearLogs, printLogs } from 'config-schema/test/testSetup'
```
- vitest (frontend, admin, database):
```ts
import { clearLogs, printLogs } from 'config-schema/test/testSetup.vitest'
```
- bun (shared, core):
```ts
import { clearLogs, printLogs } from 'config-schema/test/testSetup.bun'
```
#### Attention!
It isn't tested for parallel running tests yet.
Currently Modules `frontend`, `admin`, `share` and `core` running the tests in parallel,
`database`, `backend`, `dht-node` and `federation` are running the tests still serially.
### Clear ### Clear
In root folder calling `yarn clear` will clear all turbo caches, node_modules and build folders of all workspaces for a clean rebuild. In root folder calling `yarn clear` will clear all turbo caches, node_modules and build folders of all workspaces for a clean rebuild.

View File

@ -3,7 +3,7 @@
"description": "Administration Interface for Gradido", "description": "Administration Interface for Gradido",
"main": "index.js", "main": "index.js",
"author": "Gradido Academy - https://www.gradido.net", "author": "Gradido Academy - https://www.gradido.net",
"version": "2.6.0", "version": "2.6.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -28,6 +28,9 @@
<BListGroupItem> <BListGroupItem>
{{ $t('federation.publicKey') }}&nbsp;{{ item.publicKey }} {{ $t('federation.publicKey') }}&nbsp;{{ item.publicKey }}
</BListGroupItem> </BListGroupItem>
<BListGroupItem v-if="item.hieroTopicId && item.foreign">
{{ $t('federation.hieroTopicId') }}&nbsp;{{ item.hieroTopicId }}
</BListGroupItem>
<BListGroupItem v-if="!item.foreign"> <BListGroupItem v-if="!item.foreign">
<editable-group <editable-group
:allow-edit="$store.state.moderator.roles.includes('ADMIN')" :allow-edit="$store.state.moderator.roles.includes('ADMIN')"
@ -39,6 +42,10 @@
<p style="text-wrap: nowrap">{{ $t('federation.gmsApiKey') }}&nbsp;</p> <p style="text-wrap: nowrap">{{ $t('federation.gmsApiKey') }}&nbsp;</p>
<span class="d-block" style="overflow-x: auto">{{ gmsApiKey }}</span> <span class="d-block" style="overflow-x: auto">{{ gmsApiKey }}</span>
</div> </div>
<div class="d-flex">
<p style="text-wrap: nowrap">{{ $t('federation.hieroTopicId') }}&nbsp;</p>
<span class="d-block" style="overflow-x: auto">{{ hieroTopicId }}</span>
</div>
<BFormGroup> <BFormGroup>
{{ $t('federation.coordinates') }} {{ $t('federation.coordinates') }}
<span v-if="isValidLocation"> <span v-if="isValidLocation">
@ -57,6 +64,11 @@
:label="$t('federation.gmsApiKey')" :label="$t('federation.gmsApiKey')"
id-name="home-community-api-key" id-name="home-community-api-key"
/> />
<editable-groupable-label
v-model="hieroTopicId"
:label="$t('federation.hieroTopicId')"
id-name="home-community-hiero-topic-id"
/>
<coordinates v-model="location" /> <coordinates v-model="location" />
</template> </template>
</editable-group> </editable-group>
@ -111,9 +123,11 @@ const { toastSuccess, toastError } = useAppToast()
const details = ref(false) const details = ref(false)
const gmsApiKey = ref(item.value.gmsApiKey) const gmsApiKey = ref(item.value.gmsApiKey)
const hieroTopicId = ref(item.value.hieroTopicId)
const location = ref(item.value.location) const location = ref(item.value.location)
const originalGmsApiKey = ref(item.value.gmsApiKey) const originalGmsApiKey = ref(item.value.gmsApiKey)
const originalLocation = ref(item.value.location) const originalLocation = ref(item.value.location)
const originalHieroTopicId = ref(item.value.hieroTopicId)
const { mutate: updateHomeCommunityMutation } = useMutation(updateHomeCommunity) const { mutate: updateHomeCommunityMutation } = useMutation(updateHomeCommunity)
@ -164,6 +178,7 @@ const createdAt = computed(() => {
const isLocationChanged = computed(() => originalLocation.value !== location.value) const isLocationChanged = computed(() => originalLocation.value !== location.value)
const isGMSApiKeyChanged = computed(() => originalGmsApiKey.value !== gmsApiKey.value) const isGMSApiKeyChanged = computed(() => originalGmsApiKey.value !== gmsApiKey.value)
const isHieroTopicIdChanged = computed(() => originalHieroTopicId.value !== hieroTopicId.value)
const isValidLocation = computed( const isValidLocation = computed(
() => location.value && location.value.latitude && location.value.longitude, () => location.value && location.value.latitude && location.value.longitude,
) )
@ -178,6 +193,7 @@ const handleUpdateHomeCommunity = async () => {
uuid: item.value.uuid, uuid: item.value.uuid,
gmsApiKey: gmsApiKey.value, gmsApiKey: gmsApiKey.value,
location: location.value, location: location.value,
hieroTopicId: hieroTopicId.value,
}) })
if (isLocationChanged.value && isGMSApiKeyChanged.value) { if (isLocationChanged.value && isGMSApiKeyChanged.value) {
@ -187,8 +203,12 @@ const handleUpdateHomeCommunity = async () => {
} else if (isLocationChanged.value) { } else if (isLocationChanged.value) {
toastSuccess(t('federation.toast_gmsLocationUpdated')) toastSuccess(t('federation.toast_gmsLocationUpdated'))
} }
if (isHieroTopicIdChanged.value) {
toastSuccess(t('federation.toast_hieroTopicIdUpdated'))
}
originalLocation.value = location.value originalLocation.value = location.value
originalGmsApiKey.value = gmsApiKey.value originalGmsApiKey.value = gmsApiKey.value
originalHieroTopicId.value = hieroTopicId.value
} catch (error) { } catch (error) {
toastError(error.message) toastError(error.message)
} }
@ -197,5 +217,6 @@ const handleUpdateHomeCommunity = async () => {
const resetHomeCommunityEditable = () => { const resetHomeCommunityEditable = () => {
location.value = originalLocation.value location.value = originalLocation.value
gmsApiKey.value = originalGmsApiKey.value gmsApiKey.value = originalGmsApiKey.value
hieroTopicId.value = originalHieroTopicId.value
} }
</script> </script>

View File

@ -15,6 +15,7 @@ export const allCommunities = gql`
creationDate creationDate
createdAt createdAt
updatedAt updatedAt
hieroTopicId
federatedCommunities { federatedCommunities {
id id
apiVersion apiVersion

View File

@ -1,8 +1,13 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
export const updateHomeCommunity = gql` export const updateHomeCommunity = gql`
mutation ($uuid: String!, $gmsApiKey: String, $location: Location) { mutation ($uuid: String!, $gmsApiKey: String, $location: Location, $hieroTopicId: String) {
updateHomeCommunity(uuid: $uuid, gmsApiKey: $gmsApiKey, location: $location) { updateHomeCommunity(
uuid: $uuid
gmsApiKey: $gmsApiKey
location: $location
hieroTopicId: $hieroTopicId
) {
id id
} }
} }

View File

@ -96,9 +96,11 @@
"coordinates": "Koordinaten:", "coordinates": "Koordinaten:",
"createdAt": "Erstellt am", "createdAt": "Erstellt am",
"gmsApiKey": "GMS API Key:", "gmsApiKey": "GMS API Key:",
"hieroTopicId": "Hiero Topic ID:",
"toast_gmsApiKeyAndLocationUpdated": "Der GMS Api Key und die Location wurden erfolgreich aktualisiert!", "toast_gmsApiKeyAndLocationUpdated": "Der GMS Api Key und die Location wurden erfolgreich aktualisiert!",
"toast_gmsApiKeyUpdated": "Der GMS Api Key wurde erfolgreich aktualisiert!", "toast_gmsApiKeyUpdated": "Der GMS Api Key wurde erfolgreich aktualisiert!",
"toast_gmsLocationUpdated": "Die GMS Location wurde erfolgreich aktualisiert!", "toast_gmsLocationUpdated": "Die GMS Location wurde erfolgreich aktualisiert!",
"toast_hieroTopicIdUpdated": "Die Hiero Topic ID wurde erfolgreich aktualisiert!",
"gradidoInstances": "Gradido Instanzen", "gradidoInstances": "Gradido Instanzen",
"lastAnnouncedAt": "letzte Bekanntgabe", "lastAnnouncedAt": "letzte Bekanntgabe",
"lastErrorAt": "Letzer Fehler am", "lastErrorAt": "Letzer Fehler am",

View File

@ -96,9 +96,11 @@
"coordinates": "Coordinates:", "coordinates": "Coordinates:",
"createdAt": "Created At ", "createdAt": "Created At ",
"gmsApiKey": "GMS API Key:", "gmsApiKey": "GMS API Key:",
"hieroTopicId": "Hiero Topic ID:",
"toast_gmsApiKeyAndLocationUpdated": "The GMS Api Key and the location have been successfully updated!", "toast_gmsApiKeyAndLocationUpdated": "The GMS Api Key and the location have been successfully updated!",
"toast_gmsApiKeyUpdated": "The GMS Api Key has been successfully updated!", "toast_gmsApiKeyUpdated": "The GMS Api Key has been successfully updated!",
"toast_gmsLocationUpdated": "The GMS location has been successfully updated!", "toast_gmsLocationUpdated": "The GMS location has been successfully updated!",
"toast_hieroTopicIdUpdated": "The Hiero Topic ID has been successfully updated!",
"gradidoInstances": "Gradido Instances", "gradidoInstances": "Gradido Instances",
"lastAnnouncedAt": "Last Announced", "lastAnnouncedAt": "Last Announced",
"lastErrorAt": "last error at", "lastErrorAt": "last error at",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "2.6.0", "version": "2.6.1",
"private": false, "private": false,
"description": "Gradido unified backend providing an API-Service for Gradido Transactions", "description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"repository": "https://github.com/gradido/gradido/backend", "repository": "https://github.com/gradido/gradido/backend",

View File

@ -1 +1,3 @@
export const LOG4JS_BASE_CATEGORY_NAME = 'backend' export const LOG4JS_BASE_CATEGORY_NAME = 'backend'
export const FRONTEND_LOGIN_ROUTE = 'login'
export const GRADIDO_REALM = 'gradido'

View File

@ -6,14 +6,11 @@ import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { getLogger } from 'log4js' import { getLogger } from 'log4js'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { SendCoinsArgsLoggingView } from './logging/SendCoinsArgsLogging.view'
import { SendCoinsResultLoggingView } from './logging/SendCoinsResultLogging.view'
import { SendCoinsArgs } from './model/SendCoinsArgs'
import { SendCoinsResult } from './model/SendCoinsResult'
import { revertSendCoins as revertSendCoinsQuery } from './query/revertSendCoins' import { revertSendCoins as revertSendCoinsQuery } from './query/revertSendCoins'
import { revertSettledSendCoins as revertSettledSendCoinsQuery } from './query/revertSettledSendCoins' import { revertSettledSendCoins as revertSettledSendCoinsQuery } from './query/revertSettledSendCoins'
import { settleSendCoins as settleSendCoinsQuery } from './query/settleSendCoins' import { settleSendCoins as settleSendCoinsQuery } from './query/settleSendCoins'
import { voteForSendCoins as voteForSendCoinsQuery } from './query/voteForSendCoins' import { voteForSendCoins as voteForSendCoinsQuery } from './query/voteForSendCoins'
import { EncryptedTransferArgs } from 'core'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.client.1_0.SendCoinsClient`) const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.client.1_0.SendCoinsClient`)
@ -34,33 +31,26 @@ export class SendCoinsClient {
}) })
} }
async voteForSendCoins(args: SendCoinsArgs): Promise<SendCoinsResult> { async voteForSendCoins(args: EncryptedTransferArgs): Promise<string | null> {
logger.debug('voteForSendCoins against endpoint=', this.endpoint) logger.debug('voteForSendCoins against endpoint=', this.endpoint)
try { try {
logger.debug(`voteForSendCoins with args=`, new SendCoinsArgsLoggingView(args)) const { data } = await this.client.rawRequest<{ voteForSendCoins: string }>(voteForSendCoinsQuery, { args })
const { data } = await this.client.rawRequest<{ voteForSendCoins: SendCoinsResult }>( const responseJwt = data?.voteForSendCoins
voteForSendCoinsQuery, if (responseJwt) {
{ args }, logger.debug('received response jwt', responseJwt)
) return responseJwt
const result = data.voteForSendCoins
if (!data?.voteForSendCoins?.vote) {
logger.debug('voteForSendCoins failed with: ', new SendCoinsResultLoggingView(result))
return new SendCoinsResult()
} }
logger.debug(
'voteForSendCoins successful with result=',
new SendCoinsResultLoggingView(result),
)
return result
} catch (err) { } catch (err) {
throw new LogError(`voteForSendCoins failed for endpoint=${this.endpoint}:`, err) const errmsg = `voteForSendCoins failed for endpoint=${this.endpoint}, err=${err}`
logger.error(errmsg)
throw new Error(errmsg)
} }
return null
} }
async revertSendCoins(args: SendCoinsArgs): Promise<boolean> { async revertSendCoins(args: EncryptedTransferArgs): Promise<boolean> {
logger.debug('revertSendCoins against endpoint=', this.endpoint) logger.debug('revertSendCoins against endpoint=', this.endpoint)
try { try {
logger.debug(`revertSendCoins with args=`, new SendCoinsArgsLoggingView(args))
const { data } = await this.client.rawRequest<{ revertSendCoins: boolean }>( const { data } = await this.client.rawRequest<{ revertSendCoins: boolean }>(
revertSendCoinsQuery, revertSendCoinsQuery,
{ args }, { args },
@ -78,10 +68,9 @@ export class SendCoinsClient {
} }
} }
async settleSendCoins(args: SendCoinsArgs): Promise<boolean> { async settleSendCoins(args: EncryptedTransferArgs): Promise<boolean> {
logger.debug(`settleSendCoins against endpoint='${this.endpoint}'...`) logger.debug(`settleSendCoins against endpoint='${this.endpoint}'...`)
try { try {
logger.debug(`settleSendCoins with args=`, new SendCoinsArgsLoggingView(args))
const { data } = await this.client.rawRequest<{ settleSendCoins: boolean }>( const { data } = await this.client.rawRequest<{ settleSendCoins: boolean }>(
settleSendCoinsQuery, settleSendCoinsQuery,
{ args }, { args },
@ -98,10 +87,9 @@ export class SendCoinsClient {
} }
} }
async revertSettledSendCoins(args: SendCoinsArgs): Promise<boolean> { async revertSettledSendCoins(args: EncryptedTransferArgs): Promise<boolean> {
logger.debug(`revertSettledSendCoins against endpoint='${this.endpoint}'...`) logger.debug(`revertSettledSendCoins against endpoint='${this.endpoint}'...`)
try { try {
logger.debug(`revertSettledSendCoins with args=`, new SendCoinsArgsLoggingView(args))
const { data } = await this.client.rawRequest<{ revertSettledSendCoins: boolean }>( const { data } = await this.client.rawRequest<{ revertSettledSendCoins: boolean }>(
revertSettledSendCoinsQuery, revertSettledSendCoinsQuery,
{ args }, { args },

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request' import { gql } from 'graphql-request'
export const revertSendCoins = gql` export const revertSendCoins = gql`
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
revertSendCoins(data: $args) revertSendCoins(data: $args)
} }
` `

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request' import { gql } from 'graphql-request'
export const revertSettledSendCoins = gql` export const revertSettledSendCoins = gql`
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
revertSettledSendCoins(data: $args) revertSettledSendCoins(data: $args)
} }
` `

View File

@ -1,7 +1,7 @@
import { gql } from 'graphql-request' import { gql } from 'graphql-request'
export const settleSendCoins = gql` export const settleSendCoins = gql`
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
settleSendCoins(data: $args) settleSendCoins(data: $args)
} }
` `

View File

@ -1,8 +1,12 @@
import { gql } from 'graphql-request' import { gql } from 'graphql-request'
export const voteForSendCoins = gql` export const voteForSendCoins = gql`
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
voteForSendCoins(data: $args) { voteForSendCoins(data: $args)
}
`
/*
{
vote vote
recipGradidoID recipGradidoID
recipFirstName recipFirstName
@ -10,4 +14,4 @@ export const voteForSendCoins = gql`
recipAlias recipAlias
} }
} }
` */

View File

@ -21,7 +21,7 @@ export class EditCommunityInput {
location?: Location | null location?: Location | null
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
@IsString()
@isValidHieroId() @isValidHieroId()
topicId?: string | null hieroTopicId?: string | null
} }

View File

@ -39,6 +39,7 @@ export class AdminCommunityView {
this.uuid = dbCom.communityUuid this.uuid = dbCom.communityUuid
this.authenticatedAt = dbCom.authenticatedAt this.authenticatedAt = dbCom.authenticatedAt
this.gmsApiKey = dbCom.gmsApiKey this.gmsApiKey = dbCom.gmsApiKey
this.hieroTopicId = dbCom.hieroTopicId
if (dbCom.location) { if (dbCom.location) {
this.location = Point2Location(dbCom.location as Point) this.location = Point2Location(dbCom.location as Point)
} }
@ -71,6 +72,9 @@ export class AdminCommunityView {
@Field(() => Location, { nullable: true }) @Field(() => Location, { nullable: true })
location: Location | null location: Location | null
@Field(() => String, { nullable: true })
hieroTopicId: string | null
@Field(() => Date, { nullable: true }) @Field(() => Date, { nullable: true })
creationDate: Date | null creationDate: Date | null

View File

@ -13,6 +13,7 @@ export class Community {
this.uuid = dbCom.communityUuid this.uuid = dbCom.communityUuid
this.authenticatedAt = dbCom.authenticatedAt this.authenticatedAt = dbCom.authenticatedAt
this.gmsApiKey = dbCom.gmsApiKey this.gmsApiKey = dbCom.gmsApiKey
this.hieroTopicId = dbCom.hieroTopicId
} }
@Field(() => Int) @Field(() => Int)
@ -41,4 +42,7 @@ export class Community {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
gmsApiKey: string | null gmsApiKey: string | null
@Field(() => String, { nullable: true })
hieroTopicId: string | null
} }

View File

@ -38,6 +38,7 @@ export class CommunityResolver {
@Authorized([RIGHTS.COMMUNITIES]) @Authorized([RIGHTS.COMMUNITIES])
@Query(() => [AdminCommunityView]) @Query(() => [AdminCommunityView])
async allCommunities(@Args() paginated: Paginated): Promise<AdminCommunityView[]> { async allCommunities(@Args() paginated: Paginated): Promise<AdminCommunityView[]> {
// communityUUID could be oneTimePassCode (uint32 number)
return (await getAllCommunities(paginated)).map((dbCom) => new AdminCommunityView(dbCom)) return (await getAllCommunities(paginated)).map((dbCom) => new AdminCommunityView(dbCom))
} }
@ -58,6 +59,7 @@ export class CommunityResolver {
async communityByIdentifier( async communityByIdentifier(
@Arg('communityIdentifier') communityIdentifier: string, @Arg('communityIdentifier') communityIdentifier: string,
): Promise<Community> { ): Promise<Community> {
// communityUUID could be oneTimePassCode (uint32 number)
const community = await getCommunityByIdentifier(communityIdentifier) const community = await getCommunityByIdentifier(communityIdentifier)
if (!community) { if (!community) {
throw new LogError('community not found', communityIdentifier) throw new LogError('community not found', communityIdentifier)
@ -78,7 +80,7 @@ export class CommunityResolver {
@Authorized([RIGHTS.COMMUNITY_UPDATE]) @Authorized([RIGHTS.COMMUNITY_UPDATE])
@Mutation(() => Community) @Mutation(() => Community)
async updateHomeCommunity( async updateHomeCommunity(
@Args() { uuid, gmsApiKey, location }: EditCommunityInput, @Args() { uuid, gmsApiKey, location, hieroTopicId }: EditCommunityInput,
): Promise<Community> { ): Promise<Community> {
const homeCom = await getCommunityByUuid(uuid) const homeCom = await getCommunityByUuid(uuid)
if (!homeCom) { if (!homeCom) {
@ -87,11 +89,16 @@ export class CommunityResolver {
if (homeCom.foreign) { if (homeCom.foreign) {
throw new LogError('Error: Only the HomeCommunity could be modified!') throw new LogError('Error: Only the HomeCommunity could be modified!')
} }
if (homeCom.gmsApiKey !== gmsApiKey || homeCom.location !== location) { if (
homeCom.gmsApiKey !== gmsApiKey ||
homeCom.location !== location ||
homeCom.hieroTopicId !== hieroTopicId
) {
homeCom.gmsApiKey = gmsApiKey ?? null homeCom.gmsApiKey = gmsApiKey ?? null
if (location) { if (location) {
homeCom.location = Location2Point(location) homeCom.location = Location2Point(location)
} }
homeCom.hieroTopicId = hieroTopicId ?? null
await DbCommunity.save(homeCom) await DbCommunity.save(homeCom)
} }
return new Community(homeCom) return new Community(homeCom)

View File

@ -423,13 +423,13 @@ describe('Contribution Links', () => {
]) ])
}) })
it('returns an error if memo is longer than 255 characters', async () => { it('returns an error if memo is longer than 512 characters', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const { errors: errorObjects } = await mutate({ const { errors: errorObjects } = await mutate({
mutation: createContributionLink, mutation: createContributionLink,
variables: { variables: {
...variables, ...variables,
memo: '1234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456', memo: '123456789012345678921234567893123456789412345678951234567896123456789712345678981234567899123456789012345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567890123456789212345678931234567894123456789512345612345678901234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567890123456789212345678931234567894123456789512345678961234567897123456789812345678991234567890123456789012345678921234567893123456789412345678951234567',
}, },
}) })
expect(errorObjects).toMatchObject([ expect(errorObjects).toMatchObject([
@ -441,7 +441,7 @@ describe('Contribution Links', () => {
{ {
property: 'memo', property: 'memo',
constraints: { constraints: {
maxLength: 'memo must be shorter than or equal to 255 characters', maxLength: 'memo must be shorter than or equal to 512 characters',
}, },
}, },
], ],

View File

@ -229,14 +229,14 @@ describe('ContributionResolver', () => {
]) ])
}) })
it('throws error when memo length greater than 255 chars', async () => { it('throws error when memo length greater than 512 chars', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const date = new Date() const date = new Date()
const { errors: errorObjects } = await mutate({ const { errors: errorObjects } = await mutate({
mutation: createContribution, mutation: createContribution,
variables: { variables: {
amount: 100.0, amount: 100.0,
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test', memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
contributionDate: date.toString(), contributionDate: date.toString(),
}, },
}) })
@ -249,7 +249,7 @@ describe('ContributionResolver', () => {
{ {
property: 'memo', property: 'memo',
constraints: { constraints: {
maxLength: 'memo must be shorter than or equal to 255 characters', maxLength: 'memo must be shorter than or equal to 512 characters',
}, },
}, },
], ],
@ -398,7 +398,7 @@ describe('ContributionResolver', () => {
}) })
}) })
describe('Memo length greater than 255 chars', () => { describe('Memo length greater than 512 chars', () => {
it('throws error', async () => { it('throws error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const date = new Date() const date = new Date()
@ -407,7 +407,7 @@ describe('ContributionResolver', () => {
variables: { variables: {
contributionId: pendingContribution.data.createContribution.id, contributionId: pendingContribution.data.createContribution.id,
amount: 100.0, amount: 100.0,
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test', memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
contributionDate: date.toString(), contributionDate: date.toString(),
}, },
}) })
@ -420,7 +420,7 @@ describe('ContributionResolver', () => {
{ {
property: 'memo', property: 'memo',
constraints: { constraints: {
maxLength: 'memo must be shorter than or equal to 255 characters', maxLength: 'memo must be shorter than or equal to 512 characters',
}, },
}, },
], ],

View File

@ -187,7 +187,7 @@ describe('TransactionLinkResolver', () => {
variables: { variables: {
identifier: 'peter@lustig.de', identifier: 'peter@lustig.de',
amount: 100, amount: 100,
memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t', memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test',
}, },
}) })
expect(errorObjects).toMatchObject([ expect(errorObjects).toMatchObject([
@ -199,7 +199,7 @@ describe('TransactionLinkResolver', () => {
{ {
property: 'memo', property: 'memo',
constraints: { constraints: {
maxLength: 'memo must be shorter than or equal to 255 characters', maxLength: 'memo must be shorter than or equal to 512 characters',
}, },
}, },
], ],

View File

@ -269,7 +269,7 @@ describe('send coins', () => {
recipientCommunityIdentifier: homeCom.communityUuid, recipientCommunityIdentifier: homeCom.communityUuid,
recipientIdentifier: 'peter@lustig.de', recipientIdentifier: 'peter@lustig.de',
amount: 100, amount: 100,
memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t', memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test',
}, },
}) })
expect(errorObjects).toMatchObject([ expect(errorObjects).toMatchObject([
@ -281,7 +281,7 @@ describe('send coins', () => {
{ {
property: 'memo', property: 'memo',
constraints: { constraints: {
maxLength: 'memo must be shorter than or equal to 255 characters', maxLength: 'memo must be shorter than or equal to 512 characters',
}, },
}, },
], ],
@ -479,7 +479,6 @@ describe('send coins', () => {
}) })
}) })
}) })
describe('send coins via gradido ID', () => { describe('send coins via gradido ID', () => {
it('sends the coins', async () => { it('sends the coins', async () => {
await expect( await expect(
@ -591,8 +590,8 @@ describe('send coins', () => {
}) })
}) })
}) })
/*
describe('X-Com send coins via gradido ID', () => { describe.skip('X-Com send coins via gradido ID', () => {
beforeAll(async () => { beforeAll(async () => {
CONFIG.FEDERATION_XCOM_SENDCOINS_ENABLED = true CONFIG.FEDERATION_XCOM_SENDCOINS_ENABLED = true
fedForeignCom = DbFederatedCommunity.create() fedForeignCom = DbFederatedCommunity.create()
@ -653,7 +652,7 @@ describe('send coins', () => {
}) })
}) })
}) })
*/
describe('more transactions to test semaphore', () => { describe('more transactions to test semaphore', () => {
it('sends the coins four times in a row', async () => { it('sends the coins four times in a row', async () => {
await expect( await expect(

View File

@ -15,7 +15,7 @@ import { In, IsNull } from 'typeorm'
import { Paginated } from '@arg/Paginated' import { Paginated } from '@arg/Paginated'
import { TransactionSendArgs } from '@arg/TransactionSendArgs' import { TransactionSendArgs } from '@arg/TransactionSendArgs'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { PendingTransactionState } from 'shared' import { PendingTransactionState, SendCoinsResponseJwtPayloadType } from 'shared'
import { TransactionTypeId } from '@enum/TransactionTypeId' import { TransactionTypeId } from '@enum/TransactionTypeId'
import { Transaction } from '@model/Transaction' import { Transaction } from '@model/Transaction'
import { TransactionList } from '@model/TransactionList' import { TransactionList } from '@model/TransactionList'
@ -484,7 +484,7 @@ export class TransactionResolver {
if (recipCom !== null && recipCom.authenticatedAt === null) { if (recipCom !== null && recipCom.authenticatedAt === null) {
throw new LogError('recipient community is connected, but still not authenticated yet!') throw new LogError('recipient community is connected, but still not authenticated yet!')
} }
let pendingResult: SendCoinsResult let pendingResult: SendCoinsResponseJwtPayloadType | null = null
let committingResult: SendCoinsResult let committingResult: SendCoinsResult
const creationDate = new Date() const creationDate = new Date()
@ -499,7 +499,7 @@ export class TransactionResolver {
recipientIdentifier, recipientIdentifier,
) )
logger.debug('processXComPendingSendCoins result: ', pendingResult) logger.debug('processXComPendingSendCoins result: ', pendingResult)
if (pendingResult.vote && pendingResult.recipGradidoID) { if (pendingResult && pendingResult.vote && pendingResult.recipGradidoID) {
logger.debug('vor processXComCommittingSendCoins... ') logger.debug('vor processXComCommittingSendCoins... ')
committingResult = await processXComCommittingSendCoins( committingResult = await processXComCommittingSendCoins(
recipCom, recipCom,

View File

@ -8,7 +8,7 @@ export const FULL_CREATION_AVAILABLE = [
] ]
export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100 export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5 export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
export const MEMO_MAX_CHARS = 255 export const MEMO_MAX_CHARS = 512
export const MEMO_MIN_CHARS = 5 export const MEMO_MIN_CHARS = 5
export const DEFAULT_PAGINATION_PAGE_SIZE = 25 export const DEFAULT_PAGINATION_PAGE_SIZE = 25
export const FRONTEND_CONTRIBUTIONS_ITEM_ANCHOR_PREFIX = 'contributionListItem-' export const FRONTEND_CONTRIBUTIONS_ITEM_ANCHOR_PREFIX = 'contributionListItem-'

View File

@ -16,7 +16,7 @@ import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0
import { SendCoinsArgs } from '@/federation/client/1_0/model/SendCoinsArgs' import { SendCoinsArgs } from '@/federation/client/1_0/model/SendCoinsArgs'
import { SendCoinsResult } from '@/federation/client/1_0/model/SendCoinsResult' import { SendCoinsResult } from '@/federation/client/1_0/model/SendCoinsResult'
import { SendCoinsClientFactory } from '@/federation/client/SendCoinsClientFactory' import { SendCoinsClientFactory } from '@/federation/client/SendCoinsClientFactory'
import { PendingTransactionState } from 'shared' import { encryptAndSign, PendingTransactionState, SendCoinsJwtPayloadType, SendCoinsResponseJwtPayloadType, verifyAndDecrypt } from 'shared'
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { LogError } from '@/server/LogError' import { LogError } from '@/server/LogError'
@ -27,8 +27,10 @@ import { getLogger } from 'log4js'
import { settlePendingSenderTransaction } from './settlePendingSenderTransaction' import { settlePendingSenderTransaction } from './settlePendingSenderTransaction'
import { SendCoinsArgsLoggingView } from '@/federation/client/1_0/logging/SendCoinsArgsLogging.view' import { SendCoinsArgsLoggingView } from '@/federation/client/1_0/logging/SendCoinsArgsLogging.view'
import { SendCoinsResultLoggingView } from '@/federation/client/1_0/logging/SendCoinsResultLogging.view' import { SendCoinsResultLoggingView } from '@/federation/client/1_0/logging/SendCoinsResultLogging.view'
import { EncryptedTransferArgs } from 'core'
import { randombytes_random } from 'sodium-native'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.util.processXComSendCoins`) const createLogger = (method: string) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.util.processXComSendCoins.${method}`)
export async function processXComPendingSendCoins( export async function processXComPendingSendCoins(
receiverCom: DbCommunity, receiverCom: DbCommunity,
@ -38,12 +40,13 @@ export async function processXComPendingSendCoins(
memo: string, memo: string,
sender: dbUser, sender: dbUser,
recipientIdentifier: string, recipientIdentifier: string,
): Promise<SendCoinsResult> { ): Promise<SendCoinsResponseJwtPayloadType | null> {
let voteResult: SendCoinsResult let voteResult: SendCoinsResponseJwtPayloadType
const methodLogger = createLogger(`processXComPendingSendCoins`)
try { try {
// even if debug is not enabled, attributes are processed so we skip the entire call for performance reasons // even if debug is not enabled, attributes are processed so we skip the entire call for performance reasons
if(logger.isDebugEnabled()) { if(methodLogger.isDebugEnabled()) {
logger.debug( methodLogger.debug(
'XCom: processXComPendingSendCoins...', { 'XCom: processXComPendingSendCoins...', {
receiverCom: new CommunityLoggingView(receiverCom), receiverCom: new CommunityLoggingView(receiverCom),
senderCom: new CommunityLoggingView(senderCom), senderCom: new CommunityLoggingView(senderCom),
@ -55,17 +58,21 @@ export async function processXComPendingSendCoins(
) )
} }
if (await countOpenPendingTransactions([sender.gradidoID, recipientIdentifier]) > 0) { if (await countOpenPendingTransactions([sender.gradidoID, recipientIdentifier]) > 0) {
throw new LogError( const errmsg = `There exist still ongoing 'Pending-Transactions' for the involved users on sender-side!`
`There exist still ongoing 'Pending-Transactions' for the involved users on sender-side!`, methodLogger.error(errmsg)
) throw new LogError(errmsg)
} }
const handshakeID = randombytes_random().toString()
methodLogger.addContext('handshakeID', handshakeID)
// first calculate the sender balance and check if the transaction is allowed // first calculate the sender balance and check if the transaction is allowed
const senderBalance = await calculateSenderBalance(sender.id, amount.mul(-1), creationDate) const senderBalance = await calculateSenderBalance(sender.id, amount.mul(-1), creationDate)
if (!senderBalance) { if (!senderBalance) {
throw new LogError('User has not enough GDD or amount is < 0', senderBalance) const errmsg = `User has not enough GDD or amount is < 0`
methodLogger.error(errmsg)
throw new LogError(errmsg)
} }
if(logger.isDebugEnabled()) { if(methodLogger.isDebugEnabled()) {
logger.debug(`calculated senderBalance = ${JSON.stringify(senderBalance, null, 2)}`) methodLogger.debug(`calculated senderBalance = ${JSON.stringify(senderBalance, null, 2)}`)
} }
const receiverFCom = await DbFederatedCommunity.findOneOrFail({ const receiverFCom = await DbFederatedCommunity.findOneOrFail({
@ -77,89 +84,115 @@ export async function processXComPendingSendCoins(
const client = SendCoinsClientFactory.getInstance(receiverFCom) const client = SendCoinsClientFactory.getInstance(receiverFCom)
if (client instanceof V1_0_SendCoinsClient) { if (client instanceof V1_0_SendCoinsClient) {
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(handshakeID,
if (receiverCom.communityUuid) { receiverCom.communityUuid!,
args.recipientCommunityUuid = receiverCom.communityUuid recipientIdentifier,
creationDate.toISOString(),
amount,
memo,
senderCom.communityUuid!,
sender.gradidoID,
fullName(sender.firstName, sender.lastName),
sender.alias
)
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`ready for voteForSendCoins with payload=${payload}`)
} }
args.recipientUserIdentifier = recipientIdentifier const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, receiverCom.publicJwtKey!)
args.creationDate = creationDate.toISOString() if(methodLogger.isDebugEnabled()) {
args.amount = amount methodLogger.debug('jws', jws)
args.memo = memo
if (senderCom.communityUuid) {
args.senderCommunityUuid = senderCom.communityUuid
} }
args.senderUserUuid = sender.gradidoID // prepare the args for the client invocation
args.senderUserName = fullName(sender.firstName, sender.lastName) const args = new EncryptedTransferArgs()
args.senderAlias = sender.alias args.publicKey = senderCom.publicKey.toString('hex')
if(logger.isDebugEnabled()) { args.jwt = jws
logger.debug(`ready for voteForSendCoins with args=${new SendCoinsArgsLoggingView(args)}`) args.handshakeID = handshakeID
if(methodLogger.isDebugEnabled()) {
methodLogger.debug('before client.voteForSendCoins() args:', args)
} }
voteResult = await client.voteForSendCoins(args)
if(logger.isDebugEnabled()) {
logger.debug(`returned from voteForSendCoins: ${new SendCoinsResultLoggingView(voteResult)}`)
}
if (voteResult.vote) {
logger.debug('prepare pendingTransaction for sender...')
// writing the pending transaction on receiver-side was successfull, so now write the sender side
try {
const pendingTx = DbPendingTransaction.create()
pendingTx.amount = amount.mul(-1)
pendingTx.balance = senderBalance.balance
pendingTx.balanceDate = creationDate
pendingTx.decay = senderBalance ? senderBalance.decay.decay : new Decimal(0)
pendingTx.decayStart = senderBalance ? senderBalance.decay.start : null
if (receiverCom.communityUuid) {
pendingTx.linkedUserCommunityUuid = receiverCom.communityUuid
}
if (voteResult.recipGradidoID) {
pendingTx.linkedUserGradidoID = voteResult.recipGradidoID
}
if (voteResult.recipFirstName && voteResult.recipLastName) {
pendingTx.linkedUserName = fullName(voteResult.recipFirstName, voteResult.recipLastName)
}
pendingTx.memo = memo
pendingTx.previous = senderBalance ? senderBalance.lastTransactionId : null
pendingTx.state = PendingTransactionState.NEW
pendingTx.typeId = TransactionTypeId.SEND
if (senderCom.communityUuid) {
pendingTx.userCommunityUuid = senderCom.communityUuid
}
pendingTx.userId = sender.id
pendingTx.userGradidoID = sender.gradidoID
pendingTx.userName = fullName(sender.firstName, sender.lastName)
if(logger.isDebugEnabled()) {
logger.debug(`initialized sender pendingTX=${new PendingTransactionLoggingView(pendingTx)}`)
}
await DbPendingTransaction.insert(pendingTx) const responseJwt = await client.voteForSendCoins(args)
logger.debug('sender pendingTx successfully inserted...') if(methodLogger.isDebugEnabled()) {
} catch (err) { methodLogger.debug(`response of voteForSendCoins():`, responseJwt)
logger.error(`Error in writing sender pending transaction: ${JSON.stringify(err, null, 2)}`) }
// revert the existing pending transaction on receiver side if (responseJwt !== null) {
let revertCount = 0 voteResult = await verifyAndDecrypt(handshakeID, responseJwt, senderCom.privateJwtKey!, receiverCom.publicJwtKey!) as SendCoinsResponseJwtPayloadType
logger.debug('first try to revertSendCoins of receiver') if(methodLogger.isDebugEnabled()) {
do { methodLogger.debug(`received payload from voteForSendCoins():`, voteResult)
if (await client.revertSendCoins(args)) { }
logger.debug(`revertSendCoins()-1_0... successfull after revertCount=${revertCount}`) if (voteResult && voteResult.tokentype !== SendCoinsResponseJwtPayloadType.SEND_COINS_RESPONSE_TYPE) {
// treat revertingSendCoins as an error of the whole sendCoins-process const errmsg = `Invalid tokentype in voteForSendCoins-response of community with publicKey` + receiverCom.publicKey
throw new LogError('Error in writing sender pending transaction: ', err) methodLogger.error(errmsg)
} throw new Error('Error in X-Com-TX protocol...')
} while (CONFIG.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS > revertCount++) }
throw new LogError( if (voteResult && voteResult.vote) {
`Error in reverting receiver pending transaction even after revertCount=${revertCount}`, methodLogger.debug('prepare pendingTransaction for sender...')
err, // writing the pending transaction on receiver-side was successfull, so now write the sender side
) try {
} const pendingTx = DbPendingTransaction.create()
logger.debug('voteForSendCoins()-1_0... successfull') pendingTx.amount = amount.mul(-1)
} else { pendingTx.balance = senderBalance.balance
logger.error(`break with error on writing pendingTransaction for recipient... ${new SendCoinsResultLoggingView(voteResult)}`) pendingTx.balanceDate = creationDate
pendingTx.decay = senderBalance ? senderBalance.decay.decay : new Decimal(0)
pendingTx.decayStart = senderBalance ? senderBalance.decay.start : null
if (receiverCom.communityUuid) {
pendingTx.linkedUserCommunityUuid = receiverCom.communityUuid
}
if (voteResult.recipGradidoID) {
pendingTx.linkedUserGradidoID = voteResult.recipGradidoID
}
if (voteResult.recipFirstName && voteResult.recipLastName) {
pendingTx.linkedUserName = fullName(voteResult.recipFirstName, voteResult.recipLastName)
}
pendingTx.memo = memo
pendingTx.previous = senderBalance ? senderBalance.lastTransactionId : null
pendingTx.state = PendingTransactionState.NEW
pendingTx.typeId = TransactionTypeId.SEND
if (senderCom.communityUuid) {
pendingTx.userCommunityUuid = senderCom.communityUuid
}
pendingTx.userId = sender.id
pendingTx.userGradidoID = sender.gradidoID
pendingTx.userName = fullName(sender.firstName, sender.lastName)
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`initialized sender pendingTX=${new PendingTransactionLoggingView(pendingTx)}`)
}
await DbPendingTransaction.insert(pendingTx)
methodLogger.debug('sender pendingTx successfully inserted...')
} catch (err) {
methodLogger.error(`Error in writing sender pending transaction: ${JSON.stringify(err, null, 2)}`)
// revert the existing pending transaction on receiver side
let revertCount = 0
methodLogger.debug('first try to revertSendCoins of receiver')
do {
if (await client.revertSendCoins(args)) {
methodLogger.debug(`revertSendCoins()-1_0... successfull after revertCount=${revertCount}`)
// treat revertingSendCoins as an error of the whole sendCoins-process
const errmsg = `Error in writing sender pending transaction: ${JSON.stringify(err, null, 2)}`
methodLogger.error(errmsg)
throw new Error(errmsg)
}
} while (CONFIG.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS > revertCount++)
const errmsg = `Error in reverting receiver pending transaction even after revertCount=${revertCount}` + JSON.stringify(err, null, 2)
methodLogger.error(errmsg)
throw new Error(errmsg)
}
methodLogger.debug('voteForSendCoins()-1_0... successfull')
return voteResult
} else {
methodLogger.error(`break with error on writing pendingTransaction for recipient... ${voteResult}`)
}
} else {
methodLogger.error(`break with no response from voteForSendCoins()-1_0...`)
} }
return voteResult
} }
} catch (err: any) { } catch (err: any) {
throw new LogError(`Error: ${err.message}`, err) const errmsg = `Error: ${err.message}` + JSON.stringify(err, null, 2)
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
return new SendCoinsResult() return null
} }
export async function processXComCommittingSendCoins( export async function processXComCommittingSendCoins(
@ -171,10 +204,13 @@ export async function processXComCommittingSendCoins(
sender: dbUser, sender: dbUser,
recipient: SendCoinsResult, recipient: SendCoinsResult,
): Promise<SendCoinsResult> { ): Promise<SendCoinsResult> {
const methodLogger = createLogger(`processXComCommittingSendCoins`)
const handshakeID = randombytes_random().toString()
methodLogger.addContext('handshakeID', handshakeID)
const sendCoinsResult = new SendCoinsResult() const sendCoinsResult = new SendCoinsResult()
try { try {
if(logger.isDebugEnabled()) { if(methodLogger.isDebugEnabled()) {
logger.debug( methodLogger.debug(
'XCom: processXComCommittingSendCoins...', { 'XCom: processXComCommittingSendCoins...', {
receiverCom: new CommunityLoggingView(receiverCom), receiverCom: new CommunityLoggingView(receiverCom),
senderCom: new CommunityLoggingView(senderCom), senderCom: new CommunityLoggingView(senderCom),
@ -200,40 +236,49 @@ export async function processXComCommittingSendCoins(
memo, memo,
}) })
if (pendingTx) { if (pendingTx) {
if(logger.isDebugEnabled()) { if(methodLogger.isDebugEnabled()) {
logger.debug(`find pending Tx for settlement: ${new PendingTransactionLoggingView(pendingTx)}`) methodLogger.debug(`find pending Tx for settlement: ${new PendingTransactionLoggingView(pendingTx)}`)
} }
const receiverFCom = await DbFederatedCommunity.findOneOrFail({ const receiverFCom = await DbFederatedCommunity.findOneOrFail({
where: { where: {
publicKey: Buffer.from(receiverCom.publicKey), publicKey: Buffer.from(receiverCom.publicKey),
apiVersion: CONFIG.FEDERATION_BACKEND_SEND_ON_API, apiVersion: CONFIG.FEDERATION_BACKEND_SEND_ON_API,
}, },
}) })
const client = SendCoinsClientFactory.getInstance(receiverFCom) const client = SendCoinsClientFactory.getInstance(receiverFCom)
if (client instanceof V1_0_SendCoinsClient) { if (client instanceof V1_0_SendCoinsClient) {
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = pendingTx.linkedUserCommunityUuid handshakeID,
pendingTx.linkedUserCommunityUuid
? pendingTx.linkedUserCommunityUuid
: CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID,
pendingTx.linkedUserGradidoID!,
pendingTx.balanceDate.toISOString(),
pendingTx.amount.mul(-1),
pendingTx.memo,
pendingTx.userCommunityUuid,
pendingTx.userGradidoID!,
pendingTx.userName!,
sender.alias,
)
payload.recipientCommunityUuid = pendingTx.linkedUserCommunityUuid
? pendingTx.linkedUserCommunityUuid ? pendingTx.linkedUserCommunityUuid
: CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID : CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID
if (pendingTx.linkedUserGradidoID) { if (pendingTx.linkedUserGradidoID) {
args.recipientUserIdentifier = pendingTx.linkedUserGradidoID payload.recipientUserIdentifier = pendingTx.linkedUserGradidoID
} }
args.creationDate = pendingTx.balanceDate.toISOString() if(methodLogger.isDebugEnabled()) {
args.amount = pendingTx.amount.mul(-1) methodLogger.debug(`ready for settleSendCoins with payload=${ JSON.stringify(payload)}`)
args.memo = pendingTx.memo
args.senderCommunityUuid = pendingTx.userCommunityUuid
args.senderUserUuid = pendingTx.userGradidoID
if (pendingTx.userName) {
args.senderUserName = pendingTx.userName
}
args.senderAlias = sender.alias
if(logger.isDebugEnabled()) {
logger.debug(`ready for settleSendCoins with args=${new SendCoinsArgsLoggingView(args)}`)
} }
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, receiverCom.publicJwtKey!)
// prepare the args for the client invocation
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = handshakeID
const acknowledge = await client.settleSendCoins(args) const acknowledge = await client.settleSendCoins(args)
logger.debug(`returnd from settleSendCoins: ${acknowledge}`) methodLogger.debug(`return from settleSendCoins: ${acknowledge}`)
if (acknowledge) { if (acknowledge) {
// settle the pending transaction on receiver-side was successfull, so now settle the sender side // settle the pending transaction on receiver-side was successfull, so now settle the sender side
try { try {
@ -257,13 +302,13 @@ export async function processXComCommittingSendCoins(
sendCoinsResult.recipAlias = recipient.recipAlias sendCoinsResult.recipAlias = recipient.recipAlias
} }
} catch (err) { } catch (err) {
logger.error(`Error in writing sender pending transaction: ${JSON.stringify(err, null, 2)}`) methodLogger.error(`Error in writing sender pending transaction: ${JSON.stringify(err, null, 2)}`)
// revert the existing pending transaction on receiver side // revert the existing pending transaction on receiver side
let revertCount = 0 let revertCount = 0
logger.debug('first try to revertSetteledSendCoins of receiver') methodLogger.debug('first try to revertSetteledSendCoins of receiver')
do { do {
if (await client.revertSettledSendCoins(args)) { if (await client.revertSettledSendCoins(args)) {
logger.debug( methodLogger.debug(
`revertSettledSendCoins()-1_0... successfull after revertCount=${revertCount}`, `revertSettledSendCoins()-1_0... successfull after revertCount=${revertCount}`,
) )
// treat revertingSettledSendCoins as an error of the whole sendCoins-process // treat revertingSettledSendCoins as an error of the whole sendCoins-process
@ -279,7 +324,7 @@ export async function processXComCommittingSendCoins(
} }
} }
} catch (err) { } catch (err) {
logger.error(`Error: ${JSON.stringify(err, null, 2)}`) methodLogger.error(`Error: ${JSON.stringify(err, null, 2)}`)
sendCoinsResult.vote = false sendCoinsResult.vote = false
} }
return sendCoinsResult return sendCoinsResult

View File

@ -1,8 +1,11 @@
import { import {
AppDatabase, AppDatabase,
CommunityLoggingView,
Community as DbCommunity, Community as DbCommunity,
PendingTransaction as DbPendingTransaction, PendingTransaction as DbPendingTransaction,
User as DbUser, User as DbUser,
PendingTransactionLoggingView,
UserLoggingView,
Transaction as dbTransaction, Transaction as dbTransaction,
} from 'database' } from 'database'
import { Decimal } from 'decimal.js-light' import { Decimal } from 'decimal.js-light'
@ -34,7 +37,7 @@ export async function settlePendingSenderTransaction(
logger.debug(`start Transaction for write-access...`) logger.debug(`start Transaction for write-access...`)
try { try {
logger.info('settlePendingSenderTransaction:', homeCom, senderUser, pendingTx) logger.info('settlePendingSenderTransaction:', new CommunityLoggingView(homeCom), new UserLoggingView(senderUser), new PendingTransactionLoggingView(pendingTx))
// ensure that no other pendingTx with the same sender or recipient exists // ensure that no other pendingTx with the same sender or recipient exists
const openSenderPendingTx = await DbPendingTransaction.count({ const openSenderPendingTx = await DbPendingTransaction.count({
@ -88,7 +91,7 @@ export async function settlePendingSenderTransaction(
transactionSend.previous = pendingTx.previous transactionSend.previous = pendingTx.previous
transactionSend.linkedTransactionId = pendingTx.linkedTransactionId transactionSend.linkedTransactionId = pendingTx.linkedTransactionId
await queryRunner.manager.insert(dbTransaction, transactionSend) await queryRunner.manager.insert(dbTransaction, transactionSend)
logger.debug(`send Transaction inserted: ${dbTransaction}`) logger.debug(`send Transaction inserted: ${transactionSend}`)
// and mark the pendingTx in the pending_transactions table as settled // and mark the pendingTx in the pending_transactions table as settled
pendingTx.state = PendingTransactionState.SETTLED pendingTx.state = PendingTransactionState.SETTLED

View File

@ -0,0 +1,63 @@
import { CONFIG } from '@/config'
import { FRONTEND_LOGIN_ROUTE, GRADIDO_REALM, LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { getHomeCommunity } from 'database'
import { importSPKI, exportJWK } from 'jose'
import { createHash } from 'crypto'
import { getLogger } from 'log4js'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.openIDConnect`)
const defaultErrorForCaller = 'Internal Server Error'
export const openidConfiguration = async (req: any, res: any): Promise<void> => {
res.setHeader('Content-Type', 'application/json')
res.status(200).json({
issuer: new URL(FRONTEND_LOGIN_ROUTE, CONFIG.COMMUNITY_URL).toString(),
jwks_uri: new URL(`/realms/${GRADIDO_REALM}/protocol/openid-connect/certs`, CONFIG.COMMUNITY_URL).toString(),
})
}
export const jwks = async (req: any, res: any): Promise<void> => {
const homeCommunity = await getHomeCommunity()
if (!homeCommunity) {
logger.error('HomeCommunity not found')
throw new Error(defaultErrorForCaller)
}
if (!homeCommunity.publicJwtKey) {
logger.error('HomeCommunity publicJwtKey not found')
throw new Error(defaultErrorForCaller)
}
try {
const rs256Key = await importSPKI(homeCommunity.publicJwtKey, 'RS256')
const rsaKey = await importSPKI(homeCommunity.publicJwtKey, 'RSA-OAEP-256')
const jwkRs256 = await exportJWK(rs256Key)
const jwkRsa = await exportJWK(rsaKey)
// Optional: calculate Key ID (z.B. SHA-256 Fingerprint)
const kid = createHash('sha256')
.update(homeCommunity.publicJwtKey)
.digest('base64url')
const jwks = {
keys: [
{
...jwkRs256,
alg: 'RS256',
use: 'sig',
kid,
},
{
...jwkRsa,
alg: 'RSA-OAEP-256',
use: 'sig',
kid,
},
],
}
res.setHeader('Cache-Control', 'public, max-age=3600, immutable')
res.setHeader('Content-Type', 'application/json')
res.status(200).json(jwks)
} catch (err) {
logger.error('Failed to convert publicJwtKey to JWK', err)
res.status(500).json({ error: 'Failed to generate JWKS' })
}
}

View File

@ -9,12 +9,13 @@ import helmet from 'helmet'
import { Logger, getLogger } from 'log4js' import { Logger, getLogger } from 'log4js'
import { DataSource } from 'typeorm' import { DataSource } from 'typeorm'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { GRADIDO_REALM, LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AppDatabase } from 'database' import { AppDatabase } from 'database'
import { context as serverContext } from './context' import { context as serverContext } from './context'
import { cors } from './cors' import { cors } from './cors'
import { i18n } from './localization' import { i18n } from './localization'
import { plugins } from './plugins' import { plugins } from './plugins'
import { jwks, openidConfiguration } from '@/openIDConnect'
// TODO implement // TODO implement
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity"; // import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
@ -84,6 +85,10 @@ export const createServer = async (
app.get('/hook/gms/' + CONFIG.GMS_WEBHOOK_SECRET, gmsWebhook) app.get('/hook/gms/' + CONFIG.GMS_WEBHOOK_SECRET, gmsWebhook)
// OpenID Connect
app.get(`/realms/${GRADIDO_REALM}/.well-known/openid-configuration`, openidConfiguration)
app.get(`/realms/${GRADIDO_REALM}/protocol/openid-connect/certs`, jwks)
// Apollo Server // Apollo Server
const apollo = new ApolloServer({ const apollo = new ApolloServer({
schema: await schema(), schema: await schema(),

View File

@ -16,7 +16,7 @@
}, },
"admin": { "admin": {
"name": "admin", "name": "admin",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"@iconify/json": "^2.2.228", "@iconify/json": "^2.2.228",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
@ -85,7 +85,7 @@
}, },
"backend": { "backend": {
"name": "backend", "name": "backend",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"email-templates": "^10.0.1", "email-templates": "^10.0.1",
@ -162,7 +162,7 @@
}, },
"config-schema": { "config-schema": {
"name": "config-schema", "name": "config-schema",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"esbuild": "^0.25.2", "esbuild": "^0.25.2",
"joi": "^17.13.3", "joi": "^17.13.3",
@ -180,7 +180,7 @@
}, },
"core": { "core": {
"name": "core", "name": "core",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"database": "*", "database": "*",
"esbuild": "^0.25.2", "esbuild": "^0.25.2",
@ -198,7 +198,7 @@
}, },
"database": { "database": {
"name": "database", "name": "database",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -233,12 +233,12 @@
"ts-jest": "27.0.5", "ts-jest": "27.0.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vitest": "^3.2.4", "vitest": "^2.0.5",
}, },
}, },
"dht-node": { "dht-node": {
"name": "dht-node", "name": "dht-node",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dht-rpc": "6.18.1", "dht-rpc": "6.18.1",
@ -276,7 +276,7 @@
}, },
"federation": { "federation": {
"name": "federation", "name": "federation",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"sodium-native": "^3.4.1", "sodium-native": "^3.4.1",
@ -328,7 +328,7 @@
}, },
"frontend": { "frontend": {
"name": "frontend", "name": "frontend",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"@morev/vue-transitions": "^3.0.2", "@morev/vue-transitions": "^3.0.2",
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",
@ -423,7 +423,7 @@
}, },
"shared": { "shared": {
"name": "shared", "name": "shared",
"version": "2.6.0", "version": "2.6.1",
"dependencies": { "dependencies": {
"decimal.js-light": "^2.5.1", "decimal.js-light": "^2.5.1",
"esbuild": "^0.25.2", "esbuild": "^0.25.2",
@ -1011,8 +1011,6 @@
"@types/body-parser": ["@types/body-parser@1.19.5", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg=="], "@types/body-parser": ["@types/body-parser@1.19.5", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/content-disposition": ["@types/content-disposition@0.5.8", "", {}, "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg=="], "@types/content-disposition": ["@types/content-disposition@0.5.8", "", {}, "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg=="],
@ -1021,8 +1019,6 @@
"@types/cors": ["@types/cors@2.8.10", "", {}, "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ=="], "@types/cors": ["@types/cors@2.8.10", "", {}, "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/dotenv": ["@types/dotenv@8.2.3", "", { "dependencies": { "dotenv": "*" } }, "sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw=="], "@types/dotenv": ["@types/dotenv@8.2.3", "", { "dependencies": { "dotenv": "*" } }, "sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw=="],
"@types/email-templates": ["@types/email-templates@10.0.4", "", { "dependencies": { "@types/html-to-text": "*", "@types/nodemailer": "*", "juice": "^8.0.0" } }, "sha512-8O2bdGPO6RYgH2DrnFAcuV++s+8KNA5e2Erjl6UxgKRVsBH9zXu2YLrLyOBRMn2VyEYmzgF+6QQUslpVhj0y/g=="], "@types/email-templates": ["@types/email-templates@10.0.4", "", { "dependencies": { "@types/html-to-text": "*", "@types/nodemailer": "*", "juice": "^8.0.0" } }, "sha512-8O2bdGPO6RYgH2DrnFAcuV++s+8KNA5e2Erjl6UxgKRVsBH9zXu2YLrLyOBRMn2VyEYmzgF+6QQUslpVhj0y/g=="],
@ -2707,7 +2703,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
@ -3117,7 +3113,7 @@
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
"tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="], "tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="],
@ -3533,10 +3529,10 @@
"@nuxt/kit/pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], "@nuxt/kit/pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="],
"@nuxt/kit/tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@rollup/pluginutils/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"@selderee/plugin-htmlparser2/domhandler": ["domhandler@4.3.1", "", { "dependencies": { "domelementtype": "^2.2.0" } }, "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ=="], "@selderee/plugin-htmlparser2/domhandler": ["domhandler@4.3.1", "", { "dependencies": { "domelementtype": "^2.2.0" } }, "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ=="],
"@swc/cli/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "@swc/cli/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
@ -3603,8 +3599,6 @@
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"apollo-boost/ts-invariant": ["ts-invariant@0.4.4", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA=="], "apollo-boost/ts-invariant": ["ts-invariant@0.4.4", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA=="],
"apollo-boost/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "apollo-boost/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
@ -3697,8 +3691,6 @@
"database/ts-jest": ["ts-jest@27.0.5", "", { "dependencies": { "bs-logger": "0.x", "fast-json-stable-stringify": "2.x", "jest-util": "^27.0.0", "json5": "2.x", "lodash": "4.x", "make-error": "1.x", "semver": "7.x", "yargs-parser": "20.x" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@types/jest": "^27.0.0", "babel-jest": ">=27.0.0 <28", "jest": "^27.0.0", "typescript": ">=3.8 <5.0" }, "optionalPeers": ["@babel/core", "@types/jest", "babel-jest"], "bin": { "ts-jest": "cli.js" } }, "sha512-lIJApzfTaSSbtlksfFNHkWOzLJuuSm4faFAfo5kvzOiRAuoN4/eKxVJ2zEAho8aecE04qX6K1pAzfH5QHL1/8w=="], "database/ts-jest": ["ts-jest@27.0.5", "", { "dependencies": { "bs-logger": "0.x", "fast-json-stable-stringify": "2.x", "jest-util": "^27.0.0", "json5": "2.x", "lodash": "4.x", "make-error": "1.x", "semver": "7.x", "yargs-parser": "20.x" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@types/jest": "^27.0.0", "babel-jest": ">=27.0.0 <28", "jest": "^27.0.0", "typescript": ">=3.8 <5.0" }, "optionalPeers": ["@babel/core", "@types/jest", "babel-jest"], "bin": { "ts-jest": "cli.js" } }, "sha512-lIJApzfTaSSbtlksfFNHkWOzLJuuSm4faFAfo5kvzOiRAuoN4/eKxVJ2zEAho8aecE04qX6K1pAzfH5QHL1/8w=="],
"database/vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"dht-node/@types/jest": ["@types/jest@27.5.1", "", { "dependencies": { "jest-matcher-utils": "^27.0.0", "pretty-format": "^27.0.0" } }, "sha512-fUy7YRpT+rHXto1YlL+J9rs0uLGyiqVt3ZOTQR+4ROc47yNl8WLdVLgUloBRhOxP1PZvguHl44T3H0wAWxahYQ=="], "dht-node/@types/jest": ["@types/jest@27.5.1", "", { "dependencies": { "jest-matcher-utils": "^27.0.0", "pretty-format": "^27.0.0" } }, "sha512-fUy7YRpT+rHXto1YlL+J9rs0uLGyiqVt3ZOTQR+4ROc47yNl8WLdVLgUloBRhOxP1PZvguHl44T3H0wAWxahYQ=="],
@ -3747,6 +3739,8 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fdir/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"federation/apollo-server-testing": ["apollo-server-testing@2.25.2", "", { "dependencies": { "apollo-server-core": "^2.25.2" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, "sha512-HjQV9wPbi/ZqpRbyyhNwCbaDnfjDM0hTRec5TOoOjurEZ/vh4hTPHwGkDZx3kbcWowhGxe2qoHM6KANSB/SxuA=="], "federation/apollo-server-testing": ["apollo-server-testing@2.25.2", "", { "dependencies": { "apollo-server-core": "^2.25.2" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, "sha512-HjQV9wPbi/ZqpRbyyhNwCbaDnfjDM0hTRec5TOoOjurEZ/vh4hTPHwGkDZx3kbcWowhGxe2qoHM6KANSB/SxuA=="],
"federation/helmet": ["helmet@7.2.0", "", {}, "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw=="], "federation/helmet": ["helmet@7.2.0", "", {}, "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw=="],
@ -3825,8 +3819,6 @@
"jest-util/@types/node": ["@types/node@18.19.96", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-PzBvgsZ7YdFs/Kng1BSW8IGv68/SPcOxYYhT7luxD7QyzIhFS1xPTpfK3K9eHBa7hVwlW+z8nN0mOd515yaduQ=="], "jest-util/@types/node": ["@types/node@18.19.96", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-PzBvgsZ7YdFs/Kng1BSW8IGv68/SPcOxYYhT7luxD7QyzIhFS1xPTpfK3K9eHBa7hVwlW+z8nN0mOd515yaduQ=="],
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jest-watcher/@types/node": ["@types/node@18.19.96", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-PzBvgsZ7YdFs/Kng1BSW8IGv68/SPcOxYYhT7luxD7QyzIhFS1xPTpfK3K9eHBa7hVwlW+z8nN0mOd515yaduQ=="], "jest-watcher/@types/node": ["@types/node@18.19.96", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-PzBvgsZ7YdFs/Kng1BSW8IGv68/SPcOxYYhT7luxD7QyzIhFS1xPTpfK3K9eHBa7hVwlW+z8nN0mOd515yaduQ=="],
"jest-worker/@types/node": ["@types/node@18.19.96", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-PzBvgsZ7YdFs/Kng1BSW8IGv68/SPcOxYYhT7luxD7QyzIhFS1xPTpfK3K9eHBa7hVwlW+z8nN0mOd515yaduQ=="], "jest-worker/@types/node": ["@types/node@18.19.96", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-PzBvgsZ7YdFs/Kng1BSW8IGv68/SPcOxYYhT7luxD7QyzIhFS1xPTpfK3K9eHBa7hVwlW+z8nN0mOd515yaduQ=="],
@ -3849,8 +3841,6 @@
"mailparser/tlds": ["tlds@1.255.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw=="], "mailparser/tlds": ["tlds@1.255.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"multimatch/@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], "multimatch/@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="],
@ -3967,6 +3957,8 @@
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"ts-jest/jest": ["jest@27.5.1", "", { "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", "jest-cli": "^27.5.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ=="], "ts-jest/jest": ["jest@27.5.1", "", { "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", "jest-cli": "^27.5.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ=="],
"ts-jest/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "ts-jest/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@ -3997,14 +3989,16 @@
"unimport/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "unimport/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"unimport/pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], "unimport/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"unimport/tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="], "unimport/pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="],
"unimport/unplugin": ["unplugin@2.3.2", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-3n7YA46rROb3zSj8fFxtxC/PqoyvYQ0llwz9wtUPUutr9ig09C8gGo5CWCwHrUzlqC1LLR43kxp5vEIyH1ac1w=="], "unimport/unplugin": ["unplugin@2.3.2", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-3n7YA46rROb3zSj8fFxtxC/PqoyvYQ0llwz9wtUPUutr9ig09C8gGo5CWCwHrUzlqC1LLR43kxp5vEIyH1ac1w=="],
"unplugin-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "unplugin-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"unplugin-utils/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"unplugin-vue-components/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "unplugin-vue-components/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unplugin-vue-components/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "unplugin-vue-components/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@ -4127,30 +4121,6 @@
"database/ts-jest/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], "database/ts-jest/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"database/vitest/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"database/vitest/@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"database/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"database/vitest/@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"database/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"database/vitest/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"database/vitest/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"database/vitest/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"database/vitest/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"database/vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"database/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"database/vitest/vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"dht-node/ts-jest/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], "dht-node/ts-jest/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"dht-rpc/sodium-universal/sodium-native": ["sodium-native@5.0.1", "", { "dependencies": { "require-addon": "^1.1.0", "which-runtime": "^1.2.1" } }, "sha512-Q305aUXc0OzK7VVRvWkeEQJQIHs6slhFwWpyqLB5iJqhpyt2lYIVu96Y6PQ7TABIlWXVF3YiWDU3xS2Snkus+g=="], "dht-rpc/sodium-universal/sodium-native": ["sodium-native@5.0.1", "", { "dependencies": { "require-addon": "^1.1.0", "which-runtime": "^1.2.1" } }, "sha512-Q305aUXc0OzK7VVRvWkeEQJQIHs6slhFwWpyqLB5iJqhpyt2lYIVu96Y6PQ7TABIlWXVF3YiWDU3xS2Snkus+g=="],
@ -4221,8 +4191,6 @@
"jest-worker/jest-util/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], "jest-worker/jest-util/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"jest-worker/jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"js-beautify/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "js-beautify/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"jsdom/parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="], "jsdom/parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
@ -4279,6 +4247,8 @@
"typeorm/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "typeorm/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"unctx/unplugin/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"unimport/pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], "unimport/pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
"unplugin-vue-components/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "unplugin-vue-components/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@ -4287,8 +4257,6 @@
"unplugin-vue-components/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "unplugin-vue-components/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"vite-plugin-html/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
@ -4365,8 +4333,6 @@
"cheerio-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "cheerio-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"chokidar-cli/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chokidar-cli/yargs/cliui/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], "chokidar-cli/yargs/cliui/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
"chokidar-cli/yargs/cliui/wrap-ansi": ["wrap-ansi@5.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "string-width": "^3.0.0", "strip-ansi": "^5.0.0" } }, "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q=="], "chokidar-cli/yargs/cliui/wrap-ansi": ["wrap-ansi@5.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "string-width": "^3.0.0", "strip-ansi": "^5.0.0" } }, "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q=="],
@ -4383,12 +4349,6 @@
"css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"database/vitest/@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"database/vitest/@vitest/spy/tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="],
"database/vitest/@vitest/utils/loupe": ["loupe@3.1.4", "", {}, "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg=="],
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@ -4413,8 +4373,6 @@
"mailparser/html-to-text/selderee/parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], "mailparser/html-to-text/selderee/parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"nodemon/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"run-applescript/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], "run-applescript/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="],
@ -4431,8 +4389,6 @@
"typeorm/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "typeorm/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"unplugin-vue-components/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"unplugin-vue-components/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "unplugin-vue-components/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"vue-apollo/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "vue-apollo/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],

View File

@ -1,6 +1,6 @@
{ {
"name": "config-schema", "name": "config-schema",
"version": "2.6.0", "version": "2.6.1",
"description": "Gradido Config for validate config", "description": "Gradido Config for validate config",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./src/index.ts", "types": "./src/index.ts",

View File

@ -92,7 +92,7 @@ export const GMS_ACTIVE = Joi.boolean()
.required() .required()
export const GDT_ACTIVE = Joi.boolean() export const GDT_ACTIVE = Joi.boolean()
.description('Flag to indicate if the GMS (Geographic Member Search) service is used.') .description('Flag to indicate if the GDT (Gradido Transform) service is used.')
.default(false) .default(false)
.required() .required()

View File

@ -1,6 +1,6 @@
{ {
"name": "core", "name": "core",
"version": "2.6.0", "version": "2.6.1",
"description": "Gradido Core Code, High-Level Shared Code, with dependencies on other modules", "description": "Gradido Core Code, High-Level Shared Code, with dependencies on other modules",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./src/index.ts", "types": "./src/index.ts",

View File

@ -15,13 +15,13 @@ export const interpretEncryptedTransferArgs = async (args: EncryptedTransferArgs
// first find with args.publicKey the community 'requestingCom', which starts the request // first find with args.publicKey the community 'requestingCom', which starts the request
const requestingCom = await DbCommunity.findOneBy({ publicKey: Buffer.from(args.publicKey, 'hex') }) const requestingCom = await DbCommunity.findOneBy({ publicKey: Buffer.from(args.publicKey, 'hex') })
if (!requestingCom) { if (!requestingCom) {
const errmsg = `unknown requesting community with publicKey ${args.publicKey}` const errmsg = `unknown requesting community with publicKey ${Buffer.from(args.publicKey, 'hex')}`
methodLogger.error(errmsg) methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID') methodLogger.removeContext('handshakeID')
throw new Error(errmsg) throw new Error(errmsg)
} }
if (!requestingCom.publicJwtKey) { if (!requestingCom.publicJwtKey) {
const errmsg = `missing publicJwtKey of requesting community with publicKey ${args.publicKey}` const errmsg = `missing publicJwtKey of requesting community with publicKey ${Buffer.from(args.publicKey, 'hex')}`
methodLogger.error(errmsg) methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID') methodLogger.removeContext('handshakeID')
throw new Error(errmsg) throw new Error(errmsg)
@ -31,7 +31,7 @@ export const interpretEncryptedTransferArgs = async (args: EncryptedTransferArgs
const homeCom = await getHomeCommunity() const homeCom = await getHomeCommunity()
const jwtPayload = await verifyAndDecrypt(args.handshakeID, args.jwt, homeCom!.privateJwtKey!, requestingCom.publicJwtKey) as JwtPayloadType const jwtPayload = await verifyAndDecrypt(args.handshakeID, args.jwt, homeCom!.privateJwtKey!, requestingCom.publicJwtKey) as JwtPayloadType
if (!jwtPayload) { if (!jwtPayload) {
const errmsg = `invalid payload of community with publicKey ${args.publicKey}` const errmsg = `invalid payload of community with publicKey ${Buffer.from(args.publicKey, 'hex')}`
methodLogger.error(errmsg) methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID') methodLogger.removeContext('handshakeID')
throw new Error(errmsg) throw new Error(errmsg)

View File

@ -56,27 +56,32 @@ ENV PATH="/root/.bun/bin:${PATH}"
FROM bun-base as installer FROM bun-base as installer
COPY --chown=app:app . . COPY --chown=app:app . .
RUN bun install --filter database --production --no-cache --frozen-lockfile
##################################################################################
# Build Shared ###################################################################
##################################################################################
FROM installer as build-shared
RUN bun install --filter shared --no-cache --frozen-lockfile \
&& cd shared && yarn typecheck && yarn build
################################################################################## ##################################################################################
# Build ########################################################################## # Build ##########################################################################
################################################################################## ##################################################################################
FROM installer as build FROM installer as build
RUN bun install --no-cache --frozen-lockfile \ RUN bun install --filter database --production --no-cache --frozen-lockfile
&& cd shared && yarn build \
&& cd ../database && yarn build && yarn typecheck
################################################################################## ##################################################################################
# PRODUCTION IMAGE ############################################################### # PRODUCTION IMAGE ###############################################################
################################################################################## ##################################################################################
FROM base as production FROM base as production
COPY --chown=app:app --from=installer ${DOCKER_WORKDIR}/src ./src COPY --chown=app:app --from=build-shared ${DOCKER_WORKDIR}/shared/build ./shared/build
COPY --chown=app:app --from=installer ${DOCKER_WORKDIR}/migration ./migration COPY --chown=app:app --from=build-shared ${DOCKER_WORKDIR}/shared/package.json ./shared/package.json
COPY --chown=app:app --from=installer ${DOCKER_WORKDIR}/node_modules ./node_modules COPY --chown=app:app --from=build ${DOCKER_WORKDIR}/database ./database
COPY --chown=app:app --from=installer ${DOCKER_WORKDIR}/package.json ./package.json COPY --chown=app:app --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --chown=app:app --from=installer ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig.json COPY --chown=app:app --from=build ${DOCKER_WORKDIR}/package.json ./package.json
################################################################################## ##################################################################################
# TEST UP ######################################################################## # TEST UP ########################################################################
@ -84,7 +89,7 @@ COPY --chown=app:app --from=installer ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig
FROM production as up FROM production as up
# Run command # Run command
CMD /bin/sh -c "yarn up" CMD /bin/sh -c "cd database && yarn up"
################################################################################## ##################################################################################
# TEST RESET ##################################################################### # TEST RESET #####################################################################
@ -92,7 +97,7 @@ CMD /bin/sh -c "yarn up"
FROM production as reset FROM production as reset
# Run command # Run command
CMD /bin/sh -c "yarn reset" CMD /bin/sh -c "cd database && yarn reset"
################################################################################## ##################################################################################
# TEST DOWN ###################################################################### # TEST DOWN ######################################################################
@ -100,4 +105,4 @@ CMD /bin/sh -c "yarn reset"
FROM production as down FROM production as down
# Run command # Run command
CMD /bin/sh -c "yarn down" CMD /bin/sh -c "cd database && yarn down"

View File

@ -0,0 +1,14 @@
/* MIGRATION TO ADD hiero topic id IN COMMUNITY TABLE
*
* This migration adds fields for the hiero topic id in the community.table
*/
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `communities` ADD COLUMN `hiero_topic_id` varchar(512) DEFAULT NULL AFTER `location`;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `communities` DROP COLUMN `hiero_topic_id`;')
}

View File

@ -0,0 +1,20 @@
/* MIGRATION TO INCREASE memo TO 512 in all tables which have a memo field
*
* This migration increases the memo field in all tables which have a memo field to 512
*/
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `contributions` MODIFY COLUMN `memo` varchar(512) NOT NULL;')
await queryFn('ALTER TABLE `contribution_links` MODIFY COLUMN `memo` varchar(512) NOT NULL;')
await queryFn('ALTER TABLE `pending_transactions` MODIFY COLUMN `memo` varchar(512) NOT NULL;')
await queryFn('ALTER TABLE `transactions` MODIFY COLUMN `memo` varchar(512) NOT NULL;')
await queryFn('ALTER TABLE `transaction_links` MODIFY COLUMN `memo` varchar(512) NOT NULL;')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `contributions` MODIFY COLUMN `memo` varchar(255) NOT NULL;')
await queryFn('ALTER TABLE `contribution_links` MODIFY COLUMN `memo` varchar(255) NOT NULL;')
await queryFn('ALTER TABLE `pending_transactions` MODIFY COLUMN `memo` varchar(255) NOT NULL;')
await queryFn('ALTER TABLE `transactions` MODIFY COLUMN `memo` varchar(255) NOT NULL;')
await queryFn('ALTER TABLE `transaction_links` MODIFY COLUMN `memo` varchar(255) NOT NULL;')
}

View File

@ -1,6 +1,6 @@
{ {
"name": "database", "name": "database",
"version": "2.6.0", "version": "2.6.1",
"description": "Gradido Database Tool to execute database migrations", "description": "Gradido Database Tool to execute database migrations",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./src/index.ts", "types": "./src/index.ts",
@ -46,7 +46,7 @@
"ts-jest": "27.0.5", "ts-jest": "27.0.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vitest": "^3.2.4" "vitest": "^2.0.5"
}, },
"dependencies": { "dependencies": {
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",

View File

@ -30,6 +30,10 @@ export class Community extends BaseEntity {
@Column({ name: 'private_key', type: 'binary', length: 64, nullable: true }) @Column({ name: 'private_key', type: 'binary', length: 64, nullable: true })
privateKey: Buffer | null privateKey: Buffer | null
/**
* Most of time a uuidv4 value, but could be also a uint32 number for a short amount of time, so please check before use
* in community authentication this field is used to store a oneTimePassCode (uint32 number)
*/
@Column({ @Column({
name: 'community_uuid', name: 'community_uuid',
type: 'char', type: 'char',
@ -69,6 +73,9 @@ export class Community extends BaseEntity {
}) })
location: Geometry | null location: Geometry | null
@Column({ name: 'hiero_topic_id', type: 'varchar', length: 255, nullable: true })
hieroTopicId: string | null
@CreateDateColumn({ @CreateDateColumn({
name: 'created_at', name: 'created_at',
type: 'datetime', type: 'datetime',

View File

@ -39,7 +39,7 @@ export class Contribution extends BaseEntity {
@Column({ type: 'datetime', nullable: false, name: 'contribution_date' }) @Column({ type: 'datetime', nullable: false, name: 'contribution_date' })
contributionDate: Date contributionDate: Date
@Column({ type: 'varchar', length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 512, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string memo: string
@Column({ @Column({

View File

@ -10,7 +10,7 @@ export class ContributionLink extends BaseEntity {
@Column({ type: 'varchar', length: 100, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 100, nullable: false, collation: 'utf8mb4_unicode_ci' })
name: string name: string
@Column({ type: 'varchar', length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 512, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string memo: string
@Column({ name: 'valid_from', type: 'datetime', nullable: false }) @Column({ name: 'valid_from', type: 'datetime', nullable: false })

View File

@ -71,7 +71,7 @@ export class PendingTransaction extends BaseEntity {
}) })
decayStart: Date | null decayStart: Date | null
@Column({ type: 'varchar', length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 512, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string memo: string
@Column({ name: 'creation_date', type: 'datetime', precision: 3, nullable: true, default: null }) @Column({ name: 'creation_date', type: 'datetime', precision: 3, nullable: true, default: null })

View File

@ -71,7 +71,7 @@ export class Transaction extends BaseEntity {
}) })
decayStart: Date | null decayStart: Date | null
@Column({ type: 'varchar', length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 512, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string memo: string
@Column({ name: 'creation_date', type: 'datetime', precision: 3, nullable: true, default: null }) @Column({ name: 'creation_date', type: 'datetime', precision: 3, nullable: true, default: null })

View File

@ -32,7 +32,7 @@ export class TransactionLink extends BaseEntity {
}) })
holdAvailableAmount: Decimal holdAvailableAmount: Decimal
@Column({ type: 'varchar', length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 512, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string memo: string
@Column({ type: 'varchar', length: 24, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ type: 'varchar', length: 24, nullable: false, collation: 'utf8mb4_unicode_ci' })

View File

@ -55,8 +55,9 @@ export const findUserByIdentifier = async (
where: { alias: identifier, community: communityWhere }, where: { alias: identifier, community: communityWhere },
relations: ['emailContact', 'community'], relations: ['emailContact', 'community'],
}) })
} else {
// should don't happen often, so we create only in the rare case a logger for it
getLogger(`${LOG4JS_QUERIES_CATEGORY_NAME}.user.findUserByIdentifier`).warn('Unknown identifier type', identifier)
} }
// should don't happen often, so we create only in the rare case a logger for it
getLogger(`${LOG4JS_QUERIES_CATEGORY_NAME}.user.findUserByIdentifier`).warn('Unknown identifier type', identifier)
return null return null
} }

View File

@ -129,6 +129,42 @@ server {
error_log $GRADIDO_LOG_PATH/nginx-error.hooks.log warn; error_log $GRADIDO_LOG_PATH/nginx-error.hooks.log warn;
} }
# Well-Known for openid connect
location /.well-known/ {
limit_req zone=backend burst=10 nodelay;
limit_conn addr 5;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:4000/realms/gradido/.well-known;
proxy_redirect off;
access_log $GRADIDO_LOG_PATH/nginx-access.well-known.log gradido_log;
error_log $GRADIDO_LOG_PATH/nginx-error.well-known.log warn;
}
# Well-Known for openid connect
location /realms/gradido {
limit_req zone=backend burst=10 nodelay;
limit_conn addr 5;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:4000/realms/gradido;
proxy_redirect off;
access_log $GRADIDO_LOG_PATH/nginx-access.well-known.log gradido_log;
error_log $GRADIDO_LOG_PATH/nginx-error.well-known.log warn;
}
# Admin Frontend # Admin Frontend
location /admin { location /admin {
limit_req zone=frontend burst=30 nodelay; limit_req zone=frontend burst=30 nodelay;

View File

@ -114,6 +114,42 @@ server {
error_log $GRADIDO_LOG_PATH/nginx-error.hooks.log warn; error_log $GRADIDO_LOG_PATH/nginx-error.hooks.log warn;
} }
# Well-Known for openid connect
location /.well-known/ {
limit_req zone=backend burst=10 nodelay;
limit_conn addr 5;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:4000/realms/gradido/.well-known;
proxy_redirect off;
access_log $GRADIDO_LOG_PATH/nginx-access.well-known.log gradido_log;
error_log $GRADIDO_LOG_PATH/nginx-error.well-known.log warn;
}
# Well-Known for openid connect
location /realms/gradido {
limit_req zone=backend burst=10 nodelay;
limit_conn addr 5;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:4000/realms/gradido;
proxy_redirect off;
access_log $GRADIDO_LOG_PATH/nginx-access.well-known.log gradido_log;
error_log $GRADIDO_LOG_PATH/nginx-error.well-known.log warn;
}
# Admin Frontend # Admin Frontend
location /admin { location /admin {
limit_req zone=frontend burst=30 nodelay; limit_req zone=frontend burst=30 nodelay;

View File

@ -1,6 +1,6 @@
{ {
"name": "dht-node", "name": "dht-node",
"version": "2.6.0", "version": "2.6.1",
"description": "Gradido dht-node module", "description": "Gradido dht-node module",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/", "repository": "https://github.com/gradido/gradido/",

View File

@ -48,7 +48,7 @@ export const startDHT = async (topic: string): Promise<void> => {
) as KeyPair ) as KeyPair
const pubKeyString = keyPair.publicKey.toString('hex') const pubKeyString = keyPair.publicKey.toString('hex')
logger.info(`keyPairDHT: publicKey=${pubKeyString}`) logger.info(`keyPairDHT: publicKey=${pubKeyString}`)
logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`) logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex').slice(0, 6)}`)
await writeHomeCommunityEntry(keyPair) await writeHomeCommunityEntry(keyPair)
const ownApiVersions = await writeFederatedHomeCommunityEntries(pubKeyString) const ownApiVersions = await writeFederatedHomeCommunityEntries(pubKeyString)

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
{ {
"name": "dlt-connector", "name": "dlt-connector",
"repository": "git@github.com:gradido/gradido.git", "repository": "git@github.com:gradido/gradido.git",
"version": "2.6.1",
"description": "Gradido DLT-Connector", "description": "Gradido DLT-Connector",
"author": "Gradido Academy - https://www.gradido.net", "author": "Gradido Academy - https://www.gradido.net",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "1.0.50",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "bun run src/index.ts", "start": "bun run src/index.ts",

1907
e2e-tests/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "typescript",
"devDependencies": {
"@playwright/test": "^1.53.1",
"@types/node": "^24.0.7",
},
},
},
"packages": {
"@playwright/test": ["@playwright/test@1.54.2", "", { "dependencies": { "playwright": "1.54.2" }, "bin": { "playwright": "cli.js" } }, "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA=="],
"@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.54.2", "", { "dependencies": { "playwright-core": "1.54.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw=="],
"playwright-core": ["playwright-core@1.54.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "federation", "name": "federation",
"version": "2.6.0", "version": "2.6.1",
"description": "Gradido federation module providing Gradido-Hub-Federation and versioned API for inter community communication", "description": "Gradido federation module providing Gradido-Hub-Federation and versioned API for inter community communication",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/federation", "repository": "https://github.com/gradido/gradido/federation",

View File

@ -31,7 +31,7 @@ export class AuthenticationClient {
methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('openConnectionCallback with endpoint', this.endpoint, args) methodLogger.debug('openConnectionCallback with endpoint', this.endpoint, args)
try { try {
const { data } = await this.client.rawRequest<any>(openConnectionCallback, { args }) const { data } = await this.client.rawRequest<{ openConnectionCallback: boolean }>(openConnectionCallback, { args })
methodLogger.debug('after openConnectionCallback: data:', data) methodLogger.debug('after openConnectionCallback: data:', data)
if (!data || !data.openConnectionCallback) { if (!data || !data.openConnectionCallback) {
@ -51,13 +51,13 @@ export class AuthenticationClient {
methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug('authenticate with endpoint=', this.endpoint) methodLogger.debug('authenticate with endpoint=', this.endpoint)
try { try {
const { data } = await this.client.rawRequest<any>(authenticate, { args }) const { data } = await this.client.rawRequest<{ authenticate: string }>(authenticate, { args })
methodLogger.debug('after authenticate: data:', data) methodLogger.debug('after authenticate: data:', data)
const authUuid: string = data?.authenticate const responseJwt = data?.authenticate
if (authUuid) { if (responseJwt) {
methodLogger.debug('received authenticated uuid', authUuid) methodLogger.debug('received authenticated uuid as jwt', responseJwt)
return authUuid return responseJwt
} }
} catch (err) { } catch (err) {
methodLogger.error('authenticate failed', { methodLogger.error('authenticate failed', {

View File

@ -9,11 +9,11 @@ import {
getHomeCommunity, getHomeCommunity,
} from 'database' } from 'database'
import { getLogger } from 'log4js' import { getLogger } from 'log4js'
import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, OpenConnectionJwtPayloadType } from 'shared' import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, OpenConnectionJwtPayloadType, uint32Schema, uuidv4Schema } from 'shared'
import { Arg, Mutation, Resolver } from 'type-graphql' import { Arg, Mutation, Resolver } from 'type-graphql'
import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity' import { startAuthentication, startOpenConnectionCallback } from '../util/authenticateCommunity'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver`) const createLogger = (method: string ) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.${method}`)
@Resolver() @Resolver()
export class AuthenticationResolver { export class AuthenticationResolver {
@ -22,45 +22,49 @@ export class AuthenticationResolver {
@Arg('data') @Arg('data')
args: EncryptedTransferArgs, args: EncryptedTransferArgs,
): Promise<boolean> { ): Promise<boolean> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.openConnection`) const methodLogger = createLogger('openConnection')
methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`openConnection() via apiVersion=1_0:`, args) methodLogger.debug(`openConnection() via apiVersion=1_0:`, args)
const openConnectionJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionJwtPayloadType try {
methodLogger.debug('openConnectionJwtPayload', openConnectionJwtPayload) const openConnectionJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionJwtPayloadType
if (!openConnectionJwtPayload) { methodLogger.debug('openConnectionJwtPayload', openConnectionJwtPayload)
const errmsg = `invalid OpenConnection payload of requesting community with publicKey` + args.publicKey if (!openConnectionJwtPayload) {
methodLogger.error(errmsg) const errmsg = `invalid OpenConnection payload of requesting community with publicKey` + args.publicKey
methodLogger.removeContext('handshakeID') methodLogger.error(errmsg)
throw new Error(errmsg) // no infos to the caller
} return true
if (openConnectionJwtPayload.tokentype !== OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE) { }
const errmsg = `invalid tokentype of community with publicKey` + args.publicKey if (openConnectionJwtPayload.tokentype !== OpenConnectionJwtPayloadType.OPEN_CONNECTION_TYPE) {
methodLogger.error(errmsg) const errmsg = `invalid tokentype of community with publicKey` + args.publicKey
methodLogger.removeContext('handshakeID') methodLogger.error(errmsg)
throw new Error(errmsg) // no infos to the caller
} return true
if (!openConnectionJwtPayload.url) { }
const errmsg = `invalid url of community with publicKey` + args.publicKey if (!openConnectionJwtPayload.url) {
methodLogger.error(errmsg) const errmsg = `invalid url of community with publicKey` + args.publicKey
methodLogger.removeContext('handshakeID') methodLogger.error(errmsg)
throw new Error(errmsg) // no infos to the caller
} return true
methodLogger.debug(`vor DbFedCommunity.findOneByOrFail()...`, { publicKey: args.publicKey }) }
const fedComA = await DbFedCommunity.findOneByOrFail({ publicKey: Buffer.from(args.publicKey, 'hex') }) methodLogger.debug(`vor DbFedCommunity.findOneByOrFail()...`, { publicKey: args.publicKey })
methodLogger.debug(`nach DbFedCommunity.findOneByOrFail()...`, fedComA) const fedComA = await DbFedCommunity.findOneByOrFail({ publicKey: Buffer.from(args.publicKey, 'hex') })
methodLogger.debug('fedComA', new FederatedCommunityLoggingView(fedComA)) methodLogger.debug(`nach DbFedCommunity.findOneByOrFail()...`, fedComA)
if (!openConnectionJwtPayload.url.startsWith(fedComA.endPoint)) { methodLogger.debug('fedComA', new FederatedCommunityLoggingView(fedComA))
const errmsg = `invalid url of community with publicKey` + args.publicKey if (!openConnectionJwtPayload.url.startsWith(fedComA.endPoint)) {
methodLogger.error(errmsg) const errmsg = `invalid url of community with publicKey` + args.publicKey
methodLogger.removeContext('handshakeID') methodLogger.error(errmsg)
throw new Error(errmsg) // no infos to the caller
} return true
}
// no await to respond immediately and invoke callback-request asynchronously // no await to respond immediately and invoke callback-request asynchronously
void startOpenConnectionCallback(args.handshakeID, args.publicKey, CONFIG.FEDERATION_API) void startOpenConnectionCallback(args.handshakeID, args.publicKey, CONFIG.FEDERATION_API)
methodLogger.debug('openConnection() successfully initiated callback and returns true immediately...') methodLogger.debug('openConnection() successfully initiated callback and returns true immediately...')
methodLogger.removeContext('handshakeID') return true
return true } catch (err) {
methodLogger.error('invalid jwt token:', err)
return true
}
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
@ -68,37 +72,41 @@ export class AuthenticationResolver {
@Arg('data') @Arg('data')
args: EncryptedTransferArgs, args: EncryptedTransferArgs,
): Promise<boolean> { ): Promise<boolean> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.openConnectionCallback`) const methodLogger = createLogger('openConnectionCallback')
methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args) methodLogger.debug(`openConnectionCallback() via apiVersion=1_0 ...`, args)
try {
// decrypt args.url with homeCom.privateJwtKey and verify signing with callbackFedCom.publicKey // decrypt args.url with homeCom.privateJwtKey and verify signing with callbackFedCom.publicKey
const openConnectionCallbackJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionCallbackJwtPayloadType const openConnectionCallbackJwtPayload = await interpretEncryptedTransferArgs(args) as OpenConnectionCallbackJwtPayloadType
if (!openConnectionCallbackJwtPayload) { if (!openConnectionCallbackJwtPayload) {
const errmsg = `invalid OpenConnectionCallback payload of requesting community with publicKey` + args.publicKey const errmsg = `invalid OpenConnectionCallback payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg) methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID') // no infos to the caller
throw new Error(errmsg) return true
} }
const endPoint = openConnectionCallbackJwtPayload.url.slice(0, openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1) const endPoint = openConnectionCallbackJwtPayload.url.slice(0, openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1)
const apiVersion = openConnectionCallbackJwtPayload.url.slice(openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1, openConnectionCallbackJwtPayload.url.length) const apiVersion = openConnectionCallbackJwtPayload.url.slice(openConnectionCallbackJwtPayload.url.lastIndexOf('/') + 1, openConnectionCallbackJwtPayload.url.length)
methodLogger.debug(`search fedComB per:`, endPoint, apiVersion) methodLogger.debug(`search fedComB per:`, endPoint, apiVersion)
const fedComB = await DbFedCommunity.findOneBy({ endPoint, apiVersion }) const fedComB = await DbFedCommunity.findOneBy({ endPoint, apiVersion })
if (!fedComB) { if (!fedComB) {
const errmsg = `unknown callback community with url` + openConnectionCallbackJwtPayload.url const errmsg = `unknown callback community with url` + openConnectionCallbackJwtPayload.url
methodLogger.error(errmsg) methodLogger.error(errmsg)
methodLogger.removeContext('handshakeID') // no infos to the caller
throw new Error(errmsg) return true
}
methodLogger.debug(
`found fedComB and start authentication:`,
new FederatedCommunityLoggingView(fedComB),
)
// no await to respond immediately and invoke authenticate-request asynchronously
void startAuthentication(args.handshakeID, openConnectionCallbackJwtPayload.oneTimeCode, fedComB)
methodLogger.debug('openConnectionCallback() successfully initiated authentication and returns true immediately...')
return true
} catch (err) {
methodLogger.error('invalid jwt token:', err)
return true
} }
methodLogger.debug(
`found fedComB and start authentication:`,
new FederatedCommunityLoggingView(fedComB),
)
// no await to respond immediately and invoke authenticate-request asynchronously
void startAuthentication(args.handshakeID, openConnectionCallbackJwtPayload.oneTimeCode, fedComB)
methodLogger.debug('openConnectionCallback() successfully initiated authentication and returns true immediately...')
methodLogger.removeContext('handshakeID')
return true
} }
@Mutation(() => String) @Mutation(() => String)
@ -106,32 +114,54 @@ export class AuthenticationResolver {
@Arg('data') @Arg('data')
args: EncryptedTransferArgs, args: EncryptedTransferArgs,
): Promise<string | null> { ): Promise<string | null> {
const methodLogger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.AuthenticationResolver.authenticate`) const methodLogger = createLogger('authenticate')
methodLogger.addContext('handshakeID', args.handshakeID) methodLogger.addContext('handshakeID', args.handshakeID)
methodLogger.debug(`authenticate() via apiVersion=1_0 ...`, args) methodLogger.debug(`authenticate() via apiVersion=1_0 ...`, args)
const authArgs = await interpretEncryptedTransferArgs(args) as AuthenticationJwtPayloadType try {
if (!authArgs) { const authArgs = await interpretEncryptedTransferArgs(args) as AuthenticationJwtPayloadType
const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey if (!authArgs) {
methodLogger.error(errmsg) const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey
methodLogger.removeContext('handshakeID') methodLogger.error(errmsg)
throw new Error(errmsg) // no infos to the caller
} return null
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: authArgs.oneTimeCode })
methodLogger.debug('found authCom:', new CommunityLoggingView(authCom))
if (authCom) {
authCom.communityUuid = authArgs.uuid
authCom.authenticatedAt = new Date()
await DbCommunity.save(authCom)
methodLogger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom))
const homeComB = await getHomeCommunity()
if (homeComB?.communityUuid) {
const responseArgs = new AuthenticationResponseJwtPayloadType(args.handshakeID,homeComB.communityUuid)
const responseJwt = await encryptAndSign(responseArgs, homeComB.privateJwtKey!, authCom.publicJwtKey!)
methodLogger.removeContext('handshakeID')
return responseJwt
} }
if (!uint32Schema.safeParse(authArgs.oneTimeCode).success) {
const errmsg = `invalid oneTimeCode: ${authArgs.oneTimeCode} for community with publicKey ${authArgs.publicKey}, expect uint32`
methodLogger.error(errmsg)
// no infos to the caller
return null
}
const authCom = await DbCommunity.findOneByOrFail({ communityUuid: authArgs.oneTimeCode })
if (authCom) {
methodLogger.debug('found authCom:', new CommunityLoggingView(authCom))
if (authCom.publicKey !== authArgs.publicKey) {
const errmsg = `corrupt authentication call detected, oneTimeCode: ${authArgs.oneTimeCode} doesn't belong to caller: ${authArgs.publicKey}`
methodLogger.error(errmsg)
// no infos to the caller
return null
}
const communityUuid = uuidv4Schema.safeParse(authArgs.uuid)
if (!communityUuid.success) {
const errmsg = `invalid uuid: ${authArgs.uuid} for community with publicKey ${authArgs.publicKey}`
methodLogger.error(errmsg)
// no infos to the caller
return null
}
authCom.communityUuid = communityUuid.data
authCom.authenticatedAt = new Date()
await DbCommunity.save(authCom)
methodLogger.debug('store authCom.uuid successfully:', new CommunityLoggingView(authCom))
const homeComB = await getHomeCommunity()
if (homeComB?.communityUuid) {
const responseArgs = new AuthenticationResponseJwtPayloadType(args.handshakeID,homeComB.communityUuid)
const responseJwt = await encryptAndSign(responseArgs, homeComB.privateJwtKey!, authCom.publicJwtKey!)
return responseJwt
}
}
return null
} catch (err) {
methodLogger.error('invalid jwt token:', err)
return null
} }
methodLogger.removeContext('handshakeID')
return null
} }
} }

View File

@ -8,8 +8,8 @@ import Decimal from 'decimal.js-light'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { getLogger } from 'log4js' import { getLogger } from 'log4js'
import { DataSource } from 'typeorm' import { DataSource } from 'typeorm'
import { SendCoinsArgs } from '../model/SendCoinsArgs' import { EncryptedTransferArgs } from 'core'
import { createKeyPair, encryptAndSign, SendCoinsJwtPayloadType, SendCoinsResponseJwtPayloadType, verifyAndDecrypt } from 'shared'
let mutate: ApolloServerTestClient['mutate'] // , con: Connection let mutate: ApolloServerTestClient['mutate'] // , con: Connection
// let query: ApolloServerTestClient['query'] // let query: ApolloServerTestClient['query']
@ -21,8 +21,8 @@ let testEnv: {
CONFIG.FEDERATION_API = '1_0' CONFIG.FEDERATION_API = '1_0'
let homeCom: DbCommunity let recipientCom: DbCommunity
let foreignCom: DbCommunity let senderCom: DbCommunity
let sendUser: DbUser let sendUser: DbUser
let sendContact: DbUserContact let sendContact: DbUserContact
let recipUser: DbUser let recipUser: DbUser
@ -37,7 +37,7 @@ beforeAll(async () => {
}) })
afterAll(async () => { afterAll(async () => {
// await cleanDB() await cleanDB()
if (testEnv.con?.isInitialized) { if (testEnv.con?.isInitialized) {
await testEnv.con.destroy() await testEnv.con.destroy()
} }
@ -45,53 +45,54 @@ afterAll(async () => {
describe('SendCoinsResolver', () => { describe('SendCoinsResolver', () => {
const voteForSendCoinsMutation = ` const voteForSendCoinsMutation = `
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
voteForSendCoins(data: $args) { voteForSendCoins(data: $args)
vote
recipGradidoID
recipFirstName
recipLastName
recipAlias
}
}` }`
const settleSendCoinsMutation = ` const settleSendCoinsMutation = `
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
settleSendCoins(data: $args) settleSendCoins(data: $args)
}` }`
const revertSendCoinsMutation = ` const revertSendCoinsMutation = `
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
revertSendCoins(data: $args) revertSendCoins(data: $args)
}` }`
const revertSettledSendCoinsMutation = ` const revertSettledSendCoinsMutation = `
mutation ($args: SendCoinsArgs!) { mutation ($args: EncryptedTransferArgs!) {
revertSettledSendCoins(data: $args) revertSettledSendCoins(data: $args)
}` }`
beforeEach(async () => { beforeEach(async () => {
await cleanDB() await cleanDB()
homeCom = DbCommunity.create() // Generate key pair using jose library
homeCom.foreign = false const { publicKey: homePublicKey, privateKey: homePrivateKey } = await createKeyPair();
homeCom.url = 'homeCom-url' recipientCom = DbCommunity.create()
homeCom.name = 'homeCom-Name' recipientCom.foreign = false
homeCom.description = 'homeCom-Description' recipientCom.url = 'homeCom-url'
homeCom.creationDate = new Date() recipientCom.name = 'homeCom-Name'
homeCom.publicKey = Buffer.from('homeCom-publicKey') recipientCom.description = 'homeCom-Description'
homeCom.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894eba' recipientCom.creationDate = new Date()
await DbCommunity.insert(homeCom) recipientCom.publicKey = Buffer.alloc(32, '15F92F8EC2EA685D5FD51EE3588F5B4805EBD330EF9EDD16043F3BA9C35C0D91', 'hex') // 'homeCom-publicKey', 'hex')
recipientCom.publicJwtKey = homePublicKey;
recipientCom.privateJwtKey = homePrivateKey;
recipientCom.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894eba'
await DbCommunity.insert(recipientCom)
foreignCom = DbCommunity.create() const { publicKey: foreignPublicKey, privateKey: foreignPrivateKey } = await createKeyPair();
foreignCom.foreign = true senderCom = DbCommunity.create()
foreignCom.url = 'foreignCom-url' senderCom.foreign = true
foreignCom.name = 'foreignCom-Name' senderCom.url = 'foreignCom-url'
foreignCom.description = 'foreignCom-Description' senderCom.name = 'foreignCom-Name'
foreignCom.creationDate = new Date() senderCom.description = 'foreignCom-Description'
foreignCom.publicKey = Buffer.from('foreignCom-publicKey') senderCom.creationDate = new Date()
foreignCom.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894ebb' senderCom.publicKey = Buffer.alloc(32, '15F92F8EC2EA685D5FD51EE3588F5B4805EBD330EF9EDD16043F3BA9C35C0D92', 'hex') // 'foreignCom-publicKey', 'hex')
await DbCommunity.insert(foreignCom) senderCom.publicJwtKey = foreignPublicKey;
senderCom.privateJwtKey = foreignPrivateKey;
senderCom.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894ebb'
await DbCommunity.insert(senderCom)
sendUser = DbUser.create() sendUser = DbUser.create()
sendUser.alias = 'sendUser-alias' sendUser.alias = 'sendUser-alias'
sendUser.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894eba' sendUser.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894ebb'
sendUser.firstName = 'sendUser-FirstName' sendUser.firstName = 'sendUser-FirstName'
sendUser.gradidoID = '56a55482-909e-46a4-bfa2-cd025e894ebc' sendUser.gradidoID = '56a55482-909e-46a4-bfa2-cd025e894ebc'
sendUser.lastName = 'sendUser-LastName' sendUser.lastName = 'sendUser-LastName'
@ -106,7 +107,7 @@ describe('SendCoinsResolver', () => {
recipUser = DbUser.create() recipUser = DbUser.create()
recipUser.alias = 'recipUser-alias' recipUser.alias = 'recipUser-alias'
recipUser.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894ebb' recipUser.communityUuid = '56a55482-909e-46a4-bfa2-cd025e894eba'
recipUser.firstName = 'recipUser-FirstName' recipUser.firstName = 'recipUser-FirstName'
recipUser.gradidoID = '56a55482-909e-46a4-bfa2-cd025e894ebd' recipUser.gradidoID = '56a55482-909e-46a4-bfa2-cd025e894ebd'
recipUser.lastName = 'recipUser-LastName' recipUser.lastName = 'recipUser-LastName'
@ -124,26 +125,33 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient community', () => { describe('unknown recipient community', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = 'invalid foreignCom' 'handshakeID',
args.recipientUserIdentifier = recipUser.gradidoID 'invalid recipientCom',
args.creationDate = new Date().toISOString() recipUser.gradidoID,
args.amount = new Decimal(100) new Date().toISOString(),
args.memo = 'X-Com-TX memo' new Decimal(100),
if (homeCom.communityUuid) { 'X-Com-TX memo',
args.senderCommunityUuid = homeCom.communityUuid senderCom.communityUuid!,
} sendUser.gradidoID,
args.senderUserUuid = sendUser.gradidoID fullName(sendUser.firstName, sendUser.lastName),
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) sendUser.alias
args.senderAlias = sendUser.alias )
// invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
const graphQLResponse = await mutate({
mutation: voteForSendCoinsMutation,
variables: { args },
})
expect( expect(
await mutate({ graphQLResponse,
mutation: voteForSendCoinsMutation,
variables: { args },
}),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('voteForSendCoins with wrong recipientCommunityUuid')], errors: [new GraphQLError('voteForSendCoins with wrong recipientCommunityUuid: invalid recipientCom')],
}), }),
) )
}) })
@ -152,20 +160,25 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient user', () => { describe('unknown recipient user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) { const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = foreignCom.communityUuid 'handshakeID',
} recipientCom.communityUuid!,
args.recipientUserIdentifier = 'invalid recipient' 'invalid recipient',
args.creationDate = new Date().toISOString() new Date().toISOString(),
args.amount = new Decimal(100) new Decimal(100),
args.memo = 'X-Com-TX memo' 'X-Com-TX memo',
if (homeCom.communityUuid) { senderCom.communityUuid!,
args.senderCommunityUuid = homeCom.communityUuid sendUser.gradidoID,
} fullName(sendUser.firstName, sendUser.lastName),
args.senderUserUuid = sendUser.gradidoID sendUser.alias
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) )
args.senderAlias = sendUser.alias // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: voteForSendCoinsMutation, mutation: voteForSendCoinsMutation,
@ -175,7 +188,7 @@ describe('SendCoinsResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'voteForSendCoins with unknown recipientUserIdentifier in the community=', 'voteForSendCoins with unknown recipientUserIdentifier in the community=homeCom-Name',
), ),
], ],
}), }),
@ -186,36 +199,42 @@ describe('SendCoinsResolver', () => {
describe('valid X-Com-TX voted per gradidoID', () => { describe('valid X-Com-TX voted per gradidoID', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) { const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = foreignCom.communityUuid 'handshakeID',
} recipientCom.communityUuid!,
args.recipientUserIdentifier = recipUser.gradidoID recipUser.gradidoID,
args.creationDate = new Date().toISOString() new Date().toISOString(),
args.amount = new Decimal(100) new Decimal(100),
args.memo = 'X-Com-TX memo' 'X-Com-TX memo',
if (homeCom.communityUuid) { senderCom.communityUuid!,
args.senderCommunityUuid = homeCom.communityUuid sendUser.gradidoID,
} fullName(sendUser.firstName, sendUser.lastName),
args.senderUserUuid = sendUser.gradidoID sendUser.alias
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) )
args.senderAlias = sendUser.alias // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
const responseJwt = await mutate({
mutation: voteForSendCoinsMutation,
variables: { args },
})
const voteResult = await verifyAndDecrypt('handshakeID', responseJwt.data.voteForSendCoins, senderCom.privateJwtKey!, recipientCom.publicJwtKey!) as SendCoinsResponseJwtPayloadType
expect( expect(
await mutate({ voteResult,
mutation: voteForSendCoinsMutation,
variables: { args },
}),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
data: { expiration: '10m',
voteForSendCoins: { handshakeID: 'handshakeID',
recipGradidoID: '56a55482-909e-46a4-bfa2-cd025e894ebd', recipGradidoID: '56a55482-909e-46a4-bfa2-cd025e894ebd',
recipFirstName: 'recipUser-FirstName', recipFirstName: 'recipUser-FirstName',
recipLastName: 'recipUser-LastName', recipLastName: 'recipUser-LastName',
recipAlias: 'recipUser-alias', recipAlias: 'recipUser-alias',
vote: true, tokentype: SendCoinsResponseJwtPayloadType.SEND_COINS_RESPONSE_TYPE,
}, vote: true,
},
}), }),
) )
}) })
@ -224,36 +243,39 @@ describe('SendCoinsResolver', () => {
describe('valid X-Com-TX voted per alias', () => { describe('valid X-Com-TX voted per alias', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) { const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = foreignCom.communityUuid 'handshakeID',
} recipientCom.communityUuid!,
args.recipientUserIdentifier = recipUser.alias recipUser.alias,
args.creationDate = new Date().toISOString() new Date().toISOString(),
args.amount = new Decimal(100) new Decimal(100),
args.memo = 'X-Com-TX memo' 'X-Com-TX memo',
if (homeCom.communityUuid) { senderCom.communityUuid!,
args.senderCommunityUuid = homeCom.communityUuid sendUser.gradidoID,
} fullName(sendUser.firstName, sendUser.lastName),
args.senderUserUuid = sendUser.gradidoID sendUser.alias
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) )
args.senderAlias = sendUser.alias // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
const responseJwt = await mutate({
mutation: voteForSendCoinsMutation,
variables: { args },
})
const voteResult = await verifyAndDecrypt('handshakeID', responseJwt.data.voteForSendCoins, senderCom.privateJwtKey!, recipientCom.publicJwtKey!) as SendCoinsResponseJwtPayloadType
expect( expect(
await mutate({ voteResult,
mutation: voteForSendCoinsMutation,
variables: { args },
}),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
data: { recipGradidoID: '56a55482-909e-46a4-bfa2-cd025e894ebd',
voteForSendCoins: { recipFirstName: 'recipUser-FirstName',
recipGradidoID: '56a55482-909e-46a4-bfa2-cd025e894ebd', recipLastName: 'recipUser-LastName',
recipFirstName: 'recipUser-FirstName', recipAlias: 'recipUser-alias',
recipLastName: 'recipUser-LastName', vote: true,
recipAlias: 'recipUser-alias',
vote: true,
},
},
}), }),
) )
}) })
@ -262,36 +284,40 @@ describe('SendCoinsResolver', () => {
describe('valid X-Com-TX voted per email', () => { describe('valid X-Com-TX voted per email', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) {
args.recipientCommunityUuid = foreignCom.communityUuid const payload = new SendCoinsJwtPayloadType(
} 'handshakeID',
args.recipientUserIdentifier = recipContact.email recipientCom.communityUuid!,
args.creationDate = new Date().toISOString() recipContact.email,
args.amount = new Decimal(100) new Date().toISOString(),
args.memo = 'X-Com-TX memo' new Decimal(100),
if (homeCom.communityUuid) { 'X-Com-TX memo',
args.senderCommunityUuid = homeCom.communityUuid senderCom.communityUuid!,
} sendUser.gradidoID,
args.senderUserUuid = sendUser.gradidoID fullName(sendUser.firstName, sendUser.lastName),
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) sendUser.alias
args.senderAlias = sendUser.alias )
// invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
const responseJwt = await mutate({
mutation: voteForSendCoinsMutation,
variables: { args },
})
const voteResult = await verifyAndDecrypt('handshakeID', responseJwt.data.voteForSendCoins, senderCom.privateJwtKey!, recipientCom.publicJwtKey!) as SendCoinsResponseJwtPayloadType
expect( expect(
await mutate({ voteResult,
mutation: voteForSendCoinsMutation,
variables: { args },
}),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
data: { recipGradidoID: '56a55482-909e-46a4-bfa2-cd025e894ebd',
voteForSendCoins: { recipFirstName: 'recipUser-FirstName',
recipGradidoID: '56a55482-909e-46a4-bfa2-cd025e894ebd', recipLastName: 'recipUser-LastName',
recipFirstName: 'recipUser-FirstName', recipAlias: 'recipUser-alias',
recipLastName: 'recipUser-LastName', vote: true,
recipAlias: 'recipUser-alias',
vote: true,
},
},
}), }),
) )
}) })
@ -302,20 +328,25 @@ describe('SendCoinsResolver', () => {
const creationDate = new Date() const creationDate = new Date()
beforeEach(async () => { beforeEach(async () => {
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) { const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = foreignCom.communityUuid 'handshakeID',
} recipientCom.communityUuid!,
args.recipientUserIdentifier = recipUser.gradidoID recipUser.gradidoID,
args.creationDate = creationDate.toISOString() creationDate.toISOString(),
args.amount = new Decimal(100) new Decimal(100),
args.memo = 'X-Com-TX memo' 'X-Com-TX memo',
if (homeCom.communityUuid) { senderCom.communityUuid!,
args.senderCommunityUuid = homeCom.communityUuid sendUser.gradidoID,
} fullName(sendUser.firstName, sendUser.lastName),
args.senderUserUuid = sendUser.gradidoID sendUser.alias
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) )
args.senderAlias = sendUser.alias // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
await mutate({ await mutate({
mutation: voteForSendCoinsMutation, mutation: voteForSendCoinsMutation,
variables: { args }, variables: { args },
@ -325,18 +356,25 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient community', () => { describe('unknown recipient community', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
args.recipientCommunityUuid = 'invalid foreignCom' const payload = new SendCoinsJwtPayloadType(
args.recipientUserIdentifier = recipUser.gradidoID 'handshakeID',
args.creationDate = creationDate.toISOString() 'invalid recipientCom',
args.amount = new Decimal(100) recipUser.gradidoID,
args.memo = 'X-Com-TX memo' creationDate.toISOString(),
if (homeCom.communityUuid) { new Decimal(100),
args.senderCommunityUuid = homeCom.communityUuid 'X-Com-TX memo',
} senderCom.communityUuid!,
args.senderUserUuid = sendUser.gradidoID sendUser.gradidoID,
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) fullName(sendUser.firstName, sendUser.lastName),
args.senderAlias = sendUser.alias sendUser.alias
)
// invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: revertSendCoinsMutation, mutation: revertSendCoinsMutation,
@ -344,7 +382,7 @@ describe('SendCoinsResolver', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('revertSendCoins with wrong recipientCommunityUuid')], errors: [new GraphQLError('revertSendCoins with wrong recipientCommunityUuid=invalid recipientCom')],
}), }),
) )
}) })
@ -353,20 +391,25 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient user', () => { describe('unknown recipient user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) { const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = foreignCom.communityUuid 'handshakeID',
} recipientCom.communityUuid!,
args.recipientUserIdentifier = 'invalid recipient' 'invalid recipient',
args.creationDate = creationDate.toISOString() creationDate.toISOString(),
args.amount = new Decimal(100) new Decimal(100),
args.memo = 'X-Com-TX memo' 'X-Com-TX memo',
if (homeCom.communityUuid) { senderCom.communityUuid!,
args.senderCommunityUuid = homeCom.communityUuid sendUser.gradidoID,
} fullName(sendUser.firstName, sendUser.lastName),
args.senderUserUuid = sendUser.gradidoID sendUser.alias
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) )
args.senderAlias = sendUser.alias // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: revertSendCoinsMutation, mutation: revertSendCoinsMutation,
@ -376,7 +419,7 @@ describe('SendCoinsResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'revertSendCoins with unknown recipientUserIdentifier in the community=', 'revertSendCoins with unknown recipientUserIdentifier in the community=homeCom-Name',
), ),
], ],
}), }),
@ -387,20 +430,26 @@ describe('SendCoinsResolver', () => {
describe('valid X-Com-TX reverted', () => { describe('valid X-Com-TX reverted', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs()
if (foreignCom.communityUuid) { const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = foreignCom.communityUuid 'handshakeID',
} recipientCom.communityUuid!,
args.recipientUserIdentifier = recipUser.gradidoID recipUser.gradidoID,
args.creationDate = creationDate.toISOString() creationDate.toISOString(),
args.amount = new Decimal(100) new Decimal(100),
args.memo = 'X-Com-TX memo' 'X-Com-TX memo',
if (homeCom.communityUuid) { senderCom.communityUuid!,
args.senderCommunityUuid = homeCom.communityUuid sendUser.gradidoID,
} fullName(sendUser.firstName, sendUser.lastName),
args.senderUserUuid = sendUser.gradidoID sendUser.alias
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) )
args.senderAlias = sendUser.alias // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: revertSendCoinsMutation, mutation: revertSendCoinsMutation,
@ -421,20 +470,25 @@ describe('SendCoinsResolver', () => {
const creationDate = new Date() const creationDate = new Date()
beforeEach(async () => { beforeEach(async () => {
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
if (foreignCom.communityUuid) { 'handshakeID',
args.recipientCommunityUuid = foreignCom.communityUuid recipientCom.communityUuid!,
} recipUser.gradidoID,
args.recipientUserIdentifier = recipUser.gradidoID creationDate.toISOString(),
args.creationDate = creationDate.toISOString() new Decimal(100),
args.amount = new Decimal(100) 'X-Com-TX memo',
args.memo = 'X-Com-TX memo' senderCom.communityUuid!,
if (homeCom.communityUuid) { sendUser.gradidoID,
args.senderCommunityUuid = homeCom.communityUuid fullName(sendUser.firstName, sendUser.lastName),
} sendUser.alias
args.senderUserUuid = sendUser.gradidoID )
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
args.senderAlias = sendUser.alias const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
await mutate({ await mutate({
mutation: voteForSendCoinsMutation, mutation: voteForSendCoinsMutation,
variables: { args }, variables: { args },
@ -444,18 +498,24 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient community', () => { describe('unknown recipient community', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = 'invalid foreignCom' 'handshakeID',
args.recipientUserIdentifier = recipUser.gradidoID 'invalid recipientCom',
args.creationDate = creationDate.toISOString() recipUser.gradidoID,
args.amount = new Decimal(100) creationDate.toISOString(),
args.memo = 'X-Com-TX memo' new Decimal(100),
if (homeCom.communityUuid) { 'X-Com-TX memo',
args.senderCommunityUuid = homeCom.communityUuid senderCom.communityUuid!,
} sendUser.gradidoID,
args.senderUserUuid = sendUser.gradidoID fullName(sendUser.firstName, sendUser.lastName),
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) sendUser.alias
args.senderAlias = sendUser.alias )
// invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: settleSendCoinsMutation, mutation: settleSendCoinsMutation,
@ -463,7 +523,7 @@ describe('SendCoinsResolver', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('settleSendCoins with wrong recipientCommunityUuid')], errors: [new GraphQLError('settleSendCoins with wrong recipientCommunityUuid=invalid recipientCom')],
}), }),
) )
}) })
@ -472,20 +532,24 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient user', () => { describe('unknown recipient user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
if (foreignCom.communityUuid) { 'handshakeID',
args.recipientCommunityUuid = foreignCom.communityUuid recipientCom.communityUuid!,
} 'invalid recipient',
args.recipientUserIdentifier = 'invalid recipient' creationDate.toISOString(),
args.creationDate = creationDate.toISOString() new Decimal(100),
args.amount = new Decimal(100) 'X-Com-TX memo',
args.memo = 'X-Com-TX memo' senderCom.communityUuid!,
if (homeCom.communityUuid) { sendUser.gradidoID,
args.senderCommunityUuid = homeCom.communityUuid fullName(sendUser.firstName, sendUser.lastName),
} sendUser.alias
args.senderUserUuid = sendUser.gradidoID )
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
args.senderAlias = sendUser.alias const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: settleSendCoinsMutation, mutation: settleSendCoinsMutation,
@ -495,7 +559,7 @@ describe('SendCoinsResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'settleSendCoins with unknown recipientUserIdentifier in the community=', 'settleSendCoins with unknown recipientUserIdentifier in the community=' + recipientCom.name,
), ),
], ],
}), }),
@ -506,20 +570,24 @@ describe('SendCoinsResolver', () => {
describe('valid X-Com-TX settled', () => { describe('valid X-Com-TX settled', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
if (foreignCom.communityUuid) { 'handshakeID',
args.recipientCommunityUuid = foreignCom.communityUuid recipientCom.communityUuid!,
} recipUser.gradidoID,
args.recipientUserIdentifier = recipUser.gradidoID creationDate.toISOString(),
args.creationDate = creationDate.toISOString() new Decimal(100),
args.amount = new Decimal(100) 'X-Com-TX memo',
args.memo = 'X-Com-TX memo' senderCom.communityUuid!,
if (homeCom.communityUuid) { sendUser.gradidoID,
args.senderCommunityUuid = homeCom.communityUuid fullName(sendUser.firstName, sendUser.lastName),
} sendUser.alias
args.senderUserUuid = sendUser.gradidoID )
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
args.senderAlias = sendUser.alias const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: settleSendCoinsMutation, mutation: settleSendCoinsMutation,
@ -540,20 +608,24 @@ describe('SendCoinsResolver', () => {
const creationDate = new Date() const creationDate = new Date()
beforeEach(async () => { beforeEach(async () => {
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
if (foreignCom.communityUuid) { 'handshakeID',
args.recipientCommunityUuid = foreignCom.communityUuid recipientCom.communityUuid!,
} recipUser.gradidoID,
args.recipientUserIdentifier = recipUser.gradidoID creationDate.toISOString(),
args.creationDate = creationDate.toISOString() new Decimal(100),
args.amount = new Decimal(100) 'X-Com-TX memo',
args.memo = 'X-Com-TX memo' senderCom.communityUuid!,
if (homeCom.communityUuid) { sendUser.gradidoID,
args.senderCommunityUuid = homeCom.communityUuid fullName(sendUser.firstName, sendUser.lastName),
} sendUser.alias
args.senderUserUuid = sendUser.gradidoID )
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
args.senderAlias = sendUser.alias const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
await mutate({ await mutate({
mutation: voteForSendCoinsMutation, mutation: voteForSendCoinsMutation,
variables: { args }, variables: { args },
@ -567,18 +639,24 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient community', () => { describe('unknown recipient community', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
args.recipientCommunityUuid = 'invalid foreignCom' 'handshakeID',
args.recipientUserIdentifier = recipUser.gradidoID 'invalid recipientCom',
args.creationDate = creationDate.toISOString() recipUser.gradidoID,
args.amount = new Decimal(100) creationDate.toISOString(),
args.memo = 'X-Com-TX memo' new Decimal(100),
if (homeCom.communityUuid) { 'X-Com-TX memo',
args.senderCommunityUuid = homeCom.communityUuid senderCom.communityUuid!,
} sendUser.gradidoID,
args.senderUserUuid = sendUser.gradidoID fullName(sendUser.firstName, sendUser.lastName),
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) sendUser.alias
args.senderAlias = sendUser.alias )
// invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: revertSettledSendCoinsMutation, mutation: revertSettledSendCoinsMutation,
@ -586,7 +664,7 @@ describe('SendCoinsResolver', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('revertSettledSendCoins with wrong recipientCommunityUuid')], errors: [new GraphQLError('revertSettledSendCoins with wrong recipientCommunityUuid=invalid recipientCom')],
}), }),
) )
}) })
@ -595,20 +673,24 @@ describe('SendCoinsResolver', () => {
describe('unknown recipient user', () => { describe('unknown recipient user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
if (foreignCom.communityUuid) { 'handshakeID',
args.recipientCommunityUuid = foreignCom.communityUuid recipientCom.communityUuid!,
} 'invalid recipient',
args.recipientUserIdentifier = 'invalid recipient' creationDate.toISOString(),
args.creationDate = creationDate.toISOString() new Decimal(100),
args.amount = new Decimal(100) 'X-Com-TX memo',
args.memo = 'X-Com-TX memo' senderCom.communityUuid!,
if (homeCom.communityUuid) { sendUser.gradidoID,
args.senderCommunityUuid = homeCom.communityUuid fullName(sendUser.firstName, sendUser.lastName),
} sendUser.alias
args.senderUserUuid = sendUser.gradidoID )
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
args.senderAlias = sendUser.alias const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: revertSettledSendCoinsMutation, mutation: revertSettledSendCoinsMutation,
@ -618,7 +700,7 @@ describe('SendCoinsResolver', () => {
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'revertSettledSendCoins with unknown recipientUserIdentifier in the community=', 'revertSettledSendCoins with unknown recipientUserIdentifier in the community=' + recipientCom.name,
), ),
], ],
}), }),
@ -629,20 +711,24 @@ describe('SendCoinsResolver', () => {
describe('valid X-Com-TX settled', () => { describe('valid X-Com-TX settled', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
const args = new SendCoinsArgs() const payload = new SendCoinsJwtPayloadType(
if (foreignCom.communityUuid) { 'handshakeID',
args.recipientCommunityUuid = foreignCom.communityUuid recipientCom.communityUuid!,
} recipUser.gradidoID,
args.recipientUserIdentifier = recipUser.gradidoID creationDate.toISOString(),
args.creationDate = creationDate.toISOString() new Decimal(100),
args.amount = new Decimal(100) 'X-Com-TX memo',
args.memo = 'X-Com-TX memo' senderCom.communityUuid!,
if (homeCom.communityUuid) { sendUser.gradidoID,
args.senderCommunityUuid = homeCom.communityUuid fullName(sendUser.firstName, sendUser.lastName),
} sendUser.alias
args.senderUserUuid = sendUser.gradidoID )
args.senderUserName = fullName(sendUser.firstName, sendUser.lastName) // invoke encryption as beeing on the foreignCom side to find in voteForSendCoins the correct homeCom
args.senderAlias = sendUser.alias const jws = await encryptAndSign(payload, senderCom.privateJwtKey!, recipientCom.publicJwtKey!)
const args = new EncryptedTransferArgs()
args.publicKey = senderCom.publicKey.toString('hex')
args.jwt = jws
args.handshakeID = 'handshakeID'
expect( expect(
await mutate({ await mutate({
mutation: revertSettledSendCoinsMutation, mutation: revertSettledSendCoinsMutation,

View File

@ -10,294 +10,363 @@ import Decimal from 'decimal.js-light'
import { getLogger } from 'log4js' import { getLogger } from 'log4js'
import { Arg, Mutation, Resolver } from 'type-graphql' import { Arg, Mutation, Resolver } from 'type-graphql'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { PendingTransactionState } from 'shared' import { encryptAndSign, PendingTransactionState, verifyAndDecrypt } from 'shared'
import { TransactionTypeId } from '../enum/TransactionTypeId' import { TransactionTypeId } from '../enum/TransactionTypeId'
import { SendCoinsArgsLoggingView } from '../logger/SendCoinsArgsLogging.view' import { SendCoinsArgsLoggingView } from '../logger/SendCoinsArgsLogging.view'
import { SendCoinsArgs } from '../model/SendCoinsArgs' import { SendCoinsArgs } from '../model/SendCoinsArgs'
import { SendCoinsResult } from '../model/SendCoinsResult' import { SendCoinsResponseJwtPayloadType } from 'shared'
import { calculateRecipientBalance } from '../util/calculateRecipientBalance' import { calculateRecipientBalance } from '../util/calculateRecipientBalance'
// import { checkTradingLevel } from '@/graphql/util/checkTradingLevel' // import { checkTradingLevel } from '@/graphql/util/checkTradingLevel'
import { revertSettledReceiveTransaction } from '../util/revertSettledReceiveTransaction' import { revertSettledReceiveTransaction } from '../util/revertSettledReceiveTransaction'
import { settlePendingReceiveTransaction } from '../util/settlePendingReceiveTransaction' import { settlePendingReceiveTransaction } from '../util/settlePendingReceiveTransaction'
import { storeForeignUser } from '../util/storeForeignUser' import { storeForeignUser } from '../util/storeForeignUser'
import { countOpenPendingTransactions } from 'database' import { countOpenPendingTransactions } from 'database'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.SendCoinsResolver`) import { EncryptedTransferArgs } from 'core'
import { interpretEncryptedTransferArgs } from 'core'
import { SendCoinsJwtPayloadType } from 'shared'
const createLogger = (method: string) => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.resolver.SendCoinsResolver.${method}`)
@Resolver() @Resolver()
export class SendCoinsResolver { export class SendCoinsResolver {
@Mutation(() => SendCoinsResult) @Mutation(() => String)
async voteForSendCoins( async voteForSendCoins(
@Arg('data') @Arg('data')
args: SendCoinsArgs, args: EncryptedTransferArgs,
): Promise<SendCoinsResult> { ): Promise<string> {
logger.debug(`voteForSendCoins() via apiVersion=1_0 ...`, new SendCoinsArgsLoggingView(args)) const methodLogger = createLogger(`voteForSendCoins`)
const result = new SendCoinsResult() methodLogger.addContext('handshakeID', args.handshakeID)
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`voteForSendCoins() via apiVersion=1_0 ...`, args)
}
const authArgs = await interpretEncryptedTransferArgs(args) as SendCoinsJwtPayloadType
if (!authArgs) {
const errmsg = `invalid authentication payload of requesting community with publicKey` + args.publicKey
methodLogger.error(errmsg)
throw new Error(errmsg)
}
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`voteForSendCoins() via apiVersion=1_0 ...`, authArgs)
}
// first check if receiver community is correct // first check if receiver community is correct
const homeCom = await DbCommunity.findOneBy({ const recipientCom = await DbCommunity.findOneBy({
communityUuid: args.recipientCommunityUuid, communityUuid: authArgs.recipientCommunityUuid,
}) })
if (!homeCom) { if (!recipientCom) {
throw new LogError( const errmsg = `voteForSendCoins with wrong recipientCommunityUuid: ${authArgs.recipientCommunityUuid}`
`voteForSendCoins with wrong recipientCommunityUuid`, methodLogger.error(errmsg)
args.recipientCommunityUuid, throw new Error(errmsg)
) }
const senderCom = await DbCommunity.findOneBy({
communityUuid: authArgs.senderCommunityUuid,
})
if (!senderCom) {
const errmsg = `voteForSendCoins with wrong senderCommunityUuid: ${authArgs.senderCommunityUuid}`
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
let receiverUser let receiverUser
// second check if receiver user exists in this community // second check if receiver user exists in this community
receiverUser = await findUserByIdentifier( receiverUser = await findUserByIdentifier(
args.recipientUserIdentifier, authArgs.recipientUserIdentifier,
args.recipientCommunityUuid, authArgs.recipientCommunityUuid,
) )
if (!receiverUser) { if (!receiverUser) {
logger.error('Error in findUserByIdentifier:') const errmsg = `voteForSendCoins with unknown recipientUserIdentifier in the community=` + recipientCom.name
throw new LogError( methodLogger.error(errmsg)
`voteForSendCoins with unknown recipientUserIdentifier in the community=`, throw new Error(errmsg)
homeCom.name,
)
} }
if (await countOpenPendingTransactions([args.senderUserUuid, receiverUser.gradidoID]) > 0) { if (await countOpenPendingTransactions([authArgs.senderUserUuid, receiverUser.gradidoID]) > 0) {
throw new LogError( const errmsg = `There exist still ongoing 'Pending-Transactions' for the involved users on receiver-side!`
`There exist still ongoing 'Pending-Transactions' for the involved users on receiver-side!`, methodLogger.error(errmsg)
) throw new Error(errmsg)
} }
try { try {
const txDate = new Date(args.creationDate) const txDate = new Date(authArgs.creationDate)
const receiveBalance = await calculateRecipientBalance(receiverUser.id, args.amount, txDate) const receiveBalance = await calculateRecipientBalance(receiverUser.id, authArgs.amount, txDate)
const pendingTx = DbPendingTransaction.create() const pendingTx = DbPendingTransaction.create()
pendingTx.amount = args.amount pendingTx.amount = authArgs.amount
pendingTx.balance = receiveBalance ? receiveBalance.balance : args.amount pendingTx.balance = receiveBalance ? receiveBalance.balance : authArgs.amount
pendingTx.balanceDate = txDate pendingTx.balanceDate = txDate
pendingTx.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) pendingTx.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
pendingTx.decayStart = receiveBalance ? receiveBalance.decay.start : null pendingTx.decayStart = receiveBalance ? receiveBalance.decay.start : null
pendingTx.creationDate = new Date() pendingTx.creationDate = new Date()
pendingTx.linkedUserCommunityUuid = args.senderCommunityUuid pendingTx.linkedUserCommunityUuid = authArgs.senderCommunityUuid
pendingTx.linkedUserGradidoID = args.senderUserUuid pendingTx.linkedUserGradidoID = authArgs.senderUserUuid
pendingTx.linkedUserName = args.senderUserName pendingTx.linkedUserName = authArgs.senderUserName
pendingTx.memo = args.memo pendingTx.memo = authArgs.memo
pendingTx.previous = receiveBalance ? receiveBalance.lastTransactionId : null pendingTx.previous = receiveBalance ? receiveBalance.lastTransactionId : null
pendingTx.state = PendingTransactionState.NEW pendingTx.state = PendingTransactionState.NEW
pendingTx.typeId = TransactionTypeId.RECEIVE pendingTx.typeId = TransactionTypeId.RECEIVE
pendingTx.userId = receiverUser.id pendingTx.userId = receiverUser.id
pendingTx.userCommunityUuid = args.recipientCommunityUuid pendingTx.userCommunityUuid = authArgs.recipientCommunityUuid
pendingTx.userGradidoID = receiverUser.gradidoID pendingTx.userGradidoID = receiverUser.gradidoID
pendingTx.userName = fullName(receiverUser.firstName, receiverUser.lastName) pendingTx.userName = fullName(receiverUser.firstName, receiverUser.lastName)
await DbPendingTransaction.insert(pendingTx) await DbPendingTransaction.insert(pendingTx)
result.vote = true const responseArgs = new SendCoinsResponseJwtPayloadType(
result.recipFirstName = receiverUser.firstName authArgs.handshakeID,
result.recipLastName = receiverUser.lastName true,
result.recipAlias = receiverUser.alias receiverUser.gradidoID,
result.recipGradidoID = receiverUser.gradidoID receiverUser.firstName,
logger.debug(`voteForSendCoins()-1_0... successfull`) receiverUser.lastName,
receiverUser.alias,
)
const responseJwt = await encryptAndSign(responseArgs, recipientCom.privateJwtKey!, senderCom.publicJwtKey!)
methodLogger.debug(`voteForSendCoins()-1_0... successfull`)
return responseJwt
} catch (err) { } catch (err) {
throw new LogError(`Error in voteForSendCoins: `, err) const errmsg = `Error in voteForSendCoins: ` + err
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
return result
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
async revertSendCoins( async revertSendCoins(
@Arg('data') @Arg('data')
args: SendCoinsArgs, args: EncryptedTransferArgs,
): Promise<boolean> { ): Promise<boolean> {
logger.debug(`revertSendCoins() via apiVersion=1_0 ...`) const methodLogger = createLogger(`revertSendCoins`)
methodLogger.addContext('handshakeID', args.handshakeID)
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`revertSendCoins() via apiVersion=1_0 ...`)
}
const authArgs = await interpretEncryptedTransferArgs(args) as SendCoinsJwtPayloadType
if (!authArgs) {
const errmsg = `invalid revertSendCoins payload of requesting community with publicKey=${args.publicKey}`
methodLogger.error(errmsg)
throw new Error(errmsg)
}
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`revertSendCoins() via apiVersion=1_0 ...`, authArgs)
}
// first check if receiver community is correct // first check if receiver community is correct
const homeCom = await DbCommunity.findOneBy({ const homeCom = await DbCommunity.findOneBy({
communityUuid: args.recipientCommunityUuid, communityUuid: authArgs.recipientCommunityUuid,
}) })
if (!homeCom) { if (!homeCom) {
throw new LogError( const errmsg = `revertSendCoins with wrong recipientCommunityUuid=${authArgs.recipientCommunityUuid}`
`revertSendCoins with wrong recipientCommunityUuid`, methodLogger.error(errmsg)
args.recipientCommunityUuid, throw new Error(errmsg)
)
} }
let receiverUser let receiverUser
// second check if receiver user exists in this community // second check if receiver user exists in this community
receiverUser = await findUserByIdentifier(args.recipientUserIdentifier) receiverUser = await findUserByIdentifier(authArgs.recipientUserIdentifier)
if (!receiverUser) { if (!receiverUser) {
logger.error('Error in findUserByIdentifier') const errmsg = `revertSendCoins with unknown recipientUserIdentifier in the community=${homeCom.name}`
throw new LogError( methodLogger.error(errmsg)
`revertSendCoins with unknown recipientUserIdentifier in the community=`, throw new Error(errmsg)
homeCom.name,
)
} }
try { try {
const pendingTx = await DbPendingTransaction.findOneBy({ const pendingTx = await DbPendingTransaction.findOneBy({
userCommunityUuid: args.recipientCommunityUuid, userCommunityUuid: authArgs.recipientCommunityUuid,
userGradidoID: receiverUser.gradidoID, userGradidoID: receiverUser.gradidoID,
state: PendingTransactionState.NEW, state: PendingTransactionState.NEW,
typeId: TransactionTypeId.RECEIVE, typeId: TransactionTypeId.RECEIVE,
balanceDate: new Date(args.creationDate), balanceDate: new Date(authArgs.creationDate),
linkedUserCommunityUuid: args.senderCommunityUuid, linkedUserCommunityUuid: authArgs.senderCommunityUuid,
linkedUserGradidoID: args.senderUserUuid, linkedUserGradidoID: authArgs.senderUserUuid,
}) })
logger.debug( if(methodLogger.isDebugEnabled()) {
'XCom: revertSendCoins found pendingTX=', methodLogger.debug(
pendingTx ? new PendingTransactionLoggingView(pendingTx) : 'null', 'XCom: revertSendCoins found pendingTX=',
) pendingTx ? new PendingTransactionLoggingView(pendingTx) : 'null',
if (pendingTx && pendingTx.amount.toString() === args.amount.toString()) { )
logger.debug('XCom: revertSendCoins matching pendingTX for remove...') }
if (pendingTx && pendingTx.amount.toString() === authArgs.amount.toString()) {
methodLogger.debug('XCom: revertSendCoins matching pendingTX for remove...')
try { try {
await pendingTx.remove() await pendingTx.remove()
logger.debug('XCom: revertSendCoins pendingTX for remove successfully') methodLogger.debug('XCom: revertSendCoins pendingTX for remove successfully')
} catch (err) { } catch (err) {
throw new LogError('Error in revertSendCoins on removing pendingTx of receiver: ', err) const errmsg = `Error in revertSendCoins on removing pendingTx of receiver: ` + err
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
} else { } else {
logger.debug( methodLogger.debug(
'XCom: revertSendCoins NOT matching pendingTX for remove:', 'XCom: revertSendCoins NOT matching pendingTX for remove:',
pendingTx?.amount.toString(), pendingTx?.amount.toString(),
args.amount.toString(), authArgs.amount.toString(),
) )
throw new LogError(`Can't find in revertSendCoins the pending receiver TX for `, { const errmsg = `Can't find in revertSendCoins the pending receiver TX for ` + {
args: new SendCoinsArgsLoggingView(args), args: new SendCoinsArgsLoggingView(authArgs),
pendingTransactionState: PendingTransactionState.NEW, pendingTransactionState: PendingTransactionState.NEW,
transactionType: TransactionTypeId.RECEIVE, transactionType: TransactionTypeId.RECEIVE,
}) }
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
logger.debug(`revertSendCoins()-1_0... successfull`) methodLogger.debug(`revertSendCoins()-1_0... successfull`)
return true return true
} catch (err) { } catch (err) {
throw new LogError(`Error in revertSendCoins: `, err) const errmsg = `Error in revertSendCoins: ` + err
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
async settleSendCoins( async settleSendCoins(
@Arg('data') @Arg('data')
args: SendCoinsArgs, args: EncryptedTransferArgs,
): Promise<boolean> { ): Promise<boolean> {
logger.debug(`settleSendCoins() via apiVersion=1_0 ...`, new SendCoinsArgsLoggingView(args)) const methodLogger = createLogger(`settleSendCoins`)
methodLogger.addContext('handshakeID', args.handshakeID)
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`settleSendCoins() via apiVersion=1_0 ...`, args)
}
const authArgs = await interpretEncryptedTransferArgs(args) as SendCoinsJwtPayloadType
if (!authArgs) {
const errmsg = `invalid settleSendCoins payload of requesting community with publicKey=${args.publicKey}`
methodLogger.error(errmsg)
throw new Error(errmsg)
}
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`settleSendCoins() via apiVersion=1_0 ...`, authArgs)
}
// first check if receiver community is correct // first check if receiver community is correct
const homeCom = await DbCommunity.findOneBy({ const homeCom = await DbCommunity.findOneBy({
communityUuid: args.recipientCommunityUuid, communityUuid: authArgs.recipientCommunityUuid,
}) })
if (!homeCom) { if (!homeCom) {
throw new LogError( const errmsg = `settleSendCoins with wrong recipientCommunityUuid=${authArgs.recipientCommunityUuid}`
`settleSendCoins with wrong recipientCommunityUuid`, methodLogger.error(errmsg)
args.recipientCommunityUuid, throw new Error(errmsg)
)
} }
// second check if receiver user exists in this community // second check if receiver user exists in this community
const receiverUser = await findUserByIdentifier(args.recipientUserIdentifier) const receiverUser = await findUserByIdentifier(authArgs.recipientUserIdentifier)
if (!receiverUser) { if (!receiverUser) {
logger.error('Error in findUserByIdentifier') const errmsg = `settleSendCoins with unknown recipientUserIdentifier in the community=${homeCom.name}`
throw new LogError( methodLogger.error(errmsg)
`settleSendCoins with unknown recipientUserIdentifier in the community=`, throw new Error(errmsg)
homeCom.name,
)
} }
const pendingTx = await DbPendingTransaction.findOneBy({ const pendingTx = await DbPendingTransaction.findOneBy({
userCommunityUuid: args.recipientCommunityUuid, userCommunityUuid: authArgs.recipientCommunityUuid,
userGradidoID: receiverUser.gradidoID, userGradidoID: receiverUser.gradidoID,
state: PendingTransactionState.NEW, state: PendingTransactionState.NEW,
typeId: TransactionTypeId.RECEIVE, typeId: TransactionTypeId.RECEIVE,
balanceDate: new Date(args.creationDate), balanceDate: new Date(authArgs.creationDate),
linkedUserCommunityUuid: args.senderCommunityUuid, linkedUserCommunityUuid: authArgs.senderCommunityUuid,
linkedUserGradidoID: args.senderUserUuid, linkedUserGradidoID: authArgs.senderUserUuid,
}) })
logger.debug( if(methodLogger.isDebugEnabled()) {
'XCom: settleSendCoins found pendingTX=', methodLogger.debug(
pendingTx ? new PendingTransactionLoggingView(pendingTx) : 'null', 'XCom: settleSendCoins found pendingTX=',
) pendingTx ? new PendingTransactionLoggingView(pendingTx) : 'null',
)
}
if ( if (
pendingTx && pendingTx &&
pendingTx.amount.toString() === args.amount.toString() && pendingTx.amount.toString() === authArgs.amount.toString() &&
pendingTx.memo === args.memo pendingTx.memo === authArgs.memo
) { ) {
logger.debug('XCom: settleSendCoins matching pendingTX for settlement...') methodLogger.debug('XCom: settleSendCoins matching pendingTX for settlement...')
await settlePendingReceiveTransaction(homeCom, receiverUser, pendingTx) await settlePendingReceiveTransaction(homeCom, receiverUser, pendingTx)
// after successful x-com-tx store the recipient as foreign user // after successful x-com-tx store the recipient as foreign user
logger.debug('store recipient as foreign user...') methodLogger.debug('store recipient as foreign user...')
if (await storeForeignUser(args)) { if (await storeForeignUser(authArgs)) {
logger.info( methodLogger.info(
'X-Com: new foreign user inserted successfully...', 'X-Com: new foreign user inserted successfully...',
args.senderCommunityUuid, authArgs.senderCommunityUuid,
args.senderUserUuid, authArgs.senderUserUuid,
) )
} }
logger.debug(`XCom: settlePendingReceiveTransaction()-1_0... successful`) methodLogger.debug(`XCom: settlePendingReceiveTransaction()-1_0... successful`)
return true return true
} else { } else {
logger.debug('XCom: settlePendingReceiveTransaction NOT matching pendingTX for settlement...') methodLogger.debug('XCom: settlePendingReceiveTransaction NOT matching pendingTX for settlement...')
throw new LogError( const errmsg = `Can't find in settlePendingReceiveTransaction the pending receiver TX for ` + {
`Can't find in settlePendingReceiveTransaction the pending receiver TX for `, args: new SendCoinsArgsLoggingView(authArgs),
{ pendingTransactionState: PendingTransactionState.NEW,
args: new SendCoinsArgsLoggingView(args),
pendingTransactionState: PendingTransactionState.NEW,
transactionTypeId: TransactionTypeId.RECEIVE, transactionTypeId: TransactionTypeId.RECEIVE,
}, }
) methodLogger.error(errmsg)
throw new Error(errmsg)
} }
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
async revertSettledSendCoins( async revertSettledSendCoins(
@Arg('data') @Arg('data')
args: SendCoinsArgs, args: EncryptedTransferArgs,
): Promise<boolean> { ): Promise<boolean> {
logger.debug(`revertSettledSendCoins() via apiVersion=1_0 ...`) const methodLogger = createLogger(`revertSettledSendCoins`)
methodLogger.addContext('handshakeID', args.handshakeID)
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`revertSettledSendCoins() via apiVersion=1_0 ...`)
}
const authArgs = await interpretEncryptedTransferArgs(args) as SendCoinsJwtPayloadType
if (!authArgs) {
const errmsg = `invalid revertSettledSendCoins payload of requesting community with publicKey=${args.publicKey}`
methodLogger.error(errmsg)
throw new Error(errmsg)
}
if(methodLogger.isDebugEnabled()) {
methodLogger.debug(`revertSettledSendCoins() via apiVersion=1_0 ...`, authArgs)
}
// first check if receiver community is correct // first check if receiver community is correct
const homeCom = await DbCommunity.findOneBy({ const homeCom = await DbCommunity.findOneBy({
communityUuid: args.recipientCommunityUuid, communityUuid: authArgs.recipientCommunityUuid,
}) })
if (!homeCom) { if (!homeCom) {
throw new LogError( const errmsg = `revertSettledSendCoins with wrong recipientCommunityUuid=${authArgs.recipientCommunityUuid}`
`revertSettledSendCoins with wrong recipientCommunityUuid`, methodLogger.error(errmsg)
args.recipientCommunityUuid, throw new Error(errmsg)
)
} }
// second check if receiver user exists in this community // second check if receiver user exists in this community
const receiverUser = await findUserByIdentifier(args.recipientUserIdentifier) const receiverUser = await findUserByIdentifier(authArgs.recipientUserIdentifier)
if (!receiverUser) { if (!receiverUser) {
logger.error('Error in findUserByIdentifier') const errmsg = `revertSettledSendCoins with unknown recipientUserIdentifier in the community=${homeCom.name}`
throw new LogError( methodLogger.error(errmsg)
`revertSettledSendCoins with unknown recipientUserIdentifier in the community=`, throw new Error(errmsg)
homeCom.name,
)
} }
const pendingTx = await DbPendingTransaction.findOneBy({ const pendingTx = await DbPendingTransaction.findOneBy({
userCommunityUuid: args.recipientCommunityUuid, userCommunityUuid: authArgs.recipientCommunityUuid,
userGradidoID: args.recipientUserIdentifier, userGradidoID: authArgs.recipientUserIdentifier,
state: PendingTransactionState.SETTLED, state: PendingTransactionState.SETTLED,
typeId: TransactionTypeId.RECEIVE, typeId: TransactionTypeId.RECEIVE,
balanceDate: new Date(args.creationDate), balanceDate: new Date(authArgs.creationDate),
linkedUserCommunityUuid: args.senderCommunityUuid, linkedUserCommunityUuid: authArgs.senderCommunityUuid,
linkedUserGradidoID: args.senderUserUuid, linkedUserGradidoID: authArgs.senderUserUuid,
}) })
logger.debug( methodLogger.debug(
'XCom: revertSettledSendCoins found pendingTX=', 'XCom: revertSettledSendCoins found pendingTX=',
pendingTx ? new PendingTransactionLoggingView(pendingTx) : 'null', pendingTx ? new PendingTransactionLoggingView(pendingTx) : 'null',
) )
if ( if (
pendingTx && pendingTx &&
pendingTx.amount.toString() === args.amount.toString() && pendingTx.amount.toString() === authArgs.amount.toString() &&
pendingTx.memo === args.memo pendingTx.memo === authArgs.memo
) { ) {
logger.debug('XCom: revertSettledSendCoins matching pendingTX for remove...') methodLogger.debug('XCom: revertSettledSendCoins matching pendingTX for remove...')
try { try {
await revertSettledReceiveTransaction(homeCom, receiverUser, pendingTx) await revertSettledReceiveTransaction(homeCom, receiverUser, pendingTx)
logger.debug('XCom: revertSettledSendCoins pendingTX successfully') methodLogger.debug('XCom: revertSettledSendCoins pendingTX successfully')
} catch (err) { } catch (err) {
throw new LogError('Error in revertSettledSendCoins of receiver: ', err) const errmsg = `Error in revertSettledSendCoins of receiver: ` + err
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
} else { } else {
logger.debug('XCom: revertSettledSendCoins NOT matching pendingTX...') methodLogger.debug('XCom: revertSettledSendCoins NOT matching pendingTX...')
throw new LogError(`Can't find in revertSettledSendCoins the pending receiver TX for `, { const errmsg = `Can't find in revertSettledSendCoins the pending receiver TX for ` + {
args: new SendCoinsArgsLoggingView(args), args: new SendCoinsArgsLoggingView(authArgs),
pendingTransactionState: PendingTransactionState.SETTLED, pendingTransactionState: PendingTransactionState.SETTLED,
transactionTypeId: TransactionTypeId.RECEIVE, transactionTypeId: TransactionTypeId.RECEIVE,
}) }
methodLogger.error(errmsg)
throw new Error(errmsg)
} }
logger.debug(`revertSendCoins()-1_0... successfull`) methodLogger.debug(`revertSettledSendCoins()-1_0... successfull`)
return true return true
} }
} }

View File

@ -14,7 +14,7 @@ import { randombytes_random } from 'sodium-native'
import { AuthenticationClient as V1_0_AuthenticationClient } from '@/client/1_0/AuthenticationClient' import { AuthenticationClient as V1_0_AuthenticationClient } from '@/client/1_0/AuthenticationClient'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const' import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, verifyAndDecrypt } from 'shared' import { AuthenticationJwtPayloadType, AuthenticationResponseJwtPayloadType, encryptAndSign, OpenConnectionCallbackJwtPayloadType, uuidv4Schema, verifyAndDecrypt } from 'shared'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity`) const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.api.1_0.util.authenticateCommunity`)
@ -40,8 +40,13 @@ export async function startOpenConnectionCallback(
apiVersion: api, apiVersion: api,
publicKey: comA.publicKey, publicKey: comA.publicKey,
}) })
const oneTimeCode = randombytes_random().toString()
// store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier // store oneTimeCode in requestedCom.community_uuid as authenticate-request-identifier
// prevent overwriting valid UUID with oneTimeCode, because this request could be initiated at any time from federated community
if (uuidv4Schema.safeParse(comA.communityUuid).success) {
throw new Error('Community UUID is already a valid UUID')
}
// TODO: make sure it is unique
const oneTimeCode = randombytes_random().toString()
comA.communityUuid = oneTimeCode comA.communityUuid = oneTimeCode
await DbCommunity.save(comA) await DbCommunity.save(comA)
methodLogger.debug( methodLogger.debug(

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.6.0", "version": "2.6.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently \"yarn watch-scss\" \"vite\"", "dev": "concurrently \"yarn watch-scss\" \"vite\"",
@ -82,7 +82,7 @@
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"chokidar-cli": "^3.0.0", "chokidar-cli": "^3.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"config-schema": "2.6.0", "config-schema": "*",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv-webpack": "^7.0.3", "dotenv-webpack": "^7.0.3",
"eslint": "8.57.1", "eslint": "8.57.1",

View File

@ -22,7 +22,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUpdated } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useQuery } from '@vue/apollo-composable' import { useQuery } from '@vue/apollo-composable'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { selectCommunities } from '@/graphql/queries' import { selectCommunities } from '@/graphql/queries'
@ -50,6 +50,9 @@ onResult(({ data }) => {
if (data) { if (data) {
communities.value = data.communities communities.value = data.communities
setDefaultCommunity() setDefaultCommunity()
if (data.communities.length === 1) {
validCommunityIdentifier.value = true
}
} }
}) })

View File

@ -2,24 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ContributionForm from './ContributionForm.vue' import ContributionForm from './ContributionForm.vue'
// Mock external components and dependencies
vi.mock('@/components/Inputs/InputAmount', () => ({
default: {
name: 'InputAmount',
template: '<input data-testid="input-amount" />',
},
}))
vi.mock('@/components/Inputs/InputTextarea', () => ({
default: {
name: 'InputTextarea',
template: '<textarea data-testid="input-textarea"></textarea>',
},
}))
vi.mock('vue-i18n', () => ({ vi.mock('vue-i18n', () => ({
useI18n: () => ({ useI18n: () => ({
t: (key) => key, t: (key) => key,
d: (date) => date,
}), }),
})) }))

View File

@ -19,6 +19,7 @@
class="mb-4 bg-248" class="mb-4 bg-248"
type="date" type="date"
:rules="validationSchema.fields.contributionDate" :rules="validationSchema.fields.contributionDate"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField" @update:model-value="updateField"
/> />
<div v-if="noOpenCreation" class="p-3" data-test="contribution-message"> <div v-if="noOpenCreation" class="p-3" data-test="contribution-message">
@ -33,6 +34,7 @@
:placeholder="$t('contribution.yourActivity')" :placeholder="$t('contribution.yourActivity')"
:rules="validationSchema.fields.memo" :rules="validationSchema.fields.memo"
textarea="true" textarea="true"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField" @update:model-value="updateField"
/> />
<ValidatedInput <ValidatedInput
@ -41,8 +43,9 @@
:label="$t('form.hours')" :label="$t('form.hours')"
placeholder="0.01" placeholder="0.01"
step="0.01" step="0.01"
type="number" type="text"
:rules="validationSchema.fields.hours" :rules="validationSchema.fields.hours"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField" @update:model-value="updateField"
/> />
<LabeledInput <LabeledInput
@ -68,7 +71,7 @@
{{ $t('form.cancel') }} {{ $t('form.cancel') }}
</BButton> </BButton>
</BCol> </BCol>
<BCol class="text-end mt-lg-0"> <BCol class="text-end mt-lg-0" @mouseover="disableSmartValidState = true">
<BButton <BButton
block block
type="submit" type="submit"
@ -89,9 +92,8 @@ import { reactive, computed, ref, onMounted, onUnmounted, toRaw } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ValidatedInput from '@/components/Inputs/ValidatedInput' import ValidatedInput from '@/components/Inputs/ValidatedInput'
import LabeledInput from '@/components/Inputs/LabeledInput' import LabeledInput from '@/components/Inputs/LabeledInput'
import { memo as memoSchema } from '@/validationSchemas'
import OpenCreationsAmount from './OpenCreationsAmount.vue' import OpenCreationsAmount from './OpenCreationsAmount.vue'
import { object, date as dateSchema, number } from 'yup' import { object, date as dateSchema, number, string } from 'yup'
import { GDD_PER_HOUR } from '../../constants' import { GDD_PER_HOUR } from '../../constants'
const amountToHours = (amount) => parseFloat(amount / GDD_PER_HOUR).toFixed(2) const amountToHours = (amount) => parseFloat(amount / GDD_PER_HOUR).toFixed(2)
@ -105,7 +107,7 @@ const props = defineProps({
const emit = defineEmits(['upsert-contribution', 'abort']) const emit = defineEmits(['upsert-contribution', 'abort'])
const { t } = useI18n() const { t, d } = useI18n()
const entityDataToForm = computed(() => ({ const entityDataToForm = computed(() => ({
...props.modelValue, ...props.modelValue,
@ -121,6 +123,7 @@ const entityDataToForm = computed(() => ({
const form = reactive({ ...entityDataToForm.value }) const form = reactive({ ...entityDataToForm.value })
const now = ref(new Date()) // checked every minute, updated if day, month or year changed const now = ref(new Date()) // checked every minute, updated if day, month or year changed
const disableSmartValidState = ref(false)
const isThisMonth = computed(() => { const isThisMonth = computed(() => {
const formContributionDate = new Date(form.contributionDate) const formContributionDate = new Date(form.contributionDate)
@ -147,16 +150,26 @@ const validationSchema = computed(() => {
// The date field is required and needs to be a valid date // The date field is required and needs to be a valid date
// contribution date // contribution date
contributionDate: dateSchema() contributionDate: dateSchema()
.required() .required('form.validation.contributionDate.required')
.min(minimalDate.value.toISOString().slice(0, 10)) // min date is first day of last month .min(minimalDate.value.toISOString().slice(0, 10), ({ min }) => ({
.max(now.value.toISOString().slice(0, 10)), // date cannot be in the future key: 'form.validation.contributionDate.min',
memo: memoSchema, values: { min: d(min) },
})) // min date is first day of last month
.max(now.value.toISOString().slice(0, 10), ({ max }) => ({
key: 'form.validation.contributionDate.max',
values: { max: d(max) },
})), // date cannot be in the future
memo: string()
.min(5, ({ min }) => ({ key: 'form.validation.contributionMemo.min', values: { min } }))
.max(512, ({ max }) => ({ key: 'form.validation.contributionMemo.max', values: { max } }))
.required('form.validation.contributionMemo.required'),
hours: number() hours: number()
.typeError({ key: 'form.validation.hours.typeError', values: { min: 0.01, max: maxHours } })
.required() .required()
.transform((value, originalValue) => (originalValue === '' ? undefined : value)) // .transform((value, originalValue) => (originalValue === '' ? undefined : value))
.min(0.01, ({ min }) => ({ key: 'form.validation.gddCreationTime.min', values: { min } })) .min(0.01, ({ min }) => ({ key: 'form.validation.hours.min', values: { min } }))
.max(maxHours, ({ max }) => ({ key: 'form.validation.gddCreationTime.max', values: { max } })) .max(maxHours, ({ max }) => ({ key: 'form.validation.hours.max', values: { max } }))
.test('decimal-places', 'form.validation.gddCreationTime.decimal-places', (value) => { .test('decimal-places', 'form.validation.hours.decimal-places', (value) => {
if (value === undefined || value === null) return true if (value === undefined || value === null) return true
return /^\d+(\.\d{0,2})?$/.test(value.toString()) return /^\d+(\.\d{0,2})?$/.test(value.toString())
}), }),

View File

@ -22,7 +22,7 @@ const i18n = createI18n({
}, },
edit: 'Edit', edit: 'Edit',
delete: 'Delete', delete: 'Delete',
moderatorChat: 'Chat', Chat: 'Chat',
}, },
}, },
datetimeFormats: { datetimeFormats: {

View File

@ -22,7 +22,7 @@ const i18n = createI18n({
}, },
edit: 'Edit', edit: 'Edit',
delete: 'Delete', delete: 'Delete',
moderatorChat: 'Chat', Chat: 'Chat',
}, },
}, },
datetimeFormats: { datetimeFormats: {

View File

@ -25,6 +25,8 @@
@click="emit('toggle-messages-visible')" @click="emit('toggle-messages-visible')"
> >
{{ $t('contribution.alert.answerQuestion') }} {{ $t('contribution.alert.answerQuestion') }}
<br />
{{ $t('answerNow') }}
</div> </div>
</BCol> </BCol>
<BCol cols="9" lg="3" offset="3" offset-md="0" offset-lg="0"> <BCol cols="9" lg="3" offset="3" offset-md="0" offset-lg="0">
@ -78,7 +80,7 @@
@click="emit('toggle-messages-visible')" @click="emit('toggle-messages-visible')"
> >
<IBiChatDots /> <IBiChatDots />
<div>{{ $t('moderatorChat') }}</div> <div>{{ $t('Chat') }}</div>
</div> </div>
</BCol> </BCol>
</BRow> </BRow>

View File

@ -3,8 +3,16 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import TransactionForm from './TransactionForm' import TransactionForm from './TransactionForm'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import { SEND_TYPES } from '@/utils/sendTypes' import { SEND_TYPES } from '@/utils/sendTypes'
import { BCard, BForm, BFormRadioGroup, BRow, BCol, BFormRadio, BButton } from 'bootstrap-vue-next' import {
import { useForm } from 'vee-validate' BCard,
BForm,
BFormRadioGroup,
BRow,
BCol,
BFormRadio,
BButton,
BFormInvalidFeedback,
} from 'bootstrap-vue-next'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
@ -35,23 +43,6 @@ vi.mock('@/composables/useToast', () => ({
})), })),
})) }))
vi.mock('vee-validate', () => {
const actualUseForm = vi.fn().mockReturnValue({
handleSubmit: vi.fn((callback) => {
return () =>
callback({
identifier: 'test@example.com',
amount: '100,00',
memo: 'Test memo',
})
}),
resetForm: vi.fn(),
defineField: vi.fn(() => [vi.fn(), {}]),
})
return { useForm: actualUseForm }
})
describe('TransactionForm', () => { describe('TransactionForm', () => {
let wrapper let wrapper
@ -64,6 +55,9 @@ describe('TransactionForm', () => {
mocks: { mocks: {
$t: mockT, $t: mockT,
$n: mockN, $n: mockN,
$i18n: {
locale: 'en',
},
}, },
components: { components: {
BCard, BCard,
@ -73,12 +67,11 @@ describe('TransactionForm', () => {
BCol, BCol,
BFormRadio, BFormRadio,
BButton, BButton,
BFormInvalidFeedback,
}, },
stubs: { stubs: {
'community-switch': true, 'community-switch': true,
'input-identifier': true, 'validated-input': true,
'input-amount': true,
'input-textarea': true,
}, },
}, },
props: { props: {
@ -102,15 +95,15 @@ describe('TransactionForm', () => {
describe('with balance <= 0.00 GDD the form is disabled', () => { describe('with balance <= 0.00 GDD the form is disabled', () => {
it('has a disabled input field of type text', () => { it('has a disabled input field of type text', () => {
expect(wrapper.find('input-identifier-stub').attributes('disabled')).toBe('true') expect(wrapper.find('#identifier').attributes('disabled')).toBe('true')
}) })
it('has a disabled input field for amount', () => { it('has a disabled input field for amount', () => {
expect(wrapper.find('input-amount-stub').attributes('disabled')).toBe('true') expect(wrapper.find('#amount').attributes('disabled')).toBe('true')
}) })
it('has a disabled textarea field', () => { it('has a disabled textarea field', () => {
expect(wrapper.find('input-textarea-stub').attributes('disabled')).toBe('true') expect(wrapper.find('#memo').attributes('disabled')).toBe('true')
}) })
it('has a message indicating that there are no GDDs to send', () => { it('has a message indicating that there are no GDDs to send', () => {
@ -143,41 +136,39 @@ describe('TransactionForm', () => {
describe('identifier field', () => { describe('identifier field', () => {
it('has an input field of type text', () => { it('has an input field of type text', () => {
expect(wrapper.find('input-identifier-stub').exists()).toBe(true) expect(wrapper.find('#identifier').exists()).toBe(true)
}) })
it('has a label form.recipient', () => { it('has a label form.recipient', () => {
expect(wrapper.find('input-identifier-stub').attributes('label')).toBe('form.recipient') expect(wrapper.find('#identifier').attributes('label')).toBe('form.recipient')
}) })
it('has a placeholder for identifier', () => { it('has a placeholder for identifier', () => {
expect(wrapper.find('input-identifier-stub').attributes('placeholder')).toBe( expect(wrapper.find('#identifier').attributes('placeholder')).toBe('form.identifier')
'form.identifier',
)
}) })
}) })
describe('amount field', () => { describe('amount field', () => {
it('has an input field of type text', () => { it('has an input field of type text', () => {
expect(wrapper.find('input-amount-stub').exists()).toBe(true) expect(wrapper.find('#amount').exists()).toBe(true)
}) })
it('has a label form.amount', () => { it('has a label form.amount', () => {
expect(wrapper.find('input-amount-stub').attributes('label')).toBe('form.amount') expect(wrapper.find('#amount').attributes('label')).toBe('form.amount')
}) })
it('has a placeholder "0.01"', () => { it('has a placeholder "0.01"', () => {
expect(wrapper.find('input-amount-stub').attributes('placeholder')).toBe('0.01') expect(wrapper.find('#amount').attributes('placeholder')).toBe('0.01')
}) })
}) })
describe('message text box', () => { describe('message text box', () => {
it('has a textarea field', () => { it('has a textarea field', () => {
expect(wrapper.find('input-textarea-stub').exists()).toBe(true) expect(wrapper.find('#memo').exists()).toBe(true)
}) })
it('has a label form.message', () => { it('has a label form.message', () => {
expect(wrapper.find('input-textarea-stub').attributes('label')).toBe('form.message') expect(wrapper.find('#memo').attributes('label')).toBe('form.message')
}) })
}) })
@ -233,8 +224,10 @@ describe('TransactionForm', () => {
}) })
it('emits set-transaction event with correct data when form is submitted', async () => { it('emits set-transaction event with correct data when form is submitted', async () => {
wrapper.vm.form.identifier = 'test@example.com'
wrapper.vm.form.amount = '100,00'
wrapper.vm.form.memo = 'Test memo'
await wrapper.findComponent(BForm).trigger('submit.prevent') await wrapper.findComponent(BForm).trigger('submit.prevent')
expect(wrapper.emitted('set-transaction')).toBeTruthy() expect(wrapper.emitted('set-transaction')).toBeTruthy()
expect(wrapper.emitted('set-transaction')[0][0]).toEqual( expect(wrapper.emitted('set-transaction')[0][0]).toEqual(
expect.objectContaining({ expect.objectContaining({
@ -247,20 +240,10 @@ describe('TransactionForm', () => {
}) })
it('handles form submission with empty amount', async () => { it('handles form submission with empty amount', async () => {
vi.mocked(useForm).mockReturnValueOnce({
...vi.mocked(useForm)(),
handleSubmit: vi.fn((callback) => {
return () =>
callback({
identifier: 'test@example.com',
amount: '',
memo: 'Test memo',
})
}),
})
wrapper = createWrapper({ balance: 100.0 }) wrapper = createWrapper({ balance: 100.0 })
await nextTick() await nextTick()
wrapper.vm.form.identifier = 'test@example.com'
wrapper.vm.form.memo = 'Test memo'
await wrapper.findComponent(BForm).trigger('submit.prevent') await wrapper.findComponent(BForm).trigger('submit.prevent')
expect(wrapper.emitted('set-transaction')).toBeTruthy() expect(wrapper.emitted('set-transaction')).toBeTruthy()

View File

@ -46,20 +46,25 @@
<BRow> <BRow>
<BCol class="fw-bold"> <BCol class="fw-bold">
<community-switch <community-switch
:disabled="isBalanceDisabled" :disabled="isBalanceEmpty"
:model-value="targetCommunity" :model-value="form.targetCommunity"
@update:model-value="targetCommunity = $event" @update:model-value="updateField($event, 'targetCommunity')"
/> />
</BCol> </BCol>
</BRow> </BRow>
</BCol> </BCol>
<BCol v-if="radioSelected === SEND_TYPES.send" cols="12"> <BCol v-if="radioSelected === SEND_TYPES.send" cols="12">
<div v-if="!userIdentifier"> <div v-if="!userIdentifier">
<input-identifier <ValidatedInput
id="identifier"
:model-value="form.identifier"
name="identifier" name="identifier"
:label="$t('form.recipient')" :label="$t('form.recipient')"
:placeholder="$t('form.identifier')" :placeholder="$t('form.identifier')"
:disabled="isBalanceDisabled" :rules="validationSchema.fields.identifier"
:disabled="isBalanceEmpty"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/> />
</div> </div>
<div v-else class="mb-4"> <div v-else class="mb-4">
@ -72,13 +77,17 @@
</div> </div>
</BCol> </BCol>
<BCol cols="12" lg="6"> <BCol cols="12" lg="6">
<input-amount <ValidatedInput
id="amount"
:model-value="form.amount"
name="amount" name="amount"
:label="$t('form.amount')" :label="$t('form.amount')"
:placeholder="'0.01'" :placeholder="'0.01'"
:rules="{ required: true, gddSendAmount: { min: 0.01, max: balance } }" :rules="validationSchema.fields.amount"
:disabled="isBalanceDisabled" :disabled="isBalanceEmpty"
></input-amount> :disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/>
</BCol> </BCol>
</BRow> </BRow>
</BCol> </BCol>
@ -86,16 +95,21 @@
<BRow> <BRow>
<BCol> <BCol>
<input-textarea <ValidatedInput
id="memo"
:model-value="form.memo"
name="memo" name="memo"
:label="$t('form.message')" :label="$t('form.message')"
:placeholder="$t('form.message')" :placeholder="$t('form.message')"
:rules="{ required: true, min: 5, max: 255 }" :rules="validationSchema.fields.memo"
:disabled="isBalanceDisabled" textarea="true"
:disabled="isBalanceEmpty"
:disable-smart-valid-state="disableSmartValidState"
@update:model-value="updateField"
/> />
</BCol> </BCol>
</BRow> </BRow>
<div v-if="!!isBalanceDisabled" class="text-danger mt-5"> <div v-if="!!isBalanceEmpty" class="text-danger mt-5">
{{ $t('form.no_gdd_available') }} {{ $t('form.no_gdd_available') }}
</div> </div>
<BRow v-else class="test-buttons mt-3"> <BRow v-else class="test-buttons mt-3">
@ -110,8 +124,14 @@
{{ $t('form.reset') }} {{ $t('form.reset') }}
</BButton> </BButton>
</BCol> </BCol>
<BCol cols="12" md="6" lg="6" class="text-lg-end"> <BCol
<BButton block type="submit" variant="gradido"> cols="12"
md="6"
lg="6"
class="text-lg-end"
@mouseover="disableSmartValidState = true"
>
<BButton block type="submit" variant="gradido" :disabled="formIsInvalid">
{{ $t('form.check_now') }} {{ $t('form.check_now') }}
</BButton> </BButton>
</BCol> </BCol>
@ -124,15 +144,14 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQuery } from '@vue/apollo-composable' import { useQuery } from '@vue/apollo-composable'
import { useForm } from 'vee-validate'
import { SEND_TYPES } from '@/utils/sendTypes' import { SEND_TYPES } from '@/utils/sendTypes'
import InputIdentifier from '@/components/Inputs/InputIdentifier'
import InputAmount from '@/components/Inputs/InputAmount'
import InputTextarea from '@/components/Inputs/InputTextarea'
import CommunitySwitch from '@/components/CommunitySwitch.vue' import CommunitySwitch from '@/components/CommunitySwitch.vue'
import ValidatedInput from '@/components/Inputs/ValidatedInput.vue'
import { memo as memoSchema, identifier as identifierSchema } from '@/validationSchemas'
import { object, number } from 'yup'
import { user } from '@/graphql/queries' import { user } from '@/graphql/queries'
import CONFIG from '@/config' import CONFIG from '@/config'
import { useAppToast } from '@/composables/useToast' import { useAppToast } from '@/composables/useToast'
@ -149,6 +168,10 @@ const props = defineProps({
}, },
}) })
const entityDataToForm = computed(() => ({ ...props }))
const form = reactive({ ...entityDataToForm.value })
const disableSmartValidState = ref(false)
const emit = defineEmits(['set-transaction']) const emit = defineEmits(['set-transaction'])
const route = useRoute() const route = useRoute()
@ -157,18 +180,6 @@ const { toastError } = useAppToast()
const radioSelected = ref(props.selected) const radioSelected = ref(props.selected)
const userName = ref('') const userName = ref('')
const recipientCommunity = ref({ uuid: '', name: '' })
const { handleSubmit, resetForm, defineField, values } = useForm({
initialValues: {
identifier: props.identifier,
amount: props.amount ? String(props.amount) : '',
memo: props.memo,
targetCommunity: props.targetCommunity,
},
})
const [targetCommunity, targetCommunityProps] = defineField('targetCommunity')
const userIdentifier = computed(() => { const userIdentifier = computed(() => {
if (route.params.userIdentifier && route.params.communityIdentifier) { if (route.params.userIdentifier && route.params.communityIdentifier) {
@ -180,7 +191,49 @@ const userIdentifier = computed(() => {
return null return null
}) })
const isBalanceDisabled = computed(() => props.balance <= 0) const validationSchema = computed(() => {
const amountSchema = number()
.required()
.typeError({
key: 'form.validation.amount.typeError',
values: { min: 0.01, max: props.balance },
})
.transform((value, originalValue) => {
if (typeof originalValue === 'string') {
return Number(originalValue.replace(',', '.'))
}
return value
})
.min(0.01, ({ min }) => ({ key: 'form.validation.amount.min', values: { min } }))
.max(props.balance, ({ max }) => ({ key: 'form.validation.amount.max', values: { max } }))
.test('decimal-places', 'form.validation.amount.decimal-places', (value) => {
if (value === undefined || value === null) return true
return /^\d+(\.\d{0,2})?$/.test(value.toString())
})
if (!userIdentifier.value && radioSelected.value === SEND_TYPES.send) {
return object({
memo: memoSchema,
amount: amountSchema,
identifier: identifierSchema,
})
} else {
// don't need identifier schema if it is a transaction link or identifier was set via url
return object({
memo: memoSchema,
amount: amountSchema,
})
}
})
const formIsInvalid = computed(() => !validationSchema.value.isValidSync(form))
const updateField = (newValue, name) => {
if (typeof name === 'string' && name.length) {
form[name] = newValue
}
}
const isBalanceEmpty = computed(() => props.balance <= 0)
const { result: userResult, error: userError } = useQuery( const { result: userResult, error: userError } = useQuery(
user, user,
@ -193,6 +246,7 @@ watch(
(user) => { (user) => {
if (user) { if (user) {
userName.value = `${user.firstName} ${user.lastName}` userName.value = `${user.firstName} ${user.lastName}`
form.identifier = userIdentifier.value.identifier
} }
}, },
{ immediate: true }, { immediate: true },
@ -204,19 +258,21 @@ watch(userError, (error) => {
} }
}) })
const onSubmit = handleSubmit((formValues) => { function onSubmit() {
if (userIdentifier.value) formValues.identifier = userIdentifier.value.identifier const transformedForm = validationSchema.value.cast(form)
emit('set-transaction', { emit('set-transaction', {
...transformedForm,
selected: radioSelected.value, selected: radioSelected.value,
...formValues,
amount: Number(formValues.amount.replace(',', '.')),
userName: userName.value, userName: userName.value,
}) })
}) }
function onReset(event) { function onReset(event) {
event.preventDefault() event.preventDefault()
resetForm() form.amount = props.amount
form.memo = props.memo
form.identifier = props.identifier
form.targetCommunity = props.targetCommunity
radioSelected.value = SEND_TYPES.send radioSelected.value = SEND_TYPES.send
router.replace('/send') router.replace('/send')
} }

View File

@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import InputAmount from './InputAmount'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { BFormInput } from 'bootstrap-vue-next'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: {},
path: '/some-path',
})),
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key) => key,
n: (num) => num,
})),
}))
vi.mock('vee-validate', () => ({
useField: vi.fn(() => ({
value: ref(''),
meta: { valid: true },
errorMessage: ref(''),
})),
}))
// Mock toast
const mockToastError = vi.fn()
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(() => ({
toastError: mockToastError,
})),
}))
describe('InputAmount', () => {
let wrapper
const createWrapper = (propsData = {}) => {
return mount(InputAmount, {
props: {
name: 'amount',
label: 'Amount',
placeholder: 'Enter amount',
typ: 'TransactionForm',
modelValue: '12,34',
...propsData,
},
global: {
mocks: {
$route: useRoute(),
...useI18n(),
},
components: {
BFormInput,
},
directives: {
focus: {},
},
stubs: {
BFormGroup: true,
BFormInvalidFeedback: true,
BInputGroup: true,
},
},
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('mount in a TransactionForm', () => {
beforeEach(() => {
wrapper = createWrapper()
})
it('renders the component input-amount', () => {
expect(wrapper.find('div.input-amount').exists()).toBe(true)
})
it('normalizes the amount correctly', async () => {
await wrapper.vm.normalizeAmount('12,34')
expect(wrapper.vm.value).toBe('12.34')
})
it('does not normalize invalid input', async () => {
await wrapper.vm.normalizeAmount('12m34')
expect(wrapper.vm.value).toBe('12m34')
})
})
describe('mount in a ContributionForm', () => {
beforeEach(() => {
wrapper = createWrapper({
typ: 'ContributionForm',
modelValue: '12.34',
})
})
it('renders the component input-amount', () => {
expect(wrapper.find('div.input-amount').exists()).toBe(true)
})
it('normalizes the amount correctly', async () => {
await wrapper.vm.normalizeAmount('12.34')
expect(wrapper.vm.value).toBe('12.34')
})
it('does not normalize invalid input', async () => {
await wrapper.vm.normalizeAmount('12m34')
expect(wrapper.vm.value).toBe('12m34')
})
})
it('emits update:modelValue when value changes', async () => {
wrapper = createWrapper()
await wrapper.vm.normalizeAmount('15.67')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['15.67'])
})
})

View File

@ -1,88 +0,0 @@
<template>
<div class="input-amount">
<template v-if="typ === 'TransactionForm'">
<BFormGroup :label="label" :label-for="labelFor" data-test="input-amount">
<BFormInput
:id="labelFor"
v-focus="amountFocused"
:model-value="value"
:class="$route.path === '/send' ? 'bg-248' : ''"
:name="name"
:placeholder="placeholder"
type="text"
:state="meta.valid"
trim
:disabled="disabled"
autocomplete="off"
@update:model-value="normalizeAmount($event)"
@focus="amountFocused = true"
@blur="normalizeAmount($event)"
/>
<BFormInvalidFeedback v-if="errorMessage">
{{ errorMessage }}
</BFormInvalidFeedback>
</BFormGroup>
</template>
<BInputGroup v-else append="GDD" :label="label" :label-for="labelFor">
<BFormInput
:id="labelFor"
v-focus="amountFocused"
:model-value="value"
:name="name"
:placeholder="placeholder"
type="text"
readonly
trim
/>
</BInputGroup>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useField } from 'vee-validate'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const props = defineProps({
rules: {
type: Object,
default: () => ({}),
},
typ: { type: String, default: 'TransactionForm' },
name: { type: String, required: true, default: 'Amount' },
label: { type: String, required: true, default: 'Amount' },
placeholder: { type: String, required: true, default: 'Amount' },
balance: { type: Number, default: 0.0 },
disabled: { required: false, type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const route = useRoute()
const { n } = useI18n()
const { value, meta, errorMessage } = useField(props.name, props.rules)
const amountFocused = ref(false)
const amountValue = ref(0.0)
const labelFor = computed(() => props.name + '-input-field')
watch(value, (newValue) => {
emit('update:modelValue', newValue)
})
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== value.value) value.value = newValue
},
)
const normalizeAmount = (inputValue) => {
amountFocused.value = false
if (typeof inputValue === 'string' && inputValue.length > 1) {
value.value = inputValue.replace(',', '.')
}
}
</script>

View File

@ -1,61 +0,0 @@
<template>
<BFormGroup :label="label" :label-for="labelFor" data-test="input-identifier">
<BFormInput
:id="labelFor"
:model-value="value"
:name="name"
:placeholder="placeholder"
type="text"
:state="meta.valid"
trim
class="bg-248"
:disabled="disabled"
autocomplete="off"
@update:model-value="value = $event"
/>
<BFormInvalidFeedback v-if="errorMessage">
{{ errorMessage }}
</BFormInvalidFeedback>
</BFormGroup>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useField } from 'vee-validate'
const props = defineProps({
rules: {
type: Object,
default: () => ({
required: true,
validIdentifier: true,
}),
},
name: { type: String, required: true },
label: { type: String, required: true },
placeholder: { type: String, required: true },
modelValue: { type: String },
disabled: { type: Boolean, required: false, default: false },
})
const emit = defineEmits(['update:modelValue', 'onValidation'])
const { value, meta, errorMessage } = useField(props.name, props.rules, {
initialValue: props.modelValue,
})
const labelFor = computed(() => props.name + '-input-field')
watch(value, (newValue) => {
emit('update:modelValue', newValue)
})
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== value.value) {
value.value = newValue
emit('onValidation')
}
},
)
</script>

View File

@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import InputTextarea from './InputTextarea'
import { useField } from 'vee-validate'
import { BFormGroup, BFormInvalidFeedback, BFormTextarea } from 'bootstrap-vue-next'
vi.mock('vee-validate', () => ({
useField: vi.fn(),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
}),
}))
describe('InputTextarea', () => {
let wrapper
const createWrapper = (props = {}) => {
return mount(InputTextarea, {
props: {
rules: {},
name: 'input-field-name',
label: 'input-field-label',
placeholder: 'input-field-placeholder',
...props,
},
global: {
components: {
BFormGroup,
BFormTextarea,
BFormInvalidFeedback,
},
},
})
}
beforeEach(() => {
vi.mocked(useField).mockReturnValue({
value: '',
errorMessage: '',
meta: { valid: true },
})
wrapper = createWrapper()
})
it('renders the component InputTextarea', () => {
expect(wrapper.find('[data-test="input-textarea"]').exists()).toBe(true)
})
it('has a textarea field', () => {
expect(wrapper.findComponent({ name: 'BFormTextarea' }).exists()).toBe(true)
})
describe('properties', () => {
it('has the correct id', () => {
const textarea = wrapper.findComponent({ name: 'BFormTextarea' })
expect(textarea.attributes('id')).toBe('input-field-name-input-field')
})
it('has the correct placeholder', () => {
const textarea = wrapper.findComponent({ name: 'BFormTextarea' })
expect(textarea.attributes('placeholder')).toBe('input-field-placeholder')
})
it('has the correct label', () => {
const label = wrapper.find('label')
expect(label.text()).toBe('input-field-label')
})
it('has the correct label-for attribute', () => {
const label = wrapper.find('label')
expect(label.attributes('for')).toBe('input-field-name-input-field')
})
})
describe('input value changes', () => {
it('updates the model value when input changes', async () => {
const wrapper = mount(InputTextarea, {
props: {
rules: {},
name: 'input-field-name',
label: 'input-field-label',
placeholder: 'input-field-placeholder',
},
global: {
components: {
BFormGroup,
BFormInvalidFeedback,
BFormTextarea,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('New Text')
expect(wrapper.vm.currentValue).toBe('New Text')
})
})
describe('disabled state', () => {
it('disables the textarea when disabled prop is true', async () => {
await wrapper.setProps({ disabled: true })
const textarea = wrapper.findComponent({ name: 'BFormTextarea' })
expect(textarea.attributes('disabled')).toBeDefined()
})
})
it('shows error message when there is an error', async () => {
vi.mocked(useField).mockReturnValue({
value: '',
errorMessage: 'This field is required',
meta: { valid: false },
})
wrapper = createWrapper()
await wrapper.vm.$nextTick()
const errorFeedback = wrapper.findComponent({ name: 'BFormInvalidFeedback' })
expect(errorFeedback.exists()).toBe(true)
expect(errorFeedback.text()).toBe('This field is required')
})
})

View File

@ -1,64 +0,0 @@
<template>
<div>
<BFormGroup :label="label" :label-for="labelFor" data-test="input-textarea">
<BFormTextarea
:id="labelFor"
:model-value="currentValue"
class="bg-248"
:name="name"
:placeholder="placeholder"
:state="meta.valid"
trim
:rows="4"
:max-rows="4"
:disabled="disabled"
no-resize
@update:modelValue="currentValue = $event"
/>
<BFormInvalidFeedback v-if="errorMessage">
{{ translatedErrorString }}
</BFormInvalidFeedback>
</BFormGroup>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useField } from 'vee-validate'
import { useI18n } from 'vue-i18n'
import { translateYupErrorString } from '@/validationSchemas'
const props = defineProps({
rules: {
type: Object,
default: () => ({}),
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
})
const { value: currentValue, errorMessage, meta } = useField(props.name, props.rules)
const { t } = useI18n()
const translatedErrorString = computed(() => translateYupErrorString(errorMessage, t))
const labelFor = computed(() => `${props.name}-input-field`)
</script>
<style lang="scss" scoped>
:deep(.form-control) {
height: unset;
}
</style>

View File

@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ValidatedInput from '@/components/Inputs/ValidatedInput.vue'
import * as yup from 'yup'
import { BFormInvalidFeedback, BFormInput, BFormTextarea, BFormGroup } from 'bootstrap-vue-next'
import LabeledInput from '@/components/Inputs/LabeledInput.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
n: (n) => String(n),
}),
}))
describe('ValidatedInput', () => {
let wrapper
const createWrapper = (props = {}) =>
mount(ValidatedInput, {
props: {
label: 'Test Label',
modelValue: '',
name: 'testInput',
rules: yup.string().required().min(3).default(''),
...props,
},
global: {
mocks: {
$t: (key) => key,
$i18n: {
locale: 'en',
},
$n: (n) => String(n),
},
components: {
BFormInvalidFeedback,
BFormInput,
BFormTextarea,
BFormGroup,
LabeledInput,
},
},
})
beforeEach(() => {
wrapper = createWrapper()
})
it('renders the label and input', () => {
expect(wrapper.text()).toContain('Test Label')
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
})
it('starts with neutral validation state', () => {
const input = wrapper.find('input')
expect(input.classes()).not.toContain('is-valid')
expect(input.classes()).not.toContain('is-invalid')
})
it('shows green border when value is valid before blur', async () => {
await wrapper.setProps({ modelValue: 'validInput' })
await wrapper.vm.$nextTick()
const input = wrapper.find('input')
expect(input.classes()).toContain('is-valid')
expect(input.classes()).not.toContain('is-invalid')
})
it('does not show red border before blur even if invalid', async () => {
await wrapper.setProps({ modelValue: 'a' })
const input = wrapper.find('input')
expect(input.classes()).not.toContain('is-invalid')
})
it('shows red border and error message after blur when input is invalid', async () => {
await wrapper.setProps({ modelValue: 'a' })
const input = wrapper.find('input')
await input.trigger('blur')
await wrapper.vm.$nextTick()
expect(input.classes()).toContain('is-invalid')
expect(wrapper.text()).toContain('this must be at least 3 characters')
})
it('emits update:modelValue on input', async () => {
const input = wrapper.find('input')
await input.setValue('hello')
await wrapper.vm.$nextTick()
expect(wrapper.emitted()['update:modelValue']).toBeTruthy()
const [value, name] = wrapper.emitted()['update:modelValue'][0]
expect(value).toBe('hello')
expect(name).toBe('testInput')
})
})

View File

@ -9,7 +9,8 @@
:required="!isOptional" :required="!isOptional"
:label="label" :label="label"
:name="name" :name="name"
:state="valid" :state="smartValidState"
@blur="afterFirstInput = true"
@update:modelValue="updateValue" @update:modelValue="updateValue"
> >
<BFormInvalidFeedback v-if="errorMessage"> <BFormInvalidFeedback v-if="errorMessage">
@ -19,7 +20,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import LabeledInput from './LabeledInput' import LabeledInput from './LabeledInput'
import { translateYupErrorString } from '@/validationSchemas' import { translateYupErrorString } from '@/validationSchemas'
@ -38,19 +39,40 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
disableSmartValidState: {
type: Boolean,
default: false,
},
}) })
const { t } = useI18n() const { t } = useI18n()
const model = ref(props.modelValue) const model = ref(props.modelValue !== 0 ? props.modelValue : '')
// change to true after user leave the input field the first time
// prevent showing errors on form init
const afterFirstInput = ref(false)
const valid = computed(() => props.rules.isValidSync(props.modelValue)) const valid = computed(() => props.rules.isValidSync(model.value))
const errorMessage = computed(() => { // smartValidState controls the visual validation feedback for the input field.
if (props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) { // The goal is to avoid showing red (invalid) borders too early, creating a smoother UX:
return undefined //
// - On initial form open, the field is neutral (no validation state shown).
// - If the user enters a value that passes validation, we show a green (valid) state immediately.
// - We only show red (invalid) feedback *after* the user has blurred the field for the first time.
//
// Before first blur:
// - show green if valid, otherwise neutral (null)
// After first blur:
// - show true or false according to the validation result
const smartValidState = computed(() => {
if (afterFirstInput.value || props.disableSmartValidState) {
return valid.value
} }
return valid.value ? true : null
})
const errorMessage = computed(() => {
try { try {
props.rules.validateSync(props.modelValue) props.rules.validateSync(model.value)
return undefined return undefined
} catch (e) { } catch (e) {
return translateYupErrorString(e.message, t) return translateYupErrorString(e.message, t)
@ -79,4 +101,17 @@ const minValue = computed(() => getTestParameter('min'))
const maxValue = computed(() => getTestParameter('max')) const maxValue = computed(() => getTestParameter('max'))
const resetValue = computed(() => schemaDescription.value.default) const resetValue = computed(() => schemaDescription.value.default)
const isOptional = computed(() => schemaDescription.value.optional) const isOptional = computed(() => schemaDescription.value.optional)
// reset on mount
onMounted(() => {
afterFirstInput.value = false
})
</script> </script>
<!-- disable animation on invalid input -->
<style>
.form-control {
transition: none !important;
transform: none !important;
animation: none !important;
}
</style>

View File

@ -29,13 +29,13 @@ const i18n = createI18n({
overview: 'Overview', overview: 'Overview',
send: 'Send', send: 'Send',
transactions: 'Transactions', transactions: 'Transactions',
info: 'Info',
circles: 'Circles', circles: 'Circles',
usersearch: 'User Search', usersearch: 'User Search',
settings: 'Settings', settings: 'Settings',
admin_area: 'Admin Area', admin_area: 'Admin Area',
logout: 'Logout', logout: 'Logout',
}, },
info: 'Info',
creation: 'Creation', creation: 'Creation',
}, },
}, },
@ -108,7 +108,7 @@ describe('Sidebar', () => {
expect(wrapper.findAll('.nav-item').at(3).text()).toContain('Creation') expect(wrapper.findAll('.nav-item').at(3).text()).toContain('Creation')
}) })
it('has nav-item "navigation.info" in navbar', () => { it('has nav-item "info" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(4).text()).toContain('Info') expect(wrapper.findAll('.nav-item').at(4).text()).toContain('Info')
}) })
}) })

View File

@ -40,7 +40,7 @@
<BNavItem to="/information" class="mb-3" active-class="active-route"> <BNavItem to="/information" class="mb-3" active-class="active-route">
<div class="sidebar-menu-item-wrapper"> <div class="sidebar-menu-item-wrapper">
<i-mdi-information class="svg-icon" /> <i-mdi-information class="svg-icon" />
<span class="ms-2">{{ $t('navigation.info') }}</span> <span class="ms-2">{{ $t('info') }}</span>
</div> </div>
</BNavItem> </BNavItem>
</BNav> </BNav>

View File

@ -21,7 +21,7 @@
</span> </span>
</div> </div>
<a href="https://gradido.net/gms1/" target="_blank"> <a href="https://gradido.net/gms1/" target="_blank">
{{ $t('info') }} {{ $t('card-user-search.info') }}
</a> </a>
<BRow class="my-1"> <BRow class="my-1">
<BCol cols="12"> <BCol cols="12">

View File

@ -20,7 +20,7 @@ export function useAppToast() {
const toastInfo = (message) => { const toastInfo = (message) => {
toast(message, { toast(message, {
title: t('navigation.info'), title: t('info'),
variant: 'warning', variant: 'warning',
bodyClass: 'gdd-toaster-body-darken', bodyClass: 'gdd-toaster-body-darken',
}) })

View File

@ -40,11 +40,13 @@
<BRow> <BRow>
<BCol cols="12" lg="5"> <BCol cols="12" lg="5">
<div> <div>
<gdd-amount <router-link to="transactions">
:balance="balance" <gdd-amount
:show-status="false" :balance="balance"
:badge-show="false" :show-status="false"
/> :badge-show="false"
/>
</router-link>
</div> </div>
</BCol> </BCol>
<BCol cols="12" lg="7"> <BCol cols="12" lg="7">
@ -79,9 +81,7 @@
<BRow> <BRow>
<BCol cols="12" lg="6"> <BCol cols="12" lg="6">
<div> <div>
<router-link to="transactions"> <gdd-amount :balance="balance" :show-status="true" />
<gdd-amount :balance="balance" :show-status="true" />
</router-link>
</div> </div>
</BCol> </BCol>
<BCol cols="12" lg="6"> <BCol cols="12" lg="6">
@ -104,13 +104,11 @@
</BCol> </BCol>
<BCol cols="12" lg="6"> <BCol cols="12" lg="6">
<div> <div>
<router-link to="gdt"> <gdt-amount
<gdt-amount :badge="true"
:badge="true" :show-status="true"
:show-status="true" :gdt-balance="GdtBalance"
:gdt-balance="GdtBalance" />
/>
</router-link>
</div> </div>
</BCol> </BCol>
</BRow> </BRow>

View File

@ -5,6 +5,7 @@
"1000thanks": "1000 Dank, weil du bei uns bist!", "1000thanks": "1000 Dank, weil du bei uns bist!",
"125": "125%", "125": "125%",
"85": "85%", "85": "85%",
"Chat": "Chat",
"GDD": "GDD", "GDD": "GDD",
"GDT": "GDT", "GDT": "GDT",
"GMS": { "GMS": {
@ -17,6 +18,7 @@
}, },
"PersonalDetails": "Persönliche Angaben", "PersonalDetails": "Persönliche Angaben",
"advanced-calculation": "Vorausberechnung", "advanced-calculation": "Vorausberechnung",
"answerNow": "→ Jetzt antworten!",
"asterisks": "****", "asterisks": "****",
"auth": { "auth": {
"left": { "left": {
@ -34,7 +36,7 @@
"headline": "Kooperationsplattform »Gradido-Kreise«", "headline": "Kooperationsplattform »Gradido-Kreise«",
"text": "Lokale Kreise, Studienkreise, Projekte, Events und Kongresse", "text": "Lokale Kreise, Studienkreise, Projekte, Events und Kongresse",
"allowed": { "allowed": {
"button": "Kreise starten..." "button": "Kreise öffnen..."
}, },
"not-allowed": { "not-allowed": {
"button": "Konfigurieren..." "button": "Konfigurieren..."
@ -43,14 +45,15 @@
"card-user-search": { "card-user-search": {
"headline": "Geografische Mitgliedssuche (beta)", "headline": "Geografische Mitgliedssuche (beta)",
"allowed": { "allowed": {
"button": "Öffne Mitgliedssuche...", "button": "Mitgliedssuche öffnen...",
"disabled-button": "GMS offline...", "disabled-button": "GMS offline...",
"text": "Finde Mitglieder aller Communities auf einer Landkarte." "text": "Finde Mitglieder aller Communities auf einer Landkarte."
}, },
"not-allowed": { "not-allowed": {
"button": "Standort festlegen...", "button": "Standort eintragen...",
"text": "Finde Mitglieder aller Communities auf einer Landkarte? Dann musst du selbst erst deinen Standort festlegen." "text": "Um andere Mitglieder in deinem Umkreis zu finden, trage jetzt deinen Standort auf der Karte ein!"
} },
"info": "So gehts"
}, },
"community": { "community": {
"admins": "Administratoren", "admins": "Administratoren",
@ -203,22 +206,43 @@
"username": "Benutzername", "username": "Benutzername",
"username-placeholder": "Wähle deinen Benutzernamen", "username-placeholder": "Wähle deinen Benutzernamen",
"validation": { "validation": {
"gddCreationTime": { "amount": {
"min": "Die Stunden sollten mindestens {min} groß sein", "min": "Der Betrag sollte mindestens {min} groß sein.",
"max": "Die Stunden sollten höchstens {max} groß sein", "max": "Der Betrag sollte höchstens {max} groß sein.",
"decimal-places": "Die Stunden sollten maximal zwei Nachkommastellen enthalten" "decimal-places": "Der Betrag sollte maximal zwei Nachkommastellen enthalten.",
"typeError": "Der Betrag sollte eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein."
}, },
"gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein", "contributionDate": {
"is-not": "Du kannst dir selbst keine Gradidos überweisen", "required": "Das Beitragsdatum ist ein Pflichtfeld.",
"min": "Das Frühste Beitragsdatum ist {min}.",
"max": "Das Späteste Beitragsdatum ist heute, der {max}."
},
"contributionMemo": {
"min": "Die Tätigkeitsbeschreibung sollte mindestens {min} Zeichen lang sein.",
"max": "Die Tätigkeitsbeschreibung sollte höchstens {max} Zeichen lang sein.",
"required": "Die Tätigkeitsbeschreibung ist ein Pflichtfeld."
},
"hours": {
"min": "Die Stunden sollten mindestens {min} groß sein.",
"max": "Die Stunden sollten höchstens {max} groß sein.",
"decimal-places": "Die Stunden sollten maximal zwei Nachkommastellen enthalten.",
"typeError": "Die Stunden sollten eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein."
},
"gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein.",
"identifier": {
"required": "Der Empfänger ist ein Pflichtfeld.",
"typeError": "Der Empfänger muss eine Email, ein Nutzernamen oder eine Gradido ID sein."
},
"is-not": "Du kannst dir selbst keine Gradidos überweisen!",
"memo": { "memo": {
"min": "Die Tätigkeitsbeschreibung sollte mindestens {min} Zeichen lang sein", "min": "Die Nachricht sollte mindestens {min} Zeichen lang sein.",
"max": "Die Tätigkeitsbeschreibung sollte höchstens {max} Zeichen lang sein" "max": "Die Nachricht sollte höchstens {max} Zeichen lang sein.",
"required": "Die Nachricht ist ein Pflichtfeld."
}, },
"requiredField": "{fieldName} ist ein Pflichtfeld", "requiredField": "{fieldName} ist ein Pflichtfeld",
"username-allowed-chars": "Der Nutzername darf nur aus Buchstaben (ohne Umlaute), Zahlen, Binde- oder Unterstrichen bestehen.", "username-allowed-chars": "Der Nutzername darf nur aus Buchstaben (ohne Umlaute), Zahlen, Binde- oder Unterstrichen bestehen.",
"username-hyphens": "Binde- oder Unterstriche müssen zwischen Buchstaben oder Zahlen stehen.", "username-hyphens": "Binde- oder Unterstriche müssen zwischen Buchstaben oder Zahlen stehen.",
"username-unique": "Der Nutzername ist bereits vergeben.", "username-unique": "Der Nutzername ist bereits vergeben."
"valid-identifier": "Muss eine Email, ein Nutzernamen oder eine gradido ID sein."
}, },
"your_amount": "Dein Betrag" "your_amount": "Dein Betrag"
}, },
@ -277,7 +301,7 @@
"recruited-member": "Eingeladenes Mitglied" "recruited-member": "Eingeladenes Mitglied"
}, },
"h": "h", "h": "h",
"info": "Info", "info": "Information",
"language": "Sprache", "language": "Sprache",
"link-load": "den letzten Link nachladen | die letzten {n} Links nachladen", "link-load": "den letzten Link nachladen | die letzten {n} Links nachladen",
"link-load-more": "weitere {n} Links nachladen", "link-load-more": "weitere {n} Links nachladen",
@ -302,11 +326,9 @@
}, },
"missingGradidoAccount": "Noch kein {communityName} Konto?", "missingGradidoAccount": "Noch kein {communityName} Konto?",
"moderatorChangedMemo": "Text vom Moderator bearbeitet", "moderatorChangedMemo": "Text vom Moderator bearbeitet",
"moderatorChat": "Moderator Chat",
"navigation": { "navigation": {
"admin_area": "Adminbereich", "admin_area": "Adminbereich",
"community": "Gemeinschaft", "community": "Gemeinschaft",
"info": "Information",
"logout": "Abmelden", "logout": "Abmelden",
"overview": "Übersicht", "overview": "Übersicht",
"send": "Senden", "send": "Senden",

View File

@ -5,6 +5,7 @@
"1000thanks": "1000 thanks for being with us!", "1000thanks": "1000 thanks for being with us!",
"125": "125%", "125": "125%",
"85": "85%", "85": "85%",
"Chat": "Chat",
"GDD": "GDD", "GDD": "GDD",
"GDT": "GDT", "GDT": "GDT",
"GMS": { "GMS": {
@ -17,6 +18,7 @@
}, },
"PersonalDetails": "Personal details", "PersonalDetails": "Personal details",
"advanced-calculation": "Advanced calculation", "advanced-calculation": "Advanced calculation",
"answerNow": "→ Reply now!",
"asterisks": "****", "asterisks": "****",
"auth": { "auth": {
"left": { "left": {
@ -34,7 +36,7 @@
"headline": "Cooperation platform “Gradido Circles”", "headline": "Cooperation platform “Gradido Circles”",
"text": "Local circles, study circles, projects, events and congresses", "text": "Local circles, study circles, projects, events and congresses",
"allowed": { "allowed": {
"button": "Start Circles..." "button": "Open Circles..."
}, },
"not-allowed": { "not-allowed": {
"button": "Configurate..." "button": "Configurate..."
@ -43,14 +45,15 @@
"card-user-search": { "card-user-search": {
"headline": "Geographic member search (beta)", "headline": "Geographic member search (beta)",
"allowed": { "allowed": {
"button": "Start Membersearch...", "button": "Open member search...",
"disabled-button": "GMS offline...", "disabled-button": "GMS offline...",
"text": "Find Members of all Communities on a Map." "text": "Find Members of all Communities on a Map."
}, },
"not-allowed": { "not-allowed": {
"button": "Start Location-Capturing...", "button": "Enter location...",
"text": "Find Members of all Communities on a Map? Then you have to capture your Location first." "text": "To find other members in your area, enter your location on the map now!"
} },
"info": "How it works"
}, },
"community": { "community": {
"admins": "Administrators", "admins": "Administrators",
@ -68,7 +71,7 @@
"contribution": { "contribution": {
"activity": "Activity", "activity": "Activity",
"alert": { "alert": {
"answerQuestion": "There is a new message for this article.", "answerQuestion": "There is a new message for this contribution.",
"answerQuestionToast": "You have new messages.", "answerQuestionToast": "You have new messages.",
"communityNoteList": "Here you will find all submitted and confirmed contributions from all members of this community.", "communityNoteList": "Here you will find all submitted and confirmed contributions from all members of this community.",
"confirm": "confirmed", "confirm": "confirmed",
@ -203,22 +206,43 @@
"username": "Username", "username": "Username",
"username-placeholder": "Choose your username", "username-placeholder": "Choose your username",
"validation": { "validation": {
"gddCreationTime": { "amount": {
"min": "The hours should be at least {min} in size", "min": "The amount should be at least {min} in size.",
"max": "The hours should not be larger than {max}", "max": "The amount should not be larger than {max}.",
"decimal-places": "The hours should contain a maximum of two decimal places" "decimal-places": "The amount should contain a maximum of two decimal places.",
"typeError": "The amount should be a number between {min} and {max} with at most two digits after the decimal point."
}, },
"gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point", "contributionDate": {
"is-not": "You cannot send Gradidos to yourself", "required": "The contribution date is a required field.",
"min": "The earliest contribution date is {min}.",
"max": "The latest contribution date is today, {max}."
},
"contributionMemo": {
"min": "The job description should be at least {min} characters long.",
"max": "The job description should not be longer than {max} characters.",
"required": "The job description is required."
},
"hours": {
"min": "The hours should be at least {min} in size.",
"max": "The hours should not be larger than {max}.",
"decimal-places": "The hours should contain a maximum of two decimal places.",
"typeError": "The hours should be a number between {min} and {max} with at most two digits after the decimal point."
},
"gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point.",
"identifier": {
"required": "The recipient is a required field.",
"typeError": "The recipient must be an email, a username or a Gradido ID."
},
"is-not": "You cannot send Gradidos to yourself!",
"memo": { "memo": {
"min": "The job description should be at least {min} characters long", "min": "The message should be at least {min} characters long.",
"max": "The job description should not be longer than {max} characters" "max": "The message should not be longer than {max} characters.",
"required": "The message is required."
}, },
"requiredField": "The {fieldName} field is required", "requiredField": "The {fieldName} field is required",
"username-allowed-chars": "The username may only contain letters, numbers, hyphens or underscores.", "username-allowed-chars": "The username may only contain letters, numbers, hyphens or underscores.",
"username-hyphens": "Hyphens or underscores must be in between letters or numbers.", "username-hyphens": "Hyphens or underscores must be in between letters or numbers.",
"username-unique": "This username is already taken.", "username-unique": "This username is already taken."
"valid-identifier": "Must be a valid email, username or gradido ID."
}, },
"your_amount": "Your amount" "your_amount": "Your amount"
}, },
@ -277,7 +301,7 @@
"recruited-member": "Invited member" "recruited-member": "Invited member"
}, },
"h": "h", "h": "h",
"info": "Info", "info": "Information",
"language": "Language", "language": "Language",
"link-load": "Load the last link | Load the last {n} links", "link-load": "Load the last link | Load the last {n} links",
"link-load-more": "Load more {n} links", "link-load-more": "Load more {n} links",
@ -302,11 +326,9 @@
}, },
"missingGradidoAccount": "Don't have a {communityName} account yet?", "missingGradidoAccount": "Don't have a {communityName} account yet?",
"moderatorChangedMemo": "Text edited by moderator", "moderatorChangedMemo": "Text edited by moderator",
"moderatorChat": "Moderator Chat",
"navigation": { "navigation": {
"admin_area": "Admin Area", "admin_area": "Admin Area",
"community": "Community", "community": "Community",
"info": "Information",
"logout": "Logout", "logout": "Logout",
"overview": "Overview", "overview": "Overview",
"send": "Send", "send": "Send",

View File

@ -7,6 +7,7 @@
"GDT": "GDT", "GDT": "GDT",
"PersonalDetails": "Datos personales", "PersonalDetails": "Datos personales",
"advanced-calculation": "Proyección", "advanced-calculation": "Proyección",
"answerNow": "→ ¡Responde ahora!",
"asterisks": "****", "asterisks": "****",
"auth": { "auth": {
"left": { "left": {
@ -24,7 +25,7 @@
"headline": "Plataforma de cooperación «Círculos Gradido»", "headline": "Plataforma de cooperación «Círculos Gradido»",
"text": "Círculos locales, círculos de estudio, proyectos, ev entos y congresos", "text": "Círculos locales, círculos de estudio, proyectos, ev entos y congresos",
"allowed": { "allowed": {
"button": "Iniciar círculos..." "button": "Abrir círculos..."
}, },
"not-allowed": { "not-allowed": {
"button": "Configurar..." "button": "Configurar..."
@ -33,19 +34,20 @@
"card-user-search": { "card-user-search": {
"headline": "Búsqueda geográfica de miembros (beta)", "headline": "Búsqueda geográfica de miembros (beta)",
"allowed": { "allowed": {
"button": "Iniciar Búsqueda de Miembros...", "button": "Abrir búsqueda de miembros...",
"disabled-button": "GMS offline...", "disabled-button": "GMS offline...",
"text": "Encuentra Miembros de todas las Comunidades en un Mapa." "text": "Encuentra Miembros de todas las Comunidades en un Mapa."
}, },
"not-allowed": { "not-allowed": {
"button": "Configuración de ubicación...", "button": "Introducir ubicación...",
"text": "Encuentra Miembros de todas las Comunidades en un Mapa? Entonces tienes que establecer tu ubicación primero." "text": "Para encontrar otros miembros cerca de ti, ¡introduce ahora tu ubicación en el mapa!"
} },
"info": "Así se hace"
}, },
"circles": { "circles": {
"headline": "Juntos nos apoyamos - atentos a la cultura de los círculos.", "headline": "Juntos nos apoyamos - atentos a la cultura de los círculos.",
"text": "Haga clic en el botón para abrir la plataforma de cooperación en una nueva ventana del navegador.", "text": "Haga clic en el botón para abrir la plataforma de cooperación en una nueva ventana del navegador.",
"button": "Iniciar Círculos..." "button": "Abrir Círculos..."
}, },
"community": { "community": {
"admins": "Administradores", "admins": "Administradores",
@ -69,7 +71,8 @@
"contribution": { "contribution": {
"activity": "Actividad", "activity": "Actividad",
"alert": { "alert": {
"answerQuestion": "Por favor, contesta las preguntas", "answerQuestion": "Hay una nueva noticia sobre esta contribución.",
"answerQuestionToast": "Tienes mensajes nuevos.",
"communityNoteList": "Aquí encontrarás todas las contribuciones enviadas y confirmadas de todos los miembros de esta comunidad.", "communityNoteList": "Aquí encontrarás todas las contribuciones enviadas y confirmadas de todos los miembros de esta comunidad.",
"confirm": "confirmado", "confirm": "confirmado",
"denied": "rechazado", "denied": "rechazado",
@ -239,6 +242,7 @@
"raise": "Aumento", "raise": "Aumento",
"recruited-member": "Miembro invitado" "recruited-member": "Miembro invitado"
}, },
"info": "Información",
"language": "Idioma", "language": "Idioma",
"link-load": "recargar el último enlace | recargar los últimos {n} enlaces", "link-load": "recargar el último enlace | recargar los últimos {n} enlaces",
"link-load-more": "descargar más {n} enlaces", "link-load-more": "descargar más {n} enlaces",
@ -265,7 +269,6 @@
"navigation": { "navigation": {
"admin_area": "Área de administración", "admin_area": "Área de administración",
"community": "Comunidad", "community": "Comunidad",
"info": "Información",
"logout": "Salir", "logout": "Salir",
"members_area": "Área de afiliados", "members_area": "Área de afiliados",
"overview": "Resumen", "overview": "Resumen",

View File

@ -9,6 +9,7 @@
"GDT": "GDT", "GDT": "GDT",
"PersonalDetails": "Informations personnelles", "PersonalDetails": "Informations personnelles",
"advanced-calculation": "Calcul avancé", "advanced-calculation": "Calcul avancé",
"answerNow": "→ Répondre maintenant!",
"asterisks": "****", "asterisks": "****",
"auth": { "auth": {
"left": { "left": {
@ -26,7 +27,7 @@
"headline": "Plate-forme de coopération «Cercles Gradido»", "headline": "Plate-forme de coopération «Cercles Gradido»",
"text": "Cercles locaux, cercles d'études, projets, événements et congrès", "text": "Cercles locaux, cercles d'études, projets, événements et congrès",
"allowed": { "allowed": {
"button": "Démarrer les cercles..." "button": "Ouvrir les cercles..."
}, },
"not-allowed": { "not-allowed": {
"button": "Configurer..." "button": "Configurer..."
@ -35,19 +36,20 @@
"card-user-search": { "card-user-search": {
"headline": "Recherche géographique de membres (bêta)", "headline": "Recherche géographique de membres (bêta)",
"allowed": { "allowed": {
"button": "Commencer la recherche de membres...", "button": "Ouvrir la recherche de membres...",
"disabled-button": "GMS offline...", "disabled-button": "GMS offline...",
"text": "Trouve des membres de toutes les communautés sur une carte." "text": "Trouve des membres de toutes les communautés sur une carte."
}, },
"not-allowed": { "not-allowed": {
"button": "Configuration de l'emplacement...", "button": "Indiquer ta position...",
"text": "Trouve des membres de toutes les communautés sur une carte? Alors tu dois d'abord définir ton emplacement." "text": "Pour trouver d'autres membres près de chez toi, indique dès maintenant ta position sur la carte!"
} },
"info": "Comment ça marche"
}, },
"circles": { "circles": {
"headline": "Ensemble, nous nous soutenons mutuellement - attentifs à la culture du cercle.", "headline": "Ensemble, nous nous soutenons mutuellement - attentifs à la culture du cercle.",
"text": "En cliquant sur le bouton, tu ouvres la plateforme de coopération dans une nouvelle fenêtre de navigation.", "text": "En cliquant sur le bouton, tu ouvres la plateforme de coopération dans une nouvelle fenêtre de navigation.",
"button": "Démarrer les Cercles..." "button": "Ouvrir les Cercles..."
}, },
"community": { "community": {
"admins": "Administrateurs", "admins": "Administrateurs",
@ -69,7 +71,8 @@
"contribution": { "contribution": {
"activity": "Activité", "activity": "Activité",
"alert": { "alert": {
"answerQuestion": "S'il te plais répond à la question", "answerQuestion": "Il y a un nouveau message concernant cette contribution.",
"answerQuestionToast": "Vous avez de nouveaux messages.",
"communityNoteList": "Vous trouverez ci-contre toutes les contributions versées et certifiées de tous les membres de cette communauté.", "communityNoteList": "Vous trouverez ci-contre toutes les contributions versées et certifiées de tous les membres de cette communauté.",
"confirm": "Approuvé", "confirm": "Approuvé",
"deleted": "Supprimé", "deleted": "Supprimé",
@ -247,6 +250,7 @@
"recruited-member": "Membre invité" "recruited-member": "Membre invité"
}, },
"h": "h", "h": "h",
"info": "Information",
"language": "Langage", "language": "Langage",
"link-load": "Enregistrer le dernier lien | Enregistrer les derniers {n} liens", "link-load": "Enregistrer le dernier lien | Enregistrer les derniers {n} liens",
"link-load-more": "Enregistrer plus de {n} liens", "link-load-more": "Enregistrer plus de {n} liens",
@ -274,7 +278,6 @@
"navigation": { "navigation": {
"admin_area": "Partie administrative", "admin_area": "Partie administrative",
"community": "Communauté", "community": "Communauté",
"info": "Information",
"logout": "Déconnexion", "logout": "Déconnexion",
"overview": "Aperçu", "overview": "Aperçu",
"send": "Envoyer", "send": "Envoyer",

View File

@ -7,6 +7,7 @@
"GDT": "GDT", "GDT": "GDT",
"PersonalDetails": "Persoonlijke gegevens", "PersonalDetails": "Persoonlijke gegevens",
"advanced-calculation": "Voorcalculatie", "advanced-calculation": "Voorcalculatie",
"answerNow": "→ Nu antwoorden!",
"asterisks": "****", "asterisks": "****",
"auth": { "auth": {
"left": { "left": {
@ -24,7 +25,7 @@
"headline": "Samenwerkingsplatform “Gradido Kringen”", "headline": "Samenwerkingsplatform “Gradido Kringen”",
"text": "Lokale kringen, studiekringen, projecten, evenementen en congressen", "text": "Lokale kringen, studiekringen, projecten, evenementen en congressen",
"allowed": { "allowed": {
"button": "Kringen starten..." "button": "Kringen openen..."
}, },
"not-allowed": { "not-allowed": {
"button": "Configureren..." "button": "Configureren..."
@ -33,19 +34,20 @@
"card-user-search": { "card-user-search": {
"headline": "Geografisch leden zoeken (bèta)", "headline": "Geografisch leden zoeken (bèta)",
"allowed": { "allowed": {
"button": "Zoeken naar leden starten...", "button": "Leden zoeken openen...",
"disabled-button": "GMS offline...", "disabled-button": "GMS offline...",
"text": "Vind leden van alle gemeenschappen op een kaart." "text": "Vind leden van alle gemeenschappen op een kaart."
}, },
"not-allowed": { "not-allowed": {
"button": "Locatie instellen...", "button": "Locatie invoeren",
"text": "Vind leden van alle gemeenschappen op een kaart? Dan moet je eerst je locatie instellen." "text": "Om andere leden in jouw omgeving te vinden, voer nu je locatie in op de kaart!"
} },
"info": "Zo gaat dat"
}, },
"circles": { "circles": {
"headline": "Samen ondersteunen we elkaar - mindful in de cirkelcultuur.", "headline": "Samen ondersteunen we elkaar - mindful in de cirkelcultuur.",
"text": "Klik op de knop om het samenwerkingsplatform te openen in een nieuw browservenster.", "text": "Klik op de knop om het samenwerkingsplatform te openen in een nieuw browservenster.",
"button": "Cirkels starten..." "button": "Cirkels openen..."
}, },
"community": { "community": {
"admins": "Beheerders", "admins": "Beheerders",
@ -69,7 +71,8 @@
"contribution": { "contribution": {
"activity": "Activiteit", "activity": "Activiteit",
"alert": { "alert": {
"answerQuestion": "Please answer the question", "answerQuestion": "Er is een nieuw bericht over deze bijdrage.",
"answerQuestionToast": "U hebt nieuwe berichten.",
"communityNoteList": "Hier vind je alle ingediende en bevestigde bijdragen van alle leden uit deze gemeenschap.", "communityNoteList": "Hier vind je alle ingediende en bevestigde bijdragen van alle leden uit deze gemeenschap.",
"confirm": "bevestigt", "confirm": "bevestigt",
"denied": "afgewezen", "denied": "afgewezen",
@ -239,6 +242,7 @@
"raise": "Verhoging", "raise": "Verhoging",
"recruited-member": "Uitgenodigd lid" "recruited-member": "Uitgenodigd lid"
}, },
"info": "Informatie",
"language": "Taal", "language": "Taal",
"link-load": "de laatste link herladen | de laatste links herladen", "link-load": "de laatste link herladen | de laatste links herladen",
"link-load-more": "verdere {n} links herladen", "link-load-more": "verdere {n} links herladen",
@ -265,7 +269,6 @@
"navigation": { "navigation": {
"admin_area": "Beheerder", "admin_area": "Beheerder",
"community": "Gemeenschap", "community": "Gemeenschap",
"info": "Informatie",
"logout": "Afmelden", "logout": "Afmelden",
"members_area": "Ledenbestand", "members_area": "Ledenbestand",
"overview": "Overzicht", "overview": "Overzicht",

View File

@ -11,9 +11,7 @@ import nl from '@vee-validate/i18n/dist/locale/nl.json'
import tr from '@vee-validate/i18n/dist/locale/tr.json' import tr from '@vee-validate/i18n/dist/locale/tr.json'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
// Email and username regex patterns remain the same // username regex pattern remain the same
const EMAIL_REGEX =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const USERNAME_REGEX = /^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/ const USERNAME_REGEX = /^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/
export const loadAllRules = (i18nCallback, apollo) => { export const loadAllRules = (i18nCallback, apollo) => {
@ -48,22 +46,6 @@ export const loadAllRules = (i18nCallback, apollo) => {
defineRule('max', max) defineRule('max', max)
// ------ Custom rules ------ // ------ Custom rules ------
defineRule('gddSendAmount', (value, { min, max }) => {
value = value.replace(',', '.')
return value.match(/^[0-9]+(\.[0-9]{0,2})?$/) && Number(value) >= min && Number(value) <= max
? true
: i18nCallback.t('form.validation.gddSendAmount', {
min: i18nCallback.n(min, 'ungroupedDecimal'),
max: i18nCallback.n(max, 'ungroupedDecimal'),
})
})
defineRule('gddCreationTime', (value, { min, max }) => {
return value >= min && value <= max
? true
: i18nCallback.t('form.validation.gddCreationTime', { min, max })
})
defineRule('is_not', (value, [otherValue]) => { defineRule('is_not', (value, [otherValue]) => {
return value !== otherValue return value !== otherValue
? true ? true
@ -122,13 +104,4 @@ export const loadAllRules = (i18nCallback, apollo) => {
}) })
return data.checkUsername || i18nCallback.t('form.validation.username-unique') return data.checkUsername || i18nCallback.t('form.validation.username-unique')
}) })
defineRule('validIdentifier', (value) => {
const isEmail = !!EMAIL_REGEX.test(value)
const isUsername = !!value.match(USERNAME_REGEX)
const isGradidoId = validateUuid(value) && versionUuid(value) === 4
return (
isEmail || isUsername || isGradidoId || i18nCallback.t('form.validation.valid-identifier')
)
})
} }

View File

@ -1,4 +1,10 @@
import { string } from 'yup' import { string } from 'yup'
import { validate as validateUuid, version as versionUuid } from 'uuid'
// Email and username regex patterns remain the same
const EMAIL_REGEX =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const USERNAME_REGEX = /^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/
// TODO: only needed for grace period, before all inputs updated for using veeValidate + yup // TODO: only needed for grace period, before all inputs updated for using veeValidate + yup
export const isLanguageKey = (str) => export const isLanguageKey = (str) =>
@ -16,6 +22,16 @@ export const translateYupErrorString = (error, t) => {
} }
export const memo = string() export const memo = string()
.required('contribution.yourActivity') .required('form.validation.memo.required')
.min(5, ({ min }) => ({ key: 'form.validation.memo.min', values: { min } })) .min(5, ({ min }) => ({ key: 'form.validation.memo.min', values: { min } }))
.max(255, ({ max }) => ({ key: 'form.validation.memo.max', values: { max } })) .max(512, ({ max }) => ({ key: 'form.validation.memo.max', values: { max } }))
export const identifier = string()
.required('form.validation.identifier.required')
.test('valid-identifier', 'form.validation.identifier.typeError', (value) => {
const isEmail = !!EMAIL_REGEX.test(value)
const isUsername = !!value.match(USERNAME_REGEX)
// TODO: use valibot and rules from shared
const isGradidoId = validateUuid(value) && versionUuid(value) === 4
return isEmail || isUsername || isGradidoId
})

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,31 @@ server {
proxy_redirect off; proxy_redirect off;
} }
# Well-Known for openid connect
location /.well-known {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://backend:4000/realms/gradido/.well-known;
proxy_redirect off;
}
location /realms/gradido {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_pass http://backend:4000/realms/gradido;
proxy_redirect off;
}
# Admin Frontend # Admin Frontend
location /admin { location /admin {
proxy_http_version 1.1; proxy_http_version 1.1;

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "shared", "name": "shared",
"version": "2.6.0", "version": "2.6.1",
"description": "Gradido Shared Code, Low-Level Shared Code, without dependencies on other modules", "description": "Gradido Shared Code, Low-Level Shared Code, without dependencies on other modules",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./src/index.ts", "types": "./src/index.ts",

View File

@ -10,3 +10,6 @@ export * from './jwt/payloadtypes/JwtPayloadType'
export * from './jwt/payloadtypes/OpenConnectionJwtPayloadType' export * from './jwt/payloadtypes/OpenConnectionJwtPayloadType'
export * from './jwt/payloadtypes/OpenConnectionCallbackJwtPayloadType' export * from './jwt/payloadtypes/OpenConnectionCallbackJwtPayloadType'
export * from './jwt/payloadtypes/RedeemJwtPayloadType' export * from './jwt/payloadtypes/RedeemJwtPayloadType'
export * from './jwt/payloadtypes/SendCoinsJwtPayloadType'
export * from './jwt/payloadtypes/SendCoinsResponseJwtPayloadType'

View File

@ -0,0 +1,43 @@
import { JwtPayloadType } from './JwtPayloadType'
import { Decimal } from 'decimal.js-light'
export class SendCoinsJwtPayloadType extends JwtPayloadType {
static SEND_COINS_TYPE = 'send-coins'
recipientCommunityUuid: string
recipientUserIdentifier: string
creationDate: string
amount: Decimal
memo: string
senderCommunityUuid: string
senderUserUuid: string
senderUserName: string
senderAlias?: string | null
constructor(
handshakeID: string,
recipientCommunityUuid: string,
recipientUserIdentifier: string,
creationDate: string,
amount: Decimal,
memo: string,
senderCommunityUuid: string,
senderUserUuid: string,
senderUserName: string,
senderAlias?: string | null,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = SendCoinsJwtPayloadType.SEND_COINS_TYPE
this.recipientCommunityUuid = recipientCommunityUuid
this.recipientUserIdentifier = recipientUserIdentifier
this.creationDate = creationDate
this.amount = amount
this.memo = memo
this.senderCommunityUuid = senderCommunityUuid
this.senderUserUuid = senderUserUuid
this.senderUserName = senderUserName
this.senderAlias = senderAlias
}
}

View File

@ -0,0 +1,30 @@
import { JwtPayloadType } from './JwtPayloadType'
export class SendCoinsResponseJwtPayloadType extends JwtPayloadType {
static SEND_COINS_RESPONSE_TYPE = 'send-coins-response'
vote: boolean
recipGradidoID: string | null
recipFirstName: string | null
recipLastName: string | null
recipAlias: string | null
constructor(
handshakeID: string,
vote: boolean,
recipGradidoID: string | null,
recipFirstName: string | null,
recipLastName: string | null,
recipAlias: string | null,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super(handshakeID)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = SendCoinsResponseJwtPayloadType.SEND_COINS_RESPONSE_TYPE
this.vote = vote
this.recipGradidoID = recipGradidoID
this.recipFirstName = recipFirstName
this.recipLastName = recipLastName
this.recipAlias = recipAlias
}
}

View File

@ -1,6 +1,7 @@
import { string } from 'zod' import { string, number } from 'zod'
import { validate, version } from 'uuid' import { validate, version } from 'uuid'
export const uuidv4Schema = string().refine((val: string) => validate(val) && version(val) === 4, 'Invalid uuid') export const uuidv4Schema = string().refine((val: string) => validate(val) && version(val) === 4, 'Invalid uuid')
export const emailSchema = string().email() export const emailSchema = string().email()
export const urlSchema = string().url() export const urlSchema = string().url()
export const uint32Schema = number().positive().lte(4294967295)

2439
yarn.lock

File diff suppressed because it is too large Load Diff