Merge branch 'master' into release-2_2_1-merge

This commit is contained in:
clauspeterhuebner 2024-03-14 18:04:58 +01:00 committed by GitHub
commit 2c8f8df919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 2397 additions and 490 deletions

View File

@ -9,7 +9,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 96,
lines: 95,
},
},
moduleFileExtensions: [
@ -31,6 +31,9 @@ module.exports = {
setupFiles: ['<rootDir>/test/testSetup.js', 'jest-canvas-mock'],
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
// snapshotSerializers: ['jest-serializer-vue'],
transformIgnorePatterns: ['<rootDir>/node_modules/(?!vee-validate/dist/rules)'],
transformIgnorePatterns: [
'<rootDir>/node_modules/(?!vee-validate/dist/rules)',
'/node_modules/(?!@babel)',
],
testEnvironment: 'jest-environment-jsdom-sixteen', // why this is still needed? should not be needed anymore since jest@26, see: https://www.npmjs.com/package/jest-environment-jsdom-sixteen
}

View File

@ -16,6 +16,7 @@
"lint": "eslint --max-warnings=0 --ext .js,.vue,.json .",
"stylelint": "stylelint --max-warnings=0 '**/*.{scss,vue}'",
"test": "cross-env TZ=UTC jest",
"test:debug": "node --inspect-brk node_modules/.bin/vue-cli-service test:unit --no-cache --watch --runInBand",
"locales": "scripts/sort.sh"
},
"dependencies": {

View File

@ -1,63 +0,0 @@
<template>
<div class="federation-visualize-item">
<b-row>
<b-col cols="1"><b-icon :icon="icon" :variant="variant" class="mr-4"></b-icon></b-col>
<b-col>
<div>{{ item.url }}</div>
<small>{{ `${item.publicKey.substring(0, 26)}` }}</small>
</b-col>
<b-col cols="2">{{ lastAnnouncedAt }}</b-col>
<b-col cols="2">{{ createdAt }}</b-col>
</b-row>
</div>
</template>
<script>
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
const locales = { en, de, es, fr, nl }
export default {
name: 'FederationVisualizeItem',
props: {
item: { type: Object },
},
data() {
return {
formatDistanceToNow,
locale: this.$i18n.locale,
}
},
computed: {
verified() {
return new Date(this.item.verifiedAt) >= new Date(this.item.lastAnnouncedAt)
},
icon() {
return this.verified ? 'check' : 'x-circle'
},
variant() {
return this.verified ? 'success' : 'danger'
},
lastAnnouncedAt() {
if (this.item.lastAnnouncedAt) {
return formatDistanceToNow(new Date(this.item.lastAnnouncedAt), {
includeSecond: true,
addSuffix: true,
locale: locales[this.locale],
})
}
return ''
},
createdAt() {
if (this.item.createdAt) {
return formatDistanceToNow(new Date(this.item.createdAt), {
includeSecond: true,
addSuffix: true,
locale: locales[this.locale],
})
}
return ''
},
},
}
</script>

View File

@ -1,36 +1,83 @@
import { mount } from '@vue/test-utils'
import FederationVisualizeItem from './FederationVisualizeItem.vue'
import Vuex from 'vuex'
import CommunityVisualizeItem from './CommunityVisualizeItem.vue'
const localVue = global.localVue
localVue.use(Vuex)
const today = new Date()
const createdDate = new Date()
createdDate.setDate(createdDate.getDate() - 3)
// Mock für den Vuex-Store
const store = new Vuex.Store({
state: {
moderator: {
roles: ['ADMIN'],
},
},
})
let propsData = {
item: {
id: 7590,
id: 1,
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/2_0',
lastAnnouncedAt: createdDate,
verifiedAt: today,
lastErrorAt: null,
url: 'http://localhost/api/',
publicKey: '4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2',
communityUuid: '5ab0befd-b150-4f31-a631-7f3637e47b21',
authenticatedAt: null,
name: 'Gradido Test',
description: 'Gradido Community zum testen',
gmsApiKey: '<api key>',
creationDate: createdDate,
createdAt: createdDate,
updatedAt: null,
updatedAt: createdDate,
federatedCommunities: [
{
id: 2046,
apiVersion: '2_0',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: createdDate,
verifiedAt: today,
lastErrorAt: null,
createdAt: createdDate,
updatedAt: null,
},
{
id: 2045,
apiVersion: '1_1',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.550Z',
updatedAt: null,
},
{
id: 2044,
apiVersion: '1_0',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.544Z',
updatedAt: null,
},
],
},
}
const mocks = {
$t: (key) => key,
$i18n: {
locale: 'en',
},
}
describe('FederationVisualizeItem', () => {
describe('CommunityVisualizeItem', () => {
let wrapper
const Wrapper = () => {
return mount(FederationVisualizeItem, { localVue, mocks, propsData })
return mount(CommunityVisualizeItem, { localVue, mocks, propsData, store })
}
describe('mount', () => {
@ -39,19 +86,35 @@ describe('FederationVisualizeItem', () => {
})
it('renders the component', () => {
expect(wrapper.find('div.federation-visualize-item').exists()).toBe(true)
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('div.community-visualize-item').exists()).toBe(true)
expect(wrapper.find('.details').exists()).toBe(false)
})
it('toggles details on click', async () => {
// Click the row to toggle details
await wrapper.find('.row').trigger('click')
// Assert that details are now open
expect(wrapper.find('.details').exists()).toBe(true)
// Click the row again to toggle details back
await wrapper.find('.row').trigger('click')
// Assert that details are now closed
expect(wrapper.find('.details').exists()).toBe(false)
})
describe('rendering item properties', () => {
it('has the url', () => {
expect(wrapper.find('.row > div:nth-child(2) > div').text()).toBe(
'http://localhost/api/2_0',
expect(wrapper.find('.row > div:nth-child(2) > div > a').text()).toBe(
'http://localhost/api/',
)
})
it('has the public key', () => {
expect(wrapper.find('.row > div:nth-child(2) > small').text()).toContain(
'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7'.substring(0, 26),
'4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2'.substring(0, 26),
)
})
@ -65,33 +128,6 @@ describe('FederationVisualizeItem', () => {
})
})
describe('not verified item', () => {
beforeEach(() => {
propsData = {
item: {
id: 7590,
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/2_0',
lastAnnouncedAt: createdDate,
verifiedAt: null,
lastErrorAt: null,
createdAt: createdDate,
updatedAt: null,
},
}
wrapper = Wrapper()
})
it('has the x-circle icon', () => {
expect(wrapper.find('svg.bi-x-circle').exists()).toBe(true)
})
it('has the text variant "danger"', () => {
expect(wrapper.find('.text-danger').exists()).toBe(true)
})
})
// describe('with different locales (de, en, fr, es, nl)', () => {
describe('lastAnnouncedAt', () => {
it('computes the time string for different locales (de, en, fr, es, nl)', () => {
@ -155,6 +191,30 @@ describe('FederationVisualizeItem', () => {
expect(wrapper.vm.createdAt).toBe('3 dagen geleden')
})
describe('not verified item', () => {
beforeEach(() => {
propsData = {
item: {
id: 7590,
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/',
createdAt: createdDate,
updatedAt: null,
},
}
wrapper = Wrapper()
})
it('has the x-circle icon', () => {
expect(wrapper.find('svg.bi-x-circle').exists()).toBe(true)
})
it('has the text variant "danger"', () => {
expect(wrapper.find('.text-danger').exists()).toBe(true)
})
})
describe('createdAt == null', () => {
beforeEach(() => {
propsData = {
@ -163,9 +223,9 @@ describe('FederationVisualizeItem', () => {
foreign: false,
publicKey: 'eaf6a426b24fd54f8fbae11c17700fc595080ca25159579c63d38dbc64284ba7',
url: 'http://localhost/api/2_0',
lastAnnouncedAt: createdDate,
verifiedAt: null,
lastErrorAt: null,
communityUuid: '5ab0befd-b150-4f31-a631-7f3637e47b21',
authenticatedAt: null,
creationDate: null,
createdAt: null,
updatedAt: null,
},

View File

@ -0,0 +1,160 @@
<template>
<div class="community-visualize-item">
<b-row @click="toggleDetails">
<b-col cols="1"><b-icon :icon="icon" :variant="variant" class="mr-4"></b-icon></b-col>
<b-col>
<div>
<a :href="item.url" target="_blank">{{ item.url }}</a>
</div>
<small>{{ `${item.publicKey.substring(0, 26)}` }}</small>
</b-col>
<b-col v-b-tooltip="item.description">{{ item.name }}</b-col>
<b-col cols="2">{{ lastAnnouncedAt }}</b-col>
<b-col cols="2">{{ createdAt }}</b-col>
</b-row>
<b-row v-if="details" class="details">
<b-col colspan="5">
<b-list-group>
<b-list-group-item v-if="item.uuid">
{{ $t('federation.communityUuid') }}&nbsp;{{ item.uuid }}
</b-list-group-item>
<b-list-group-item v-if="item.authenticatedAt">
{{ $t('federation.authenticatedAt') }}&nbsp;{{ item.authenticatedAt }}
</b-list-group-item>
<b-list-group-item>
{{ $t('federation.publicKey') }}&nbsp;{{ item.publicKey }}
</b-list-group-item>
<b-list-group-item v-if="!item.foreign">
{{ $t('federation.gmsApiKey') }}&nbsp;
<editable-label
:value="gmsApiKey"
:allowEdit="$store.state.moderator.roles.includes('ADMIN')"
@save="handleSaveGsmApiKey"
/>
</b-list-group-item>
<b-list-group-item>
<b-list-group>
<b-row>
<b-col class="ml-1">{{ $t('federation.verified') }}</b-col>
<b-col>{{ $t('federation.apiVersion') }}</b-col>
<b-col>{{ $t('federation.createdAt') }}</b-col>
<b-col>{{ $t('federation.lastAnnouncedAt') }}</b-col>
<b-col>{{ $t('federation.verifiedAt') }}</b-col>
<b-col>{{ $t('federation.lastErrorAt') }}</b-col>
</b-row>
<b-list-group-item
v-for="federation in item.federatedCommunities"
:key="federation.id"
:variant="!item.foreign ? 'primary' : 'warning'"
>
<federation-visualize-item :item="federation" />
</b-list-group-item>
</b-list-group>
</b-list-group-item>
</b-list-group>
</b-col>
</b-row>
</div>
</template>
<script>
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
import EditableLabel from '@/components/input/EditableLabel'
import FederationVisualizeItem from './FederationVisualizeItem.vue'
import { updateHomeCommunity } from '../../graphql/updateHomeCommunity'
const locales = { en, de, es, fr, nl }
export default {
name: 'CommunityVisualizeItem',
components: {
EditableLabel,
FederationVisualizeItem,
},
props: {
item: { type: Object },
},
data() {
return {
formatDistanceToNow,
locale: this.$i18n.locale,
details: false,
gmsApiKey: '',
}
},
created() {
this.gmsApiKey = this.item.gmsApiKey
},
computed: {
verified() {
if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) {
return false
}
return (
this.item.federatedCommunities.filter(
(federatedCommunity) =>
new Date(federatedCommunity.verifiedAt) >= new Date(federatedCommunity.lastAnnouncedAt),
).length > 0
)
},
icon() {
return this.verified ? 'check' : 'x-circle'
},
variant() {
return this.verified ? 'success' : 'danger'
},
lastAnnouncedAt() {
if (!this.item.federatedCommunities || this.item.federatedCommunities.length === 0) return ''
const minDate = new Date(0)
const lastAnnouncedAt = this.item.federatedCommunities.reduce(
(lastAnnouncedAt, federateCommunity) => {
if (!federateCommunity.lastAnnouncedAt) return lastAnnouncedAt
const date = new Date(federateCommunity.lastAnnouncedAt)
return date > lastAnnouncedAt ? date : lastAnnouncedAt
},
minDate,
)
if (lastAnnouncedAt !== minDate) {
return formatDistanceToNow(lastAnnouncedAt, {
includeSecond: true,
addSuffix: true,
locale: locales[this.locale],
})
}
return ''
},
createdAt() {
if (this.item.createdAt) {
return formatDistanceToNow(new Date(this.item.createdAt), {
includeSecond: true,
addSuffix: true,
locale: locales[this.locale],
})
}
return ''
},
},
methods: {
toggleDetails() {
this.details = !this.details
},
handleSaveGsmApiKey(gmsApiKey) {
this.gmsApiKey = gmsApiKey
this.$apollo
.mutate({
mutation: updateHomeCommunity,
variables: {
uuid: this.item.uuid,
gmsApiKey: gmsApiKey,
},
})
.then(() => {
this.toastSuccess(this.$t('federation.toast_gmsApiKeyUpdated'))
})
.catch((error) => {
this.toastError(error.message)
})
},
},
}
</script>

View File

@ -0,0 +1,47 @@
<template>
<div class="federation-visualize-item">
<b-row>
<b-col><b-icon :icon="icon" :variant="variant" class="mr-4"></b-icon></b-col>
<b-col class="ml-1">{{ item.apiVersion }}</b-col>
<b-col v-b-tooltip="item.createdAt">{{ distanceDate(item.createdAt) }}</b-col>
<b-col v-b-tooltip="item.lastAnnouncedAt">{{ distanceDate(item.lastAnnouncedAt) }}</b-col>
<b-col v-b-tooltip="item.verifiedAt">{{ distanceDate(item.verifiedAt) }}</b-col>
<b-col v-b-tooltip="item.lastErrorAt">{{ distanceDate(item.lastErrorAt) }}</b-col>
</b-row>
</div>
</template>
<script>
import { formatDistanceToNow } from 'date-fns'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
const locales = { en, de, es, fr, nl }
export default {
name: 'FederationVisualizeItem',
props: {
item: { type: Object },
},
computed: {
verified() {
return new Date(this.item.verifiedAt) >= new Date(this.item.lastAnnouncedAt)
},
icon() {
return this.verified ? 'check' : 'x-circle'
},
variant() {
return this.verified ? 'success' : 'danger'
},
},
methods: {
distanceDate(dateString) {
return dateString
? formatDistanceToNow(new Date(dateString), {
includeSecond: true,
addSuffix: true,
locale: locales[this.$i18n.locale],
})
: ''
},
},
}
</script>

View File

@ -0,0 +1,83 @@
// Test written by ChatGPT 3.5
import { mount } from '@vue/test-utils'
import EditableLabel from './EditableLabel.vue'
const localVue = global.localVue
const value = 'test label value'
describe('EditableLabel', () => {
let wrapper
const createWrapper = (propsData) => {
return mount(EditableLabel, {
localVue,
propsData,
})
}
it('renders the label when not editing', () => {
wrapper = createWrapper({ value, allowEdit: true })
expect(wrapper.find('label').text()).toBe(value)
})
it('renders the input when editing', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.find('input').exists()).toBe(true)
})
it('emits save event when clicking save button', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.setData({ inputValue: 'New Value' })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted().save).toBeTruthy()
expect(wrapper.emitted().save[0][0]).toBe('New Value')
})
it('disables save button when value is not changed', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
})
it('enables save button when value is changed', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.setData({ inputValue: 'New Value' })
expect(wrapper.find('button').attributes('disabled')).toBeFalsy()
})
it('updates originalValue when saving changes', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
await wrapper.setData({ inputValue: 'New Value' })
await wrapper.find('button').trigger('click')
expect(wrapper.vm.originalValue).toBe('New Value')
})
it('changes variant to success when editing', async () => {
wrapper = createWrapper({ value, allowEdit: true })
await wrapper.find('button').trigger('click')
expect(wrapper.vm.variant).toBe('success')
})
it('changes variant to prime when not editing', async () => {
wrapper = createWrapper({ value, allowEdit: true })
expect(wrapper.vm.variant).toBe('prime')
})
})

View File

@ -0,0 +1,64 @@
<template>
<div>
<b-form-group>
<label v-if="!editing">{{ value }}</label>
<b-form-input v-else v-model="inputValue" :placeholder="placeholder" />
</b-form-group>
<b-button
v-if="allowEdit"
@click="toggleEdit"
:disabled="!isValueChanged && editing"
:variant="variant"
>
<b-icon v-if="!editing" icon="pencil-fill" tooltip="$t('edit')"></b-icon>
<b-icon v-else icon="check" tooltip="$t('save')"></b-icon>
</b-button>
</div>
</template>
<script>
export default {
// Code written from chatGPT 3.5
name: 'EditableLabel',
props: {
value: {
type: String,
required: false,
default: '',
},
allowEdit: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
required: false,
default: '',
},
},
data() {
return {
editing: false,
inputValue: this.value,
originalValue: this.value,
}
},
computed: {
variant() {
return this.editing ? 'success' : 'prime'
},
isValueChanged() {
return this.inputValue !== this.originalValue
},
},
methods: {
toggleEdit() {
if (this.editing) {
this.$emit('save', this.inputValue)
this.originalValue = this.inputValue
}
this.editing = !this.editing
},
},
}
</script>

View File

@ -0,0 +1,29 @@
import gql from 'graphql-tag'
export const allCommunities = gql`
query {
allCommunities {
foreign
url
publicKey
uuid
authenticatedAt
name
description
gmsApiKey
creationDate
createdAt
updatedAt
federatedCommunities {
id
apiVersion
endPoint
lastAnnouncedAt
verifiedAt
lastErrorAt
createdAt
updatedAt
}
}
}
`

View File

@ -1,17 +0,0 @@
import gql from 'graphql-tag'
export const getCommunities = gql`
query {
getCommunities {
id
foreign
publicKey
url
lastAnnouncedAt
verifiedAt
lastErrorAt
createdAt
updatedAt
}
}
`

View File

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

View File

@ -73,11 +73,20 @@
"error": "Fehler",
"expired": "abgelaufen",
"federation": {
"apiVersion": "API Version",
"authenticatedAt": "Verifiziert am:",
"communityUuid": "Community UUID:",
"createdAt": "Erstellt am",
"gmsApiKey": "GMS API Key:",
"toast_gmsApiKeyUpdated": "Der GMS Api Key wurde erfolgreich aktualisiert!",
"gradidoInstances": "Gradido Instanzen",
"lastAnnouncedAt": "letzte Bekanntgabe",
"lastErrorAt": "Letzer Fehler am",
"name": "Name",
"publicKey": "PublicKey:",
"url": "Url",
"verified": "Verifiziert"
"verified": "Verifiziert",
"verifiedAt": "Verifiziert am"
},
"firstname": "Vorname",
"footer": {

View File

@ -73,11 +73,20 @@
"error": "Error",
"expired": "expired",
"federation": {
"apiVersion": "API Version",
"authenticatedAt": "verified at:",
"communityUuid": "Community UUID:",
"createdAt": "Created At ",
"gmsApiKey": "GMS API Key:",
"toast_gmsApiKeyUpdated": "The GMS Api Key has been successfully updated!",
"gradidoInstances": "Gradido Instances",
"lastAnnouncedAt": "Last Announced",
"lastErrorAt": "last error at",
"name": "Name",
"publicKey": "PublicKey:",
"url": "Url",
"verified": "Verified"
"verified": "Verified",
"verifiedAt": "Verified at"
},
"firstname": "Firstname",
"footer": {

View File

@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import FederationVisualize from './FederationVisualize'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
import { getCommunities } from '@/graphql/getCommunities'
import { allCommunities } from '@/graphql/allCommunities'
import { toastErrorSpy } from '../../test/testSetup'
const mockClient = createMockClient()
@ -25,42 +25,54 @@ const mocks = {
const defaultData = () => {
return {
getCommunities: [
allCommunities: [
{
id: 1776,
foreign: true,
publicKey: 'c7ca9e742421bb167b8666cb78f90b40c665b8f35db8f001988d44dbb3ce8527',
url: 'http://localhost/api/2_0',
lastAnnouncedAt: '2023-04-07T12:27:24.037Z',
verifiedAt: null,
lastErrorAt: null,
createdAt: '2023-04-07T11:45:06.254Z',
updatedAt: null,
__typename: 'Community',
},
{
id: 1775,
foreign: true,
publicKey: 'c7ca9e742421bb167b8666cb78f90b40c665b8f35db8f001988d44dbb3ce8527',
url: 'http://localhost/api/1_1',
lastAnnouncedAt: '2023-04-07T12:27:24.023Z',
verifiedAt: null,
lastErrorAt: null,
createdAt: '2023-04-07T11:45:06.234Z',
updatedAt: null,
__typename: 'Community',
},
{
id: 1774,
foreign: true,
publicKey: 'c7ca9e742421bb167b8666cb78f90b40c665b8f35db8f001988d44dbb3ce8527',
url: 'http://localhost/api/1_0',
lastAnnouncedAt: '2023-04-07T12:27:24.009Z',
verifiedAt: null,
lastErrorAt: null,
createdAt: '2023-04-07T11:45:06.218Z',
updatedAt: null,
__typename: 'Community',
id: 1,
foreign: false,
url: 'http://localhost/api/',
publicKey: '4007170edd8d33fb009cd99ee4e87f214e7cd21b668d45540a064deb42e243c2',
communityUuid: '5ab0befd-b150-4f31-a631-7f3637e47b21',
authenticatedAt: null,
name: 'Gradido Test',
description: 'Gradido Community zum testen',
gmsApiKey: '<api key>',
creationDate: '2024-01-09T15:56:40.592Z',
createdAt: '2024-01-09T15:56:40.595Z',
updatedAt: '2024-01-16T11:17:15.000Z',
federatedCommunities: [
{
id: 2046,
apiVersion: '2_0',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.544Z',
updatedAt: null,
},
{
id: 2045,
apiVersion: '1_1',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.550Z',
updatedAt: null,
__typename: 'FederatedCommunity',
},
{
id: 2044,
apiVersion: '1_0',
endPoint: 'http://localhost/api/',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
createdAt: '2024-01-16T10:08:21.544Z',
updatedAt: null,
__typename: 'FederatedCommunity',
},
],
},
],
}
@ -68,11 +80,11 @@ const defaultData = () => {
describe('FederationVisualize', () => {
let wrapper
const getCommunitiesMock = jest.fn()
const allCommunitiesMock = jest.fn()
mockClient.setRequestHandler(
getCommunities,
getCommunitiesMock
allCommunities,
allCommunitiesMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
@ -95,7 +107,7 @@ describe('FederationVisualize', () => {
describe('sever success', () => {
it('sends query to Apollo when created', () => {
expect(getCommunitiesMock).toBeCalled()
expect(allCommunitiesMock).toBeCalled()
})
it('has a DIV element with the class "federation-visualize"', () => {
@ -106,8 +118,8 @@ describe('FederationVisualize', () => {
expect(wrapper.find('[data-test="federation-communities-refresh-btn"]').exists()).toBe(true)
})
it('renders 3 community list items', () => {
expect(wrapper.findAll('.list-group-item').length).toBe(3)
it('renders 1 community list item', () => {
expect(wrapper.findAll('.list-group-item').length).toBe(1)
})
describe('cklicking the refresh button', () => {
@ -117,7 +129,7 @@ describe('FederationVisualize', () => {
})
it('calls the API', async () => {
expect(getCommunitiesMock).toBeCalled()
expect(allCommunitiesMock).toBeCalled()
})
})
})

View File

@ -7,7 +7,7 @@
icon="arrow-clockwise"
font-scale="2"
:animation="animation"
@click="$apollo.queries.GetCommunities.refresh()"
@click="$apollo.queries.allCommunities.refresh()"
data-test="federation-communities-refresh-btn"
></b-icon>
</b-button>
@ -16,28 +16,29 @@
<b-row>
<b-col cols="1" class="ml-1">{{ $t('federation.verified') }}</b-col>
<b-col class="ml-3">{{ $t('federation.url') }}</b-col>
<b-col class="ml-3">{{ $t('federation.name') }}</b-col>
<b-col cols="2">{{ $t('federation.lastAnnouncedAt') }}</b-col>
<b-col cols="2">{{ $t('federation.createdAt') }}</b-col>
</b-row>
<b-list-group-item
v-for="item in communities"
:key="item.id"
:key="item.publicKey"
:variant="!item.foreign ? 'primary' : 'warning'"
>
<federation-visualize-item :item="item" />
<community-visualize-item :item="item" />
</b-list-group-item>
</b-list-group>
</div>
</template>
<script>
import { getCommunities } from '@/graphql/getCommunities'
import { allCommunities } from '@/graphql/allCommunities'
import FederationVisualizeItem from '../components/Fedaration/FederationVisualizeItem.vue'
import CommunityVisualizeItem from '../components/Federation/CommunityVisualizeItem.vue'
export default {
name: 'FederationVisualize',
components: {
FederationVisualizeItem,
CommunityVisualizeItem,
},
data() {
return {
@ -48,17 +49,17 @@ export default {
},
computed: {
animation() {
return this.$apollo.queries.GetCommunities.loading ? 'spin' : ''
return this.$apollo.queries.allCommunities.loading ? 'spin' : ''
},
},
apollo: {
GetCommunities: {
allCommunities: {
fetchPolicy: 'network-only',
query() {
return getCommunities
return allCommunities
},
update({ getCommunities }) {
this.communities = getCommunities
update({ allCommunities }) {
this.communities = allCommunities
},
error({ message }) {
this.toastError(message)

View File

@ -16,6 +16,7 @@ module.exports = {
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
'@arg/(.*)': '<rootDir>/src/graphql/arg/$1',
'@input/(.*)': '<rootDir>/src/graphql/input/$1',
'@dltConnector/(.*)': '<rootDir>/src/apis/dltConnector/$1',
'@enum/(.*)': '<rootDir>/src/graphql/enum/$1',
'@model/(.*)': '<rootDir>/src/graphql/model/$1',

View File

@ -6,4 +6,6 @@ export const ADMIN_RIGHTS = [
RIGHTS.UNDELETE_USER,
RIGHTS.COMMUNITY_UPDATE,
RIGHTS.COMMUNITY_BY_UUID,
RIGHTS.COMMUNITY_BY_IDENTIFIER,
RIGHTS.HOME_COMMUNITY,
]

View File

@ -0,0 +1,3 @@
import { RIGHTS } from './RIGHTS'
export const DLT_CONNECTOR_RIGHTS = [RIGHTS.COMMUNITY_BY_IDENTIFIER, RIGHTS.HOME_COMMUNITY]

View File

@ -59,5 +59,7 @@ export enum RIGHTS {
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
COMMUNITY_BY_UUID = 'COMMUNITY_BY_UUID',
COMMUNITY_BY_IDENTIFIER = 'COMMUNITY_BY_IDENTIFIER',
HOME_COMMUNITY = 'HOME_COMMUNITY',
COMMUNITY_UPDATE = 'COMMUNITY_UPDATE',
}

View File

@ -1,6 +1,7 @@
import { RoleNames } from '@/graphql/enum/RoleNames'
import { ADMIN_RIGHTS } from './ADMIN_RIGHTS'
import { DLT_CONNECTOR_RIGHTS } from './DLT_CONNECTOR_RIGHTS'
import { INALIENABLE_RIGHTS } from './INALIENABLE_RIGHTS'
import { MODERATOR_RIGHTS } from './MODERATOR_RIGHTS'
import { Role } from './Role'
@ -20,5 +21,7 @@ export const ROLE_ADMIN = new Role(RoleNames.ADMIN, [
...ADMIN_RIGHTS,
])
export const ROLE_DLT_CONNECTOR = new Role(RoleNames.DLT_CONNECTOR, DLT_CONNECTOR_RIGHTS)
// TODO from database
export const ROLES = [ROLE_UNAUTHORIZED, ROLE_USER, ROLE_MODERATOR, ROLE_ADMIN]

View File

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

View File

@ -1,14 +1,13 @@
import { IsString } from 'class-validator'
import { Field, ArgsType, InputType } from 'type-graphql'
import { IsBoolean, IsString } from 'class-validator'
import { ArgsType, Field } from 'type-graphql'
@InputType()
@ArgsType()
export class CommunityArgs {
@Field(() => String)
@Field(() => String, { nullable: true })
@IsString()
uuid: string
communityIdentifier?: string | null
@Field(() => String)
@IsString()
gmsApiKey: string
@Field(() => Boolean, { nullable: true })
@IsBoolean()
foreign?: boolean | null
}

View File

@ -1,6 +1,8 @@
import { IsBoolean, IsInt, IsString } from 'class-validator'
import { IsBoolean, IsEnum, IsInt, IsString } from 'class-validator'
import { ArgsType, Field, InputType, Int } from 'type-graphql'
import { GmsPublishLocationType } from '@enum/GmsPublishLocationType'
import { GmsPublishNameType } from '@enum/GmsPublishNameType'
import { Location } from '@model/Location'
import { isValidLocation } from '@/graphql/validator/Location'
@ -44,19 +46,19 @@ export class UpdateUserInfosArgs {
@IsBoolean()
hideAmountGDT?: boolean
@Field({ nullable: true, defaultValue: true })
@Field({ nullable: true })
@IsBoolean()
gmsAllowed?: boolean
@Field(() => Int, { nullable: true, defaultValue: 0 })
@IsInt()
gmsPublishName?: number | null
@Field(() => GmsPublishNameType, { nullable: true })
@IsEnum(GmsPublishNameType)
gmsPublishName?: GmsPublishNameType | null
@Field(() => Location, { nullable: true })
@isValidLocation()
gmsLocation?: Location | null
@Field(() => Int, { nullable: true, defaultValue: 2 })
@IsInt()
gmsPublishLocation?: number | null
@Field(() => GmsPublishLocationType, { nullable: true })
@IsEnum(GmsPublishLocationType)
gmsPublishLocation?: GmsPublishLocationType | null
}

View File

@ -6,7 +6,13 @@ import { RoleNames } from '@enum/RoleNames'
import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS'
import { decode, encode } from '@/auth/JWT'
import { RIGHTS } from '@/auth/RIGHTS'
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN, ROLE_MODERATOR } from '@/auth/ROLES'
import {
ROLE_UNAUTHORIZED,
ROLE_USER,
ROLE_ADMIN,
ROLE_MODERATOR,
ROLE_DLT_CONNECTOR,
} from '@/auth/ROLES'
import { Context } from '@/server/context'
import { LogError } from '@/server/LogError'
@ -30,31 +36,35 @@ export const isAuthorized: AuthChecker<Context> = async ({ context }, rights) =>
// Set context gradidoID
context.gradidoID = decoded.gradidoID
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
try {
const user = await User.findOneOrFail({
where: { gradidoID: decoded.gradidoID },
withDeleted: true,
relations: ['emailContact', 'userRoles'],
})
context.user = user
context.role = ROLE_USER
if (user.userRoles?.length > 0) {
switch (user.userRoles[0].role) {
case RoleNames.ADMIN:
context.role = ROLE_ADMIN
break
case RoleNames.MODERATOR:
context.role = ROLE_MODERATOR
break
default:
context.role = ROLE_USER
if (context.gradidoID === 'dlt-connector') {
context.role = ROLE_DLT_CONNECTOR
} else {
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
try {
const user = await User.findOneOrFail({
where: { gradidoID: decoded.gradidoID },
withDeleted: true,
relations: ['emailContact', 'userRoles'],
})
context.user = user
context.role = ROLE_USER
if (user.userRoles?.length > 0) {
switch (user.userRoles[0].role) {
case RoleNames.ADMIN:
context.role = ROLE_ADMIN
break
case RoleNames.MODERATOR:
context.role = ROLE_MODERATOR
break
default:
context.role = ROLE_USER
}
}
} catch {
// in case the database query fails (user deleted)
throw new LogError('401 Unauthorized')
}
} catch {
// in case the database query fails (user deleted)
throw new LogError('401 Unauthorized')
}
// check for correct rights

View File

@ -5,6 +5,7 @@ export enum RoleNames {
USER = 'USER',
MODERATOR = 'MODERATOR',
ADMIN = 'ADMIN',
DLT_CONNECTOR = 'DLT_CONNECTOR_ROLE',
}
registerEnumType(RoleNames, {

View File

@ -0,0 +1,14 @@
import { IsString, IsUUID } from 'class-validator'
import { ArgsType, Field, InputType } from 'type-graphql'
@ArgsType()
@InputType()
export class EditCommunityInput {
@Field(() => String)
@IsUUID('4')
uuid: string
@Field(() => String)
@IsString()
gmsApiKey: string
}

View File

@ -0,0 +1,76 @@
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { ObjectType, Field } from 'type-graphql'
import { FederatedCommunity } from './FederatedCommunity'
@ObjectType()
export class AdminCommunityView {
constructor(dbCom: DbCommunity) {
if (dbCom.federatedCommunities && dbCom.federatedCommunities.length > 0) {
const federatedCommunity = dbCom.federatedCommunities[0]
this.foreign = federatedCommunity.foreign
const url = new URL(federatedCommunity.endPoint)
// use only the host part
this.url = url.protocol + '//' + url.host
this.publicKey = federatedCommunity.publicKey.toString('hex')
this.federatedCommunities = dbCom.federatedCommunities.map(
(federatedCom: DbFederatedCommunity) => new FederatedCommunity(federatedCom),
)
}
if (dbCom.foreign !== undefined) {
this.foreign = dbCom.foreign
}
this.name = dbCom.name
this.description = dbCom.description
this.gmsApiKey = dbCom.gmsApiKey
if (dbCom.url) {
this.url = dbCom.url
}
if (dbCom.publicKey && dbCom.publicKey.length === 32) {
this.publicKey = dbCom.publicKey.toString('hex')
}
this.creationDate = dbCom.creationDate
this.createdAt = dbCom.createdAt
this.updatedAt = dbCom.updatedAt
this.uuid = dbCom.communityUuid
this.authenticatedAt = dbCom.authenticatedAt
this.gmsApiKey = dbCom.gmsApiKey
}
@Field(() => Boolean)
foreign: boolean
@Field(() => String)
url: string
@Field(() => String)
publicKey: string
@Field(() => String, { nullable: true })
uuid: string | null
@Field(() => Date, { nullable: true })
authenticatedAt: Date | null
@Field(() => String, { nullable: true })
name: string | null
@Field(() => String, { nullable: true })
description: string | null
@Field(() => String, { nullable: true })
gmsApiKey: string | null
@Field(() => Date, { nullable: true })
creationDate: Date | null
@Field(() => Date, { nullable: true })
createdAt: Date | null
@Field(() => Date, { nullable: true })
updatedAt: Date | null
@Field(() => [FederatedCommunity], { nullable: true })
federatedCommunities: FederatedCommunity[] | null
}

View File

@ -7,8 +7,8 @@ export class FederatedCommunity {
this.id = dbCom.id
this.foreign = dbCom.foreign
this.publicKey = dbCom.publicKey.toString('hex')
this.url =
(dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/') + dbCom.apiVersion
this.apiVersion = dbCom.apiVersion
this.endPoint = dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'
this.lastAnnouncedAt = dbCom.lastAnnouncedAt
this.verifiedAt = dbCom.verifiedAt
this.lastErrorAt = dbCom.lastErrorAt
@ -26,7 +26,10 @@ export class FederatedCommunity {
publicKey: string
@Field(() => String)
url: string
apiVersion: string
@Field(() => String)
endPoint: string
@Field(() => Date, { nullable: true })
lastAnnouncedAt: Date | null

View File

@ -1,6 +1,9 @@
import { User as dbUser } from '@entity/User'
import { ObjectType, Field, Int } from 'type-graphql'
import { GmsPublishLocationType } from '@enum/GmsPublishLocationType'
import { GmsPublishNameType } from '@enum/GmsPublishNameType'
import { KlickTipp } from './KlickTipp'
@ObjectType()
@ -29,6 +32,9 @@ export class User {
this.hasElopage = null
this.hideAmountGDD = user.hideAmountGDD
this.hideAmountGDT = user.hideAmountGDT
this.gmsAllowed = user.gmsAllowed
this.gmsPublishName = user.gmsPublishName
this.gmsPublishLocation = user.gmsPublishLocation
}
}
@ -74,6 +80,15 @@ export class User {
@Field(() => Boolean)
hideAmountGDT: boolean
@Field(() => Boolean)
gmsAllowed: boolean
@Field(() => GmsPublishNameType, { nullable: true })
gmsPublishName: GmsPublishNameType | null
@Field(() => GmsPublishLocationType, { nullable: true })
gmsPublishLocation: GmsPublishLocationType | null
// This is not the users publisherId, but the one of the users who recommend him
@Field(() => Int, { nullable: true })
publisherId: number | null

View File

@ -10,13 +10,20 @@ import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { ApolloServerTestClient } from 'apollo-server-testing'
import { GraphQLError } from 'graphql/error/GraphQLError'
import { v4 as uuidv4 } from 'uuid'
import { cleanDB, testEnvironment } from '@test/helpers'
import { logger, i18n as localization } from '@test/testSetup'
import { userFactory } from '@/seeds/factory/user'
import { login, updateHomeCommunityQuery } from '@/seeds/graphql/mutations'
import { getCommunities, communitiesQuery, getCommunityByUuidQuery } from '@/seeds/graphql/queries'
import {
allCommunities,
getCommunities,
communitiesQuery,
getHomeCommunityQuery,
getCommunityByIdentifierQuery,
} from '@/seeds/graphql/queries'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { getCommunityByUuid } from './util/communities'
@ -164,7 +171,8 @@ describe('CommunityResolver', () => {
id: 3,
foreign: homeCom3.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public),
url: expect.stringMatching('http://localhost/api/2_0'),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '2_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -175,7 +183,8 @@ describe('CommunityResolver', () => {
id: 2,
foreign: homeCom2.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public),
url: expect.stringMatching('http://localhost/api/1_1'),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_1',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -186,7 +195,8 @@ describe('CommunityResolver', () => {
id: 1,
foreign: homeCom1.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
url: expect.stringMatching('http://localhost/api/1_0'),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -222,7 +232,7 @@ describe('CommunityResolver', () => {
foreignCom3 = DbFederatedCommunity.create()
foreignCom3.foreign = true
foreignCom3.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex')
foreignCom3.apiVersion = '1_2'
foreignCom3.apiVersion = '2_0'
foreignCom3.endPoint = 'http://remotehost/api'
foreignCom3.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom3)
@ -236,7 +246,8 @@ describe('CommunityResolver', () => {
id: 3,
foreign: homeCom3.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public),
url: expect.stringMatching('http://localhost/api/2_0'),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '2_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -247,7 +258,8 @@ describe('CommunityResolver', () => {
id: 2,
foreign: homeCom2.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public),
url: expect.stringMatching('http://localhost/api/1_1'),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_1',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -258,7 +270,8 @@ describe('CommunityResolver', () => {
id: 1,
foreign: homeCom1.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
url: expect.stringMatching('http://localhost/api/1_0'),
endPoint: expect.stringMatching('http://localhost/api/'),
apiVersion: '1_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -269,7 +282,8 @@ describe('CommunityResolver', () => {
id: 6,
foreign: foreignCom3.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[5].public),
url: expect.stringMatching('http://remotehost/api/1_2'),
endPoint: expect.stringMatching('http://remotehost/api/'),
apiVersion: '2_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -280,7 +294,8 @@ describe('CommunityResolver', () => {
id: 5,
foreign: foreignCom2.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[4].public),
url: expect.stringMatching('http://remotehost/api/1_1'),
endPoint: expect.stringMatching('http://remotehost/api/'),
apiVersion: '1_1',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -291,7 +306,8 @@ describe('CommunityResolver', () => {
id: 4,
foreign: foreignCom1.foreign,
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[3].public),
url: expect.stringMatching('http://remotehost/api/1_0'),
endPoint: expect.stringMatching('http://remotehost/api/'),
apiVersion: '1_0',
lastAnnouncedAt: null,
verifiedAt: null,
lastErrorAt: null,
@ -303,6 +319,229 @@ describe('CommunityResolver', () => {
})
})
})
describe('with 6 federated community entries', () => {
let comHomeCom1: DbCommunity
let comForeignCom1: DbCommunity
let comForeignCom2: DbCommunity
let foreignCom4: DbFederatedCommunity
beforeEach(async () => {
jest.clearAllMocks()
comHomeCom1 = DbCommunity.create()
comHomeCom1.foreign = false
comHomeCom1.url = 'http://localhost'
comHomeCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[0].public, 'hex')
comHomeCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[0].private, 'hex')
comHomeCom1.communityUuid = 'HomeCom-UUID'
comHomeCom1.authenticatedAt = new Date()
comHomeCom1.name = 'HomeCommunity-name'
comHomeCom1.description = 'HomeCommunity-description'
comHomeCom1.creationDate = new Date()
await DbCommunity.insert(comHomeCom1)
comForeignCom1 = DbCommunity.create()
comForeignCom1.foreign = true
comForeignCom1.url = 'http://stage-2.gradido.net'
comForeignCom1.publicKey = Buffer.from(ed25519KeyPairStaticHex[3].public, 'hex')
comForeignCom1.privateKey = Buffer.from(ed25519KeyPairStaticHex[3].private, 'hex')
// foreignCom1.communityUuid = 'Stage2-Com-UUID'
// foreignCom1.authenticatedAt = new Date()
comForeignCom1.name = 'Stage-2_Community-name'
comForeignCom1.description = 'Stage-2_Community-description'
comForeignCom1.creationDate = new Date()
await DbCommunity.insert(comForeignCom1)
comForeignCom2 = DbCommunity.create()
comForeignCom2.foreign = true
comForeignCom2.url = 'http://stage-3.gradido.net'
comForeignCom2.publicKey = Buffer.from(ed25519KeyPairStaticHex[4].public, 'hex')
comForeignCom2.privateKey = Buffer.from(ed25519KeyPairStaticHex[4].private, 'hex')
comForeignCom2.communityUuid = 'Stage3-Com-UUID'
comForeignCom2.authenticatedAt = new Date()
comForeignCom2.name = 'Stage-3_Community-name'
comForeignCom2.description = 'Stage-3_Community-description'
comForeignCom2.creationDate = new Date()
await DbCommunity.insert(comForeignCom2)
foreignCom4 = DbFederatedCommunity.create()
foreignCom4.foreign = true
foreignCom4.publicKey = Buffer.from(ed25519KeyPairStaticHex[5].public, 'hex')
foreignCom4.apiVersion = '1_0'
foreignCom4.endPoint = 'http://remotehost/api'
foreignCom4.createdAt = new Date()
await DbFederatedCommunity.insert(foreignCom4)
})
it('return communities structured for admin ', async () => {
await expect(query({ query: allCommunities })).resolves.toMatchObject({
data: {
allCommunities: [
{
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[2].public),
authenticatedAt: null,
createdAt: null,
creationDate: null,
description: null,
gmsApiKey: null,
name: null,
updatedAt: null,
uuid: null,
federatedCommunities: [
{
id: 3,
apiVersion: '2_0',
endPoint: 'http://localhost/api/',
createdAt: homeCom3.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[1].public),
authenticatedAt: null,
createdAt: null,
creationDate: null,
description: null,
gmsApiKey: null,
name: null,
updatedAt: null,
uuid: null,
federatedCommunities: [
{
id: 2,
apiVersion: '1_1',
endPoint: 'http://localhost/api/',
createdAt: homeCom2.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: false,
url: 'http://localhost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[0].public),
authenticatedAt: comHomeCom1.authenticatedAt?.toISOString(),
createdAt: comHomeCom1.createdAt.toISOString(),
creationDate: comHomeCom1.creationDate?.toISOString(),
description: comHomeCom1.description,
gmsApiKey: null,
name: comHomeCom1.name,
updatedAt: null,
uuid: comHomeCom1.communityUuid,
federatedCommunities: [
{
id: 1,
apiVersion: '1_0',
endPoint: 'http://localhost/api/',
createdAt: homeCom1.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: true,
url: 'http://remotehost',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[5].public),
authenticatedAt: null,
createdAt: null,
creationDate: null,
description: null,
gmsApiKey: null,
name: null,
updatedAt: null,
uuid: null,
federatedCommunities: [
{
id: 7,
apiVersion: '1_0',
endPoint: 'http://remotehost/api/',
createdAt: foreignCom4.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
{
id: 6,
apiVersion: '2_0',
endPoint: 'http://remotehost/api/',
createdAt: foreignCom3.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: true,
url: 'http://stage-3.gradido.net',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[4].public),
authenticatedAt: comForeignCom2.authenticatedAt?.toISOString(),
createdAt: comForeignCom2.createdAt.toISOString(),
creationDate: comForeignCom2.creationDate?.toISOString(),
description: comForeignCom2.description,
gmsApiKey: null,
name: comForeignCom2.name,
updatedAt: null,
uuid: comForeignCom2.communityUuid,
federatedCommunities: [
{
id: 5,
apiVersion: '1_1',
endPoint: 'http://remotehost/api/',
createdAt: foreignCom2.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
{
foreign: true,
url: 'http://stage-2.gradido.net',
publicKey: expect.stringMatching(ed25519KeyPairStaticHex[3].public),
authenticatedAt: null,
createdAt: comForeignCom1.createdAt.toISOString(),
creationDate: comForeignCom1.creationDate?.toISOString(),
description: comForeignCom1.description,
gmsApiKey: null,
name: comForeignCom1.name,
updatedAt: null,
uuid: null,
federatedCommunities: [
{
id: 4,
apiVersion: '1_0',
endPoint: 'http://remotehost/api/',
createdAt: foreignCom1.createdAt.toISOString(),
lastAnnouncedAt: null,
lastErrorAt: null,
updatedAt: null,
verifiedAt: null,
},
],
},
],
},
})
})
})
})
describe('communities', () => {
@ -464,8 +703,14 @@ describe('CommunityResolver', () => {
foreignCom1 = DbCommunity.create()
foreignCom1.foreign = true
foreignCom1.url = 'http://stage-2.gradido.net/api'
foreignCom1.publicKey = Buffer.from('publicKey-stage-2_Community')
foreignCom1.privateKey = Buffer.from('privateKey-stage-2_Community')
foreignCom1.publicKey = Buffer.from(
'8a1f9374b99c30d827b85dcd23f7e50328430d64ef65ef35bf375ea8eb9a2e1d',
'hex',
)
foreignCom1.privateKey = Buffer.from(
'f6c2a9d78e20a3c910f35b8ffcf824aa7b37f0d3d81bfc4c0e65e17a194b3a4a',
'hex',
)
// foreignCom1.communityUuid = 'Stage2-Com-UUID'
// foreignCom1.authenticatedAt = new Date()
foreignCom1.name = 'Stage-2_Community-name'
@ -476,9 +721,15 @@ describe('CommunityResolver', () => {
foreignCom2 = DbCommunity.create()
foreignCom2.foreign = true
foreignCom2.url = 'http://stage-3.gradido.net/api'
foreignCom2.publicKey = Buffer.from('publicKey-stage-3_Community')
foreignCom2.privateKey = Buffer.from('privateKey-stage-3_Community')
foreignCom2.communityUuid = 'Stage3-Com-UUID'
foreignCom2.publicKey = Buffer.from(
'e047365a54082e8a7e9273da61b55c8134a2a0c836799ba12b78b9b0c52bc85f',
'hex',
)
foreignCom2.privateKey = Buffer.from(
'e047365a54082e8a7e9273da61b55c8134a2a0c836799ba12b78b9b0c52bc85f',
'hex',
)
foreignCom2.communityUuid = uuidv4()
foreignCom2.authenticatedAt = new Date()
foreignCom2.name = 'Stage-3_Community-name'
foreignCom2.description = 'Stage-3_Community-description'
@ -486,15 +737,36 @@ describe('CommunityResolver', () => {
await DbCommunity.insert(foreignCom2)
})
it('finds the home-community', async () => {
it('finds the home-community by uuid', async () => {
await expect(
query({
query: getCommunityByUuidQuery,
variables: { communityUuid: homeCom?.communityUuid },
query: getCommunityByIdentifierQuery,
variables: { communityIdentifier: homeCom?.communityUuid },
}),
).resolves.toMatchObject({
data: {
community: {
communityByIdentifier: {
id: homeCom?.id,
foreign: homeCom?.foreign,
name: homeCom?.name,
description: homeCom?.description,
url: homeCom?.url,
creationDate: homeCom?.creationDate?.toISOString(),
uuid: homeCom?.communityUuid,
authenticatedAt: homeCom?.authenticatedAt,
},
},
})
})
it('finds the home-community', async () => {
await expect(
query({
query: getHomeCommunityQuery,
}),
).resolves.toMatchObject({
data: {
homeCommunity: {
id: homeCom?.id,
foreign: homeCom?.foreign,
name: homeCom?.name,
@ -563,7 +835,7 @@ describe('CommunityResolver', () => {
expect(
await mutate({
mutation: updateHomeCommunityQuery,
variables: { uuid: 'unknownUuid', gmsApiKey: 'gmsApiKey' },
variables: { uuid: uuidv4(), gmsApiKey: 'gmsApiKey' },
}),
).toEqual(
expect.objectContaining({

View File

@ -1,16 +1,23 @@
import { IsNull, Not } from '@dbTools/typeorm'
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { Resolver, Query, Authorized, Arg, Mutation, Args } from 'type-graphql'
import { Resolver, Query, Authorized, Mutation, Args, Arg } from 'type-graphql'
import { CommunityArgs } from '@arg//CommunityArgs'
import { Paginated } from '@arg/Paginated'
import { EditCommunityInput } from '@input/EditCommunityInput'
import { AdminCommunityView } from '@model/AdminCommunityView'
import { Community } from '@model/Community'
import { FederatedCommunity } from '@model/FederatedCommunity'
import { RIGHTS } from '@/auth/RIGHTS'
import { LogError } from '@/server/LogError'
import { getCommunityByUuid } from './util/communities'
import {
getAllCommunities,
getCommunityByIdentifier,
getCommunityByUuid,
getHomeCommunity,
} from './util/communities'
@Resolver()
export class CommunityResolver {
@ -29,6 +36,12 @@ export class CommunityResolver {
)
}
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => [AdminCommunityView])
async allCommunities(@Args() paginated: Paginated): Promise<AdminCommunityView[]> {
return (await getAllCommunities(paginated)).map((dbCom) => new AdminCommunityView(dbCom))
}
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => [Community])
async communities(): Promise<Community[]> {
@ -41,41 +54,42 @@ export class CommunityResolver {
return dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom))
}
@Authorized([RIGHTS.COMMUNITY_BY_UUID])
@Authorized([RIGHTS.COMMUNITY_BY_IDENTIFIER])
@Query(() => Community)
async community(@Arg('communityUuid') communityUuid: string): Promise<Community> {
const com: DbCommunity | null = await getCommunityByUuid(communityUuid)
if (!com) {
throw new LogError('community not found', communityUuid)
async communityByIdentifier(
@Arg('communityIdentifier') communityIdentifier: string,
): Promise<Community> {
const community = await getCommunityByIdentifier(communityIdentifier)
if (!community) {
throw new LogError('community not found', communityIdentifier)
}
return new Community(com)
return new Community(community)
}
@Authorized([RIGHTS.HOME_COMMUNITY])
@Query(() => Community)
async homeCommunity(): Promise<Community> {
const community = await getHomeCommunity()
if (!community) {
throw new LogError('no home community exist')
}
return new Community(community)
}
@Authorized([RIGHTS.COMMUNITY_UPDATE])
@Mutation(() => Community)
async updateHomeCommunity(@Args() { uuid, gmsApiKey }: CommunityArgs): Promise<Community> {
let homeCom: DbCommunity | null
let com: Community
if (uuid) {
let toUpdate = false
homeCom = await getCommunityByUuid(uuid)
if (!homeCom) {
throw new LogError('HomeCommunity with uuid not found: ', uuid)
}
if (homeCom.foreign) {
throw new LogError('Error: Only the HomeCommunity could be modified!')
}
if (homeCom.gmsApiKey !== gmsApiKey) {
homeCom.gmsApiKey = gmsApiKey
toUpdate = true
}
if (toUpdate) {
await DbCommunity.save(homeCom)
}
com = new Community(homeCom)
} else {
throw new LogError(`HomeCommunity without an uuid can't be modified!`)
async updateHomeCommunity(@Args() { uuid, gmsApiKey }: EditCommunityInput): Promise<Community> {
const homeCom = await getCommunityByUuid(uuid)
if (!homeCom) {
throw new LogError('HomeCommunity with uuid not found: ', uuid)
}
return com
if (homeCom.foreign) {
throw new LogError('Error: Only the HomeCommunity could be modified!')
}
if (homeCom.gmsApiKey !== gmsApiKey) {
homeCom.gmsApiKey = gmsApiKey
await DbCommunity.save(homeCom)
}
return new Community(homeCom)
}
}

View File

@ -15,8 +15,6 @@ import { ApolloServerTestClient } from 'apollo-server-testing'
import { GraphQLError } from 'graphql'
import { v4 as uuidv4 } from 'uuid'
import { GmsPublishLocationType } from '@enum/GmsPublishLocationType'
import { GmsPublishNameType } from '@enum/GmsPublishNameType'
import { cleanDB, testEnvironment } from '@test/helpers'
import { logger } from '@test/testSetup'
@ -530,13 +528,12 @@ describe('send coins', () => {
describe('send coins via alias', () => {
beforeAll(async () => {
// first set alias to null, because updating alias isn't allowed
await User.update({ alias: 'MeisterBob' }, { alias: () => 'NULL' })
await mutate({
mutation: updateUserInfos,
variables: {
alias: 'bob',
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
},
})
await mutate({

View File

@ -38,7 +38,7 @@ import { calculateBalance } from '@/util/validate'
import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions'
import { BalanceResolver } from './BalanceResolver'
import { getCommunityByUuid, getCommunityName, isHomeCommunity } from './util/communities'
import { getCommunityByIdentifier, getCommunityName, isHomeCommunity } from './util/communities'
import { findUserByIdentifier } from './util/findUserByIdentifier'
import { getLastTransaction } from './util/getLastTransaction'
import { getTransactionList } from './util/getTransactionList'
@ -452,7 +452,7 @@ export class TransactionResolver {
if (!CONFIG.FEDERATION_XCOM_SENDCOINS_ENABLED) {
throw new LogError('X-Community sendCoins disabled per configuration!')
}
const recipCom = await getCommunityByUuid(recipientCommunityIdentifier)
const recipCom = await getCommunityByIdentifier(recipientCommunityIdentifier)
logger.debug('recipient commuity: ', recipCom)
if (recipCom === null) {
throw new LogError(

View File

@ -1258,6 +1258,8 @@ describe('UserResolver', () => {
describe('valid alias', () => {
it('updates the user in DB', async () => {
// first empty alias, because currently updating alias isn't allowed
await User.update({ alias: 'BBB' }, { alias: () => 'NULL' })
await mutate({
mutation: updateUserInfos,
variables: {
@ -1303,8 +1305,10 @@ describe('UserResolver', () => {
mutation: updateUserInfos,
variables: {
gmsAllowed: false,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_APPROXIMATE,
gmsPublishName:
GmsPublishNameType[GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL],
gmsPublishLocation:
GmsPublishLocationType[GmsPublishLocationType.GMS_LOCATION_TYPE_APPROXIMATE],
},
})
await expect(User.find()).resolves.toEqual([
@ -1326,9 +1330,11 @@ describe('UserResolver', () => {
mutation: updateUserInfos,
variables: {
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishName:
GmsPublishNameType[GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS],
gmsLocation: loc,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
gmsPublishLocation:
GmsPublishLocationType[GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM],
},
})
await expect(User.find()).resolves.toEqual([
@ -2670,13 +2676,12 @@ describe('UserResolver', () => {
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
// first set alias to null, because updating alias isn't currently allowed
await User.update({ alias: 'BBB' }, { alias: () => 'NULL' })
await mutate({
mutation: updateUserInfos,
variables: {
alias: 'bibi',
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
},
})
})

View File

@ -19,8 +19,6 @@ import { SearchUsersFilters } from '@arg/SearchUsersFilters'
import { SetUserRoleArgs } from '@arg/SetUserRoleArgs'
import { UnsecureLoginArgs } from '@arg/UnsecureLoginArgs'
import { UpdateUserInfosArgs } from '@arg/UpdateUserInfosArgs'
import { GmsPublishLocationType } from '@enum/GmsPublishLocationType'
import { GmsPublishNameType } from '@enum/GmsPublishNameType'
import { OptInType } from '@enum/OptInType'
import { Order } from '@enum/Order'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
@ -561,17 +559,6 @@ export class UserResolver {
logger.info(
`updateUserInfos(${firstName}, ${lastName}, ${alias}, ${language}, ***, ***, ${hideAmountGDD}, ${hideAmountGDT}, ${gmsAllowed}, ${gmsPublishName}, ${gmsLocation}, ${gmsPublishLocation})...`,
)
// check default arg settings
if (gmsAllowed === null || gmsAllowed === undefined) {
gmsAllowed = true
}
if (!gmsPublishName) {
gmsPublishName = GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS
}
if (!gmsPublishLocation) {
gmsPublishLocation = GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM
}
const user = getUser(context)
// try {
if (firstName) {
@ -582,7 +569,8 @@ export class UserResolver {
user.lastName = lastName
}
if (alias && (await validateAlias(alias))) {
// currently alias can only be set, not updated
if (alias && !user.alias && (await validateAlias(alias))) {
user.alias = alias
}
@ -619,13 +607,18 @@ export class UserResolver {
if (hideAmountGDT !== undefined) {
user.hideAmountGDT = hideAmountGDT
}
user.gmsAllowed = gmsAllowed
user.gmsPublishName = gmsPublishName
if (gmsAllowed !== undefined) {
user.gmsAllowed = gmsAllowed
}
if (gmsPublishName !== null && gmsPublishName !== undefined) {
user.gmsPublishName = gmsPublishName
}
if (gmsLocation) {
user.location = Location2Point(gmsLocation)
}
user.gmsPublishLocation = gmsPublishLocation
if (gmsPublishLocation !== null && gmsPublishLocation !== undefined) {
user.gmsPublishLocation = gmsPublishLocation
}
// } catch (err) {
// console.log('error:', err)
// }

View File

@ -1,65 +1,146 @@
import { FindOneOptions } from '@dbTools/typeorm'
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { Paginated } from '@arg/Paginated'
import { LogError } from '@/server/LogError'
import { Connection } from '@/typeorm/connection'
function findWithCommunityIdentifier(communityIdentifier: string): FindOneOptions<DbCommunity> {
return {
where: [
{ communityUuid: communityIdentifier },
{ name: communityIdentifier },
{ url: communityIdentifier },
],
}
}
/**
* Checks if a community with the given identifier exists and is not foreign.
* @param communityIdentifier The identifier (URL, UUID, or name) of the community.
* @returns A promise that resolves to true if a non-foreign community exists with the given identifier, otherwise false.
*/
export async function isHomeCommunity(communityIdentifier: string): Promise<boolean> {
const homeCommunity = await DbCommunity.findOne({
// The !! operator in JavaScript or TypeScript is a shorthand for converting a value to a boolean.
// It essentially converts any truthy value to true and any falsy value to false.
return !!(await DbCommunity.findOne({
where: [
{ foreign: false, communityUuid: communityIdentifier },
{ foreign: false, name: communityIdentifier },
{ foreign: false, url: communityIdentifier },
],
})
if (homeCommunity) {
return true
} else {
return false
}
}))
}
/**
* Retrieves the home community, i.e., a community that is not foreign.
* @returns A promise that resolves to the home community, or throw if no home community was found
*/
export async function getHomeCommunity(): Promise<DbCommunity> {
return await DbCommunity.findOneOrFail({
where: [{ foreign: false }],
})
}
/**
* TODO: Check if it is needed, because currently it isn't used at all
* Retrieves the URL of the community with the given identifier.
* @param communityIdentifier The identifier (URL, UUID, or name) of the community.
* @returns A promise that resolves to the URL of the community or throw if no community with this identifier was found
*/
export async function getCommunityUrl(communityIdentifier: string): Promise<string> {
const community = await DbCommunity.findOneOrFail({
where: [
{ communityUuid: communityIdentifier },
{ name: communityIdentifier },
{ url: communityIdentifier },
],
})
return community.url
return (await DbCommunity.findOneOrFail(findWithCommunityIdentifier(communityIdentifier))).url
}
/**
* TODO: Check if it is needed, because currently it isn't used at all
* Checks if a community with the given identifier exists and has an authenticatedAt property set.
* @param communityIdentifier The identifier (URL, UUID, or name) of the community.
* @returns A promise that resolves to true if a community with an authenticatedAt property exists with the given identifier,
* otherwise false.
*/
export async function isCommunityAuthenticated(communityIdentifier: string): Promise<boolean> {
const community = await DbCommunity.findOne({
where: [
{ communityUuid: communityIdentifier },
{ name: communityIdentifier },
{ url: communityIdentifier },
],
})
if (community?.authenticatedAt) {
return true
} else {
return false
}
// The !! operator in JavaScript or TypeScript is a shorthand for converting a value to a boolean.
// It essentially converts any truthy value to true and any falsy value to false.
return !!(await DbCommunity.findOne(findWithCommunityIdentifier(communityIdentifier)))
?.authenticatedAt
}
/**
* Retrieves the name of the community with the given identifier.
* @param communityIdentifier The identifier (URL, UUID) of the community.
* @returns A promise that resolves to the name of the community. If the community does not exist or has no name,
* an empty string is returned.
*/
export async function getCommunityName(communityIdentifier: string): Promise<string> {
const community = await DbCommunity.findOne({
where: [{ communityUuid: communityIdentifier }, { url: communityIdentifier }],
})
if (community?.name) {
return community.name
} else {
return ''
}
}
return community?.name ? community.name : ''
}
export async function getCommunityByUuid(communityUuid: string): Promise<DbCommunity | null> {
return await DbCommunity.findOne({
where: [{ communityUuid }],
})
}
export async function getCommunityByIdentifier(
communityIdentifier: string,
): Promise<DbCommunity | null> {
return await DbCommunity.findOne(findWithCommunityIdentifier(communityIdentifier))
}
/**
* Simulate RIGHT Join between Communities and Federated Communities
* select *
* Community as c
* RIGHT JOIN FederatedCommunity as f
* ON(c.public_key = f.public_key)
* Typeorm don't has right joins
* @returns
*/
export async function getAllCommunities({
pageSize = 25,
currentPage = 1,
}: Paginated): Promise<DbCommunity[]> {
const connection = await Connection.getInstance()
if (!connection) {
throw new LogError('Cannot connect to db')
}
// foreign: 'ASC',
// createdAt: 'DESC',
// lastAnnouncedAt: 'DESC',
const result = await connection
.getRepository(DbFederatedCommunity)
.createQueryBuilder('federatedCommunity')
.leftJoinAndSelect('federatedCommunity.community', 'community')
.orderBy('federatedCommunity.foreign', 'ASC')
.addOrderBy('federatedCommunity.createdAt', 'DESC')
.addOrderBy('federatedCommunity.lastAnnouncedAt', 'DESC')
.skip((currentPage - 1) * pageSize * 3)
.take(pageSize * 3)
.getManyAndCount()
const communityMap = new Map<string, DbCommunity>()
result[0].forEach((value: DbFederatedCommunity) => {
const publicKeyHex = value.publicKey.toString('hex')
if (!communityMap.has(value.publicKey.toString('hex'))) {
let community: DbCommunity = DbCommunity.create()
if (value.community) {
community = value.community
}
if (!community.federatedCommunities) {
community.federatedCommunities = []
}
communityMap.set(publicKeyHex, community)
}
const community = communityMap.get(publicKeyHex)
if (!community?.federatedCommunities) {
throw new LogError('missing community after set it into map', publicKeyHex)
}
community.federatedCommunities.push(value)
})
return Array.from(communityMap.values())
}

View File

@ -2,9 +2,11 @@ import { FindOptionsWhere } from '@dbTools/typeorm'
import { Community } from '@entity/Community'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { isURL } from 'class-validator'
import { validate, version } from 'uuid'
import { LogError } from '@/server/LogError'
import { isEMail, isUUID4 } from '@/util/validate'
import { VALID_ALIAS_REGEX } from './validateAlias'
@ -19,10 +21,11 @@ export const findUserByIdentifier = async (
communityIdentifier: string,
): Promise<DbUser> => {
let user: DbUser | null
const communityWhere: FindOptionsWhere<Community> =
validate(communityIdentifier) && version(communityIdentifier) === 4
? { communityUuid: communityIdentifier }
: { name: communityIdentifier }
const communityWhere: FindOptionsWhere<Community> = isURL(communityIdentifier)
? { url: communityIdentifier }
: isUUID4(communityIdentifier)
? { communityUuid: communityIdentifier }
: { name: communityIdentifier }
if (validate(identifier) && version(identifier) === 4) {
user = await DbUser.findOne({
@ -32,7 +35,7 @@ export const findUserByIdentifier = async (
if (!user) {
throw new LogError('No user found to given identifier(s)', identifier, communityIdentifier)
}
} else if (/^.{2,}@.{2,}\..{2,}$/.exec(identifier)) {
} else if (isEMail(identifier)) {
const userContact = await DbUserContact.findOne({
where: {
email: identifier,

View File

@ -35,9 +35,9 @@ export const updateUserInfos = gql`
$hideAmountGDD: Boolean
$hideAmountGDT: Boolean
$gmsAllowed: Boolean
$gmsPublishName: Int
$gmsPublishName: GmsPublishNameType
$gmsLocation: Location
$gmsPublishLocation: Int
$gmsPublishLocation: GmsPublishLocationType
) {
updateUserInfos(
firstName: $firstName

View File

@ -134,9 +134,25 @@ export const communitiesQuery = gql`
}
`
export const getCommunityByUuidQuery = gql`
query ($communityUuid: String!) {
community(communityUuid: $communityUuid) {
export const getCommunityByIdentifierQuery = gql`
query ($communityIdentifier: String!) {
communityByIdentifier(communityIdentifier: $communityIdentifier) {
id
foreign
name
description
url
creationDate
uuid
authenticatedAt
gmsApiKey
}
}
`
export const getHomeCommunityQuery = gql`
query {
homeCommunity {
id
foreign
name
@ -156,7 +172,8 @@ export const getCommunities = gql`
id
foreign
publicKey
url
endPoint
apiVersion
lastAnnouncedAt
verifiedAt
lastErrorAt
@ -166,6 +183,34 @@ export const getCommunities = gql`
}
`
export const allCommunities = gql`
query {
allCommunities {
foreign
url
publicKey
uuid
authenticatedAt
name
description
gmsApiKey
creationDate
createdAt
updatedAt
federatedCommunities {
id
apiVersion
endPoint
lastAnnouncedAt
verifiedAt
lastErrorAt
createdAt
updatedAt
}
}
}
`
export const queryTransactionLink = gql`
query ($code: String!) {
queryTransactionLink(code: $code) {

View File

@ -1,5 +1,6 @@
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { Decimal } from 'decimal.js-light'
import { validate, version } from 'uuid'
import { Decay } from '@model/Decay'
@ -16,6 +17,14 @@ function isStringBoolean(value: string): boolean {
return false
}
function isUUID4(value: string): boolean {
return validate(value) && version(value) === 4
}
function isEMail(value: string): boolean {
return /^.{2,}@.{2,}\..{2,}$/.exec(value) !== null
}
async function calculateBalance(
userId: number,
amount: Decimal,
@ -42,4 +51,4 @@ async function calculateBalance(
return { balance, lastTransactionId: lastTransaction.id, decay }
}
export { calculateBalance, isStringBoolean }
export { calculateBalance, isStringBoolean, isUUID4, isEMail }

View File

@ -49,6 +49,7 @@
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"@/*": ["src/*"],
"@arg/*": ["src/graphql/arg/*"],
"@input/*": ["src/graphql/input/*"],
"@dltConnector/*": ["src/apis/dltConnector/*"],
"@enum/*": ["src/graphql/enum/*"],
"@model/*": ["src/graphql/model/*"],

View File

@ -0,0 +1,78 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
JoinColumn,
} from 'typeorm'
import { FederatedCommunity } from '../FederatedCommunity'
import { User } from '../User'
@Entity('communities')
export class Community extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'foreign', type: 'bool', nullable: false, default: true })
foreign: boolean
@Column({ name: 'url', length: 255, nullable: false })
url: string
@Column({ name: 'public_key', type: 'binary', length: 32, nullable: false })
publicKey: Buffer
@Column({ name: 'private_key', type: 'binary', length: 64, nullable: true })
privateKey: Buffer | null
@Column({
name: 'community_uuid',
type: 'char',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
communityUuid: string | null
@Column({ name: 'authenticated_at', type: 'datetime', nullable: true })
authenticatedAt: Date | null
@Column({ name: 'name', type: 'varchar', length: 40, nullable: true })
name: string | null
@Column({ name: 'description', type: 'varchar', length: 255, nullable: true })
description: string | null
@CreateDateColumn({ name: 'creation_date', type: 'datetime', nullable: true })
creationDate: Date | null
@Column({ name: 'gms_api_key', type: 'varchar', length: 512, nullable: true, default: null })
gmsApiKey: string | null
@CreateDateColumn({
name: 'created_at',
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP(3)',
nullable: false,
})
createdAt: Date
@UpdateDateColumn({
name: 'updated_at',
type: 'datetime',
onUpdate: 'CURRENT_TIMESTAMP(3)',
nullable: true,
})
updatedAt: Date | null
@OneToMany(() => User, (user) => user.community)
@JoinColumn({ name: 'community_uuid', referencedColumnName: 'communityUuid' })
users: User[]
@OneToMany(() => FederatedCommunity, (federatedCommunity) => federatedCommunity.community)
@JoinColumn({ name: 'public_key', referencedColumnName: 'publicKey' })
federatedCommunities?: FederatedCommunity[]
}

View File

@ -0,0 +1,58 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm'
import { Community } from '../Community'
@Entity('federated_communities')
export class FederatedCommunity extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'foreign', type: 'bool', nullable: false, default: true })
foreign: boolean
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
publicKey: Buffer
@Column({ name: 'api_version', length: 10, nullable: false })
apiVersion: string
@Column({ name: 'end_point', length: 255, nullable: false })
endPoint: string
@Column({ name: 'last_announced_at', type: 'datetime', nullable: true })
lastAnnouncedAt: Date | null
@Column({ name: 'verified_at', type: 'datetime', nullable: true })
verifiedAt: Date | null
@Column({ name: 'last_error_at', type: 'datetime', nullable: true })
lastErrorAt: Date | null
@CreateDateColumn({
name: 'created_at',
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP(3)',
nullable: false,
})
createdAt: Date
@UpdateDateColumn({
name: 'updated_at',
type: 'datetime',
onUpdate: 'CURRENT_TIMESTAMP(3)',
nullable: true,
})
updatedAt: Date | null
@ManyToOne(() => Community, (community) => community.federatedCommunities)
@JoinColumn({ name: 'public_key', referencedColumnName: 'publicKey' })
community?: Community
}

View File

@ -1 +1 @@
export { Community } from './0082-introduce_gms_registration/Community'
export { Community } from './0083-join_community_federated_communities/Community'

View File

@ -1 +1 @@
export { FederatedCommunity } from './0068-community_tables_public_key_length/FederatedCommunity'
export { FederatedCommunity } from './0083-join_community_federated_communities/FederatedCommunity'

View File

@ -0,0 +1,3 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}

View File

@ -26,7 +26,7 @@ EMAIL_CODE_REQUEST_TIME=10
# config versions
DATABASE_CONFIG_VERSION=v1.2022-03-18
BACKEND_CONFIG_VERSION=v21.2024-01-06
FRONTEND_CONFIG_VERSION=v5.2024-01-08
FRONTEND_CONFIG_VERSION=v6.2024-02-27
ADMIN_CONFIG_VERSION=v2.2024-01-04
FEDERATION_CONFIG_VERSION=v2.2023-08-24
FEDERATION_DHT_CONFIG_VERSION=v4.2024-01-17
@ -120,7 +120,7 @@ DEFAULT_PUBLISHER_ID=2896
WEBHOOK_ELOPAGE_SECRET=secret
# GMS
#GMS_ACTIVE=true
GMS_ACTIVE=false
# Coordinates of Illuminz test instance
#GMS_URL=http://54.176.169.179:3071
#GMS_URL=http://localhost:4044/
GMS_URL=http://localhost:4044/

View File

@ -4,7 +4,7 @@ import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0082-introduce_gms_registration',
DB_VERSION: '0083-join_community_federated_communities',
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v4.2023-09-12
CONFIG_VERSION=v6.2024-02-20
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
@ -19,4 +19,8 @@ DB_DATABASE_TEST=gradido_dlt_test
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.backend.log
# DLT-Connector
DLT_CONNECTOR_PORT=6010
DLT_CONNECTOR_PORT=6010
# Route to Backend
BACKEND_SERVER_URL=http://localhost:4000
JWT_SECRET=secret123

View File

@ -1,5 +1,7 @@
CONFIG_VERSION=$DLT_CONNECTOR_CONFIG_VERSION
JWT_SECRET=$JWT_SECRET
#IOTA
IOTA_API_URL=$IOTA_API_URL
IOTA_COMMUNITY_ALIAS=$IOTA_COMMUNITY_ALIAS
@ -15,4 +17,7 @@ DB_DATABASE_TEST=$DB_DATABASE_TEST
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.backend.log
# DLT-Connector
DLT_CONNECTOR_PORT=$DLT_CONNECTOR_PORT
DLT_CONNECTOR_PORT=$DLT_CONNECTOR_PORT
# Route to Backend
BACKEND_SERVER_URL=http://localhost:4000

View File

@ -6,7 +6,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
coverageThreshold: {
global: {
lines: 75,
lines: 72,
},
},
setupFiles: ['<rootDir>/test/testSetup.ts'],

View File

@ -31,8 +31,10 @@
"express": "4.17.1",
"express-slow-down": "^2.0.1",
"graphql": "^16.7.1",
"graphql-request": "^6.1.0",
"graphql-scalars": "^1.22.2",
"helmet": "^7.1.0",
"jose": "^5.2.2",
"log4js": "^6.7.1",
"nodemon": "^2.0.20",
"protobufjs": "^7.2.5",

View File

@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { gql, GraphQLClient } from 'graphql-request'
import { SignJWT } from 'jose'
import { CONFIG } from '@/config'
import { CommunityDraft } from '@/graphql/input/CommunityDraft'
import { logger } from '@/logging/logger'
import { LogError } from '@/server/LogError'
const homeCommunity = gql`
query {
homeCommunity {
uuid
foreign
creationDate
}
}
`
interface Community {
homeCommunity: {
uuid: string
foreign: boolean
creationDate: string
}
}
// Source: https://refactoring.guru/design-patterns/singleton/typescript/example
// and ../federation/client/FederationClientFactory.ts
/**
* A Singleton class defines the `getInstance` method that lets clients access
* the unique singleton instance.
*/
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class BackendClient {
// eslint-disable-next-line no-use-before-define
private static instance: BackendClient
client: GraphQLClient
/**
* The Singleton's constructor should always be private to prevent direct
* construction calls with the `new` operator.
*/
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {}
/**
* The static method that controls the access to the singleton instance.
*
* This implementation let you subclass the Singleton class while keeping
* just one instance of each subclass around.
*/
public static getInstance(): BackendClient | undefined {
if (!BackendClient.instance) {
BackendClient.instance = new BackendClient()
}
if (!BackendClient.instance.client) {
try {
BackendClient.instance.client = new GraphQLClient(CONFIG.BACKEND_SERVER_URL, {
headers: {
'content-type': 'application/json',
},
method: 'GET',
jsonSerializer: {
parse: JSON.parse,
stringify: JSON.stringify,
},
})
} catch (e) {
logger.error("couldn't connect to backend: ", e)
return
}
}
return BackendClient.instance
}
public async getHomeCommunityDraft(): Promise<CommunityDraft> {
logger.info('check home community on backend')
const { data, errors } = await this.client.rawRequest<Community>(
homeCommunity,
{},
{
authorization: 'Bearer ' + (await this.createJWTToken()),
},
)
if (errors) {
throw new LogError('error getting home community from backend', errors)
}
const communityDraft = new CommunityDraft()
communityDraft.uuid = data.homeCommunity.uuid
communityDraft.foreign = data.homeCommunity.foreign
communityDraft.createdAt = data.homeCommunity.creationDate
return communityDraft
}
private async createJWTToken(): Promise<string> {
const secret = new TextEncoder().encode(CONFIG.JWT_SECRET)
const token = await new SignJWT({ gradidoID: 'dlt-connector', 'urn:gradido:claim': true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime('1m')
.sign(secret)
return token
}
}

View File

@ -9,13 +9,14 @@ const constants = {
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v4.2023-09-12',
EXPECTED: 'v6.2024-02-20',
CURRENT: '',
},
}
const server = {
PRODUCTION: process.env.NODE_ENV === 'production' ?? false,
JWT_SECRET: process.env.JWT_SECRET ?? 'secret123',
}
const database = {
@ -38,6 +39,10 @@ const dltConnector = {
DLT_CONNECTOR_PORT: process.env.DLT_CONNECTOR_PORT ?? 6010,
}
const backendServer = {
BACKEND_SERVER_URL: process.env.BACKEND_SERVER_URL ?? 'http://backend:4000',
}
// Check config version
constants.CONFIG_VERSION.CURRENT = process.env.CONFIG_VERSION ?? constants.CONFIG_VERSION.DEFAULT
if (
@ -56,4 +61,5 @@ export const CONFIG = {
...database,
...iota,
...dltConnector,
...backendServer,
}

View File

@ -1,10 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import 'reflect-metadata'
import { CONFIG } from '@/config'
import { BackendClient } from './client/BackendClient'
import { CommunityRepository } from './data/Community.repository'
import { Mnemonic } from './data/Mnemonic'
import { CommunityDraft } from './graphql/input/CommunityDraft'
import { AddCommunityContext } from './interactions/backendToDb/community/AddCommunity.context'
import { logger } from './logging/logger'
import createServer from './server/createServer'
import { LogError } from './server/LogError'
import { stopTransmitToIota, transmitToIota } from './tasks/transmitToIota'
async function waitForServer(
backend: BackendClient,
retryIntervalMs: number,
maxRetries: number,
): Promise<CommunityDraft> {
let retries = 0
while (retries < maxRetries) {
logger.info(`Attempt ${retries + 1} for connecting to backend`)
try {
// Make a HEAD request to the server
return await backend.getHomeCommunityDraft()
} catch (error) {
logger.info('Server is not reachable: ', error)
}
// Server is not reachable, wait and retry
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs))
retries++
}
throw new LogError('Max retries exceeded. Server did not become reachable.')
}
async function main() {
if (CONFIG.IOTA_HOME_COMMUNITY_SEED) {
Mnemonic.validateSeed(CONFIG.IOTA_HOME_COMMUNITY_SEED)
@ -13,6 +45,22 @@ async function main() {
console.log(`DLT_CONNECTOR_PORT=${CONFIG.DLT_CONNECTOR_PORT}`)
const { app } = await createServer()
// ask backend for home community if we haven't one
try {
await CommunityRepository.loadHomeCommunityKeyPair()
} catch (e) {
const backend = BackendClient.getInstance()
if (!backend) {
throw new LogError('cannot create backend client')
}
// wait for backend server to be ready
await waitForServer(backend, 2500, 10)
const communityDraft = await backend.getHomeCommunityDraft()
const addCommunityContext = new AddCommunityContext(communityDraft)
await addCommunityContext.run()
}
// loop run all the time, check for new transaction for sending to iota
void transmitToIota()
app.listen(CONFIG.DLT_CONNECTOR_PORT, () => {

View File

@ -12,6 +12,7 @@ import { TransactionError } from '@/graphql/model/TransactionError'
import { CommunityLoggingView } from '@/logging/CommunityLogging.view'
import { logger } from '@/logging/logger'
import { InterruptiveSleepManager } from '@/manager/InterruptiveSleepManager'
import { LogError } from '@/server/LogError'
import { getDataSource } from '@/typeorm/DataSource'
import { CreateTransactionRecipeContext } from '../transaction/CreateTransationRecipe.context'
@ -24,7 +25,19 @@ export class HomeCommunityRole extends CommunityRole {
public async create(communityDraft: CommunityDraft, topic: string): Promise<void> {
super.create(communityDraft, topic)
// generate key pair for signing transactions and deriving all keys for community
const keyPair = new KeyPair(new Mnemonic(CONFIG.IOTA_HOME_COMMUNITY_SEED ?? undefined))
let mnemonic: Mnemonic
try {
mnemonic = new Mnemonic(CONFIG.IOTA_HOME_COMMUNITY_SEED ?? undefined)
} catch (e) {
throw new LogError(
'error creating mnemonic for home community, please fill IOTA_HOME_COMMUNITY_SEED in .env',
{
IOTA_HOME_COMMUNITY_SEED: CONFIG.IOTA_HOME_COMMUNITY_SEED,
error: e,
},
)
}
const keyPair = new KeyPair(mnemonic)
keyPair.fillInCommunityKeys(this.self)
// create auf account and gmw account

View File

@ -569,7 +569,7 @@
"@graphql-typed-document-node/core" "^3.1.1"
tslib "^2.4.0"
"@graphql-typed-document-node/core@^3.1.1":
"@graphql-typed-document-node/core@^3.1.1", "@graphql-typed-document-node/core@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
@ -2119,6 +2119,13 @@ cross-env@^7.0.3:
dependencies:
cross-spawn "^7.0.1"
cross-fetch@^3.1.5:
version "3.1.8"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82"
integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==
dependencies:
node-fetch "^2.6.12"
cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -3329,6 +3336,14 @@ graphql-query-complexity@^0.12.0:
dependencies:
lodash.get "^4.4.2"
graphql-request@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-6.1.0.tgz#f4eb2107967af3c7a5907eb3131c671eac89be4f"
integrity sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==
dependencies:
"@graphql-typed-document-node/core" "^3.2.0"
cross-fetch "^3.1.5"
graphql-scalars@^1.22.2:
version "1.22.2"
resolved "https://registry.yarnpkg.com/graphql-scalars/-/graphql-scalars-1.22.2.tgz#6326e6fe2d0ad4228a9fea72a977e2bf26b86362"
@ -4266,6 +4281,11 @@ jiti@^1.19.3:
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.20.0.tgz#2d823b5852ee8963585c8dd8b7992ffc1ae83b42"
integrity sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==
jose@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/jose/-/jose-5.2.2.tgz#b91170e9ba6dbe609b0c0a86568f9a1fbe4335c0"
integrity sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==
js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -4775,7 +4795,7 @@ node-abort-controller@^3.1.1:
resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548"
integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==
node-fetch@^2.6.7:
node-fetch@^2.6.12, node-fetch@^2.6.7:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==

View File

@ -275,28 +275,45 @@ It contains a map-component of the leaflet library and offers to capture the use
There is no user-specific authentication nor autorization necessary for this dialog as mentioned above.
### GMS user playground dialog (gms efforts)
### GMS user playground dialog
As described in the chapter "User search" above, we need a dialog in GMS to display in a graphical map:
* the location of the user as a red needle, who opens the user search-dialog
* the location of his community as a circle, the invoker belongs to
* the locations of all other users as white needles, belonging to the same community
* the locations of all other users belonging to the same community as white/gray or black needles - depending on the location-type of the user
* circles and needles of all other communities and users, which are nearby the requesting user and community location
There is no user-specific authentication nor autorization necessary for this dialog as mentioned above.
On activation of the menu-entry _user-search_ a technical flow in the background have to prepare the connection between the gradido-system and the gms-component. The following list will describe the necessary steps of all involved components:
Which (filter-)components this playground-dialog should have next to the graphical location map is not clear at the moment. In the first run to display the above mentioned locations of users and communities with circles and needles will be sufficient.
* **gradido-frontend:** user press the menu entry _user search_
* **(1.a) gradido-frontend:** invokes the gradido-backend `authUserForGmsUserSearch`
* **(1.b) gradido-backend:** the method `authUserForGmsUserSearch` reads the context-user of the current request and the uuid of the user's home-community. With these values it prepares the parameters for invokation of the `gms.verifyAuthToken` method. The first parameter is set by the `community-uuid` and the second parameter is a JWT-token with the encrypted `user-uuid` in the payload and signed by the community's privateKey
* **(2.a) gradido-backend:** invokes the `gms.verifyAuthToken` with community-uuid as 1st parameter and JWT-Token as 2nd parameter
* **(2.b) gms-backend:** recieving the request `verifyAuthToken` with the community-uuid and the JWT-token. After searching and verifing the given community-uuid exists in gms, it prepares the invokation of the configured endpoint `community-Auth-Url` of this community by sending the given JWT-token as parameter back to gradido.
* **(3.a) gms-backend:** invokes the endpoint configured in `gms.community-auth-url` with the previous received JWT-token
* **(3.b) gradido-backend:** receives the request at the endpoint "communityAuthUrl" with the previously sent JWT-token. The JWT-token will be verified if the signature is valid and afterwards the payload is decrypted to verify the contained user-data will match with the current context-user of request (1).
* **(4.a) gradido-backend:** in case of valid JWT-token signature and valid payload data the gradido-backend returns TRUE as result of the authentication-request otherwise FALSE.
* **(4.b) gms-backend:** receives the response of request (3) and in case of TRUE the gms-backend prepares to return a complete URI including a _JWT-access-token_ to be used for entering the user-playground. *It will not return gms-data used for the user-playground as the current implementation of the api `verify-auth-token` do.* In case of FALSE prepare returning an authentication-error.
* **(5.a) gms-backend:** returning the complete URI including a _JWT-access-token_ as response of request (2) or an authentication-error
* **(5.b) gradido-backend:** receiving as response of request (2) a complete URI including a _JWT-access-token_ for entering the users-playground on gms or an authentication-error
* **(6.a) gradido-backend:** returning the complete URI including a _JWT-access-token_ as response of request (1) or an expressive error message
* **(6.b) gradido-frontend:** receiving the complete URI including a _JWT-access-token_ after activation of menu-entry "user-search" or an expressive error-message, which will end the _user search_-flow without requesting the gms-frontend (7).
* **(7.a) gradido-frontend:** on opening a new browser-window the gradido-frontend uses the received URI with the _JWT-access-token_ to enter the gms user-playground
* **(7.b) gms-frontend:** receiving the request for the user-playground with an _JWT-access-token_. After verifying the access-token the gms-frontend will read the data for the user given by the access-token and loads all necessary data to render the users playground
The detailed requirements will come up as soon as we get some user expiriences and feedbacks.
The following picture shows the logical flow and interaction between the involved components:
![](./image/usecase-user_search.svg)
The detailed requirements for the playground-dialog will come up as soon as we get some user expiriences and feedbacks.
### GMS Offer Capture dialog (gms efforts)
will come later...
### GMS Need Capture dialog (gms efforts)
will come later...

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

View File

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

View File

@ -21,3 +21,5 @@ META_DESCRIPTION_EN="Gratitude is the currency of the new age. More and more peo
META_KEYWORDS_DE="Grundeinkommen, Währung, Dankbarkeit, Schenk-Ökonomie, Natürliche Ökonomie des Lebens, Ökonomie, Ökologie, Potenzialentfaltung, Schenken und Danken, Kreislauf des Lebens, Geldsystem"
META_KEYWORDS_EN="Basic Income, Currency, Gratitude, Gift Economy, Natural Economy of Life, Economy, Ecology, Potential Development, Giving and Thanking, Cycle of Life, Monetary System"
META_AUTHOR="Bernd Hückstädt - Gradido-Akademie"
GMS_ACTIVE=false

View File

@ -24,3 +24,5 @@ META_DESCRIPTION_EN=$META_DESCRIPTION_EN
META_KEYWORDS_DE=$META_KEYWORDS_DE
META_KEYWORDS_EN=$META_KEYWORDS_EN
META_AUTHOR=$META_AUTHOR
GMS_ACTIVE=$GMS_ACTIVE

View File

@ -71,4 +71,16 @@ export default {
.text-color-gdd-yellow {
color: rgb(197 141 56);
}
.dropdown > .dropdown-toggle {
border-radius: 17px;
height: 50px;
text-align: left;
}
.dropdown-toggle::after {
float: right;
top: 50%;
transform: translateY(-50%);
position: relative;
}
</style>

View File

@ -90,20 +90,3 @@ export default {
},
}
</script>
<style>
.community-switch > div,
.community-switch ul.dropdown-menu {
width: 100%;
}
.community-switch > div > button {
border-radius: 17px;
height: 50px;
text-align: left;
}
.community-switch .dropdown-toggle::after {
float: right;
top: 50%;
transform: translateY(-50%);
position: relative;
}
</style>

View File

@ -0,0 +1,8 @@
<template>
<b-button>{{ $t('settings.GMS.location.button') }}</b-button>
</template>
<script>
export default {
name: 'UserGMSLocation',
}
</script>

View File

@ -0,0 +1,79 @@
import { mount } from '@vue/test-utils'
import UserGMSLocationFormat from './UserGMSLocationFormat.vue'
import { toastErrorSpy } from '@test/testSetup'
const mockAPIcall = jest.fn()
const storeCommitMock = jest.fn()
const localVue = global.localVue
describe('UserGMSLocationFormat', () => {
let wrapper
beforeEach(() => {
wrapper = mount(UserGMSLocationFormat, {
mocks: {
$t: (key) => key, // Mocking the translation function
$store: {
state: {
gmsPublishLocation: null,
},
commit: storeCommitMock,
},
$apollo: {
mutate: mockAPIcall,
},
},
localVue,
propsData: {
selectedOption: 'GMS_LOCATION_TYPE_RANDOM',
},
})
})
afterEach(() => {
wrapper.destroy()
})
it('renders the correct dropdown options', () => {
const dropdownItems = wrapper.findAll('.dropdown-item')
expect(dropdownItems.length).toBe(3)
const labels = dropdownItems.wrappers.map((item) => item.text())
expect(labels).toEqual([
'settings.GMS.publish-location.exact',
'settings.GMS.publish-location.approximate',
'settings.GMS.publish-location.random',
])
})
it('updates selected option on click', async () => {
const dropdownItem = wrapper.findAll('.dropdown-item').at(1) // Click the second item
await dropdownItem.trigger('click')
expect(wrapper.emitted().gmsPublishLocation).toBeTruthy()
expect(wrapper.emitted().gmsPublishLocation.length).toBe(1)
expect(wrapper.emitted().gmsPublishLocation[0]).toEqual(['GMS_LOCATION_TYPE_APPROXIMATE'])
})
it('does not update when clicking on already selected option', async () => {
const dropdownItem = wrapper.findAll('.dropdown-item').at(2) // Click the third item (which is already selected)
await dropdownItem.trigger('click')
expect(wrapper.emitted().gmsPublishLocation).toBeFalsy()
})
describe('update with error', () => {
beforeEach(async () => {
mockAPIcall.mockRejectedValue({
message: 'Ouch',
})
const dropdownItem = wrapper.findAll('.dropdown-item').at(1) // Click the second item
await dropdownItem.trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch')
})
})
})

View File

@ -0,0 +1,73 @@
<template>
<div class="user-gms-location-format">
<b-dropdown v-model="selectedOption">
<template slot="button-content">{{ selectedOptionLabel }}</template>
<b-dropdown-item
v-for="option in dropdownOptions"
@click.prevent="update(option)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</b-dropdown-item>
</b-dropdown>
</div>
</template>
<script>
import { updateUserInfos } from '@/graphql/mutations'
export default {
name: 'UserGMSLocationFormat',
data() {
return {
selectedOption: this.$store.state.gmsPublishLocation ?? 'GMS_LOCATION_TYPE_RANDOM',
dropdownOptions: [
{
label: this.$t('settings.GMS.publish-location.exact'),
value: 'GMS_LOCATION_TYPE_EXACT',
},
{
label: this.$t('settings.GMS.publish-location.approximate'),
value: 'GMS_LOCATION_TYPE_APPROXIMATE',
},
{
label: this.$t('settings.GMS.publish-location.random'),
value: 'GMS_LOCATION_TYPE_RANDOM',
},
],
}
},
computed: {
selectedOptionLabel() {
return this.dropdownOptions.find((option) => option.value === this.selectedOption).label
},
},
methods: {
async update(option) {
if (option.value === this.selectedOption) {
return
}
try {
await this.$apollo.mutate({
mutation: updateUserInfos,
variables: {
gmsPublishLocation: option.value,
},
})
this.toastSuccess(this.$t('settings.GMS.publish-location.updated'))
this.selectedOption = option.value
this.$store.commit('gmsPublishLocation', option.value)
this.$emit('gmsPublishLocation', option.value)
} catch (error) {
this.toastError(error.message)
}
},
},
}
</script>
<style>
.user-gms-location-format > .dropdown,
.user-gms-location-format > .dropdown > .dropdown-toggle > ul.dropdown-menu {
width: 100%;
}
</style>

View File

@ -0,0 +1,81 @@
import { mount } from '@vue/test-utils'
import UserGMSNamingFormat from './UserGMSNamingFormat.vue'
import { toastErrorSpy } from '@test/testSetup'
const mockAPIcall = jest.fn()
const storeCommitMock = jest.fn()
const localVue = global.localVue
describe('UserGMSNamingFormat', () => {
let wrapper
beforeEach(() => {
wrapper = mount(UserGMSNamingFormat, {
mocks: {
$t: (key) => key, // Mocking the translation function
$store: {
state: {
gmsPublishName: null,
},
commit: storeCommitMock,
},
$apollo: {
mutate: mockAPIcall,
},
},
localVue,
propsData: {
selectedOption: 'GMS_PUBLISH_NAME_ALIAS_OR_INITALS',
},
})
})
afterEach(() => {
wrapper.destroy()
})
it('renders the correct dropdown options', () => {
const dropdownItems = wrapper.findAll('.dropdown-item')
expect(dropdownItems.length).toBe(5)
const labels = dropdownItems.wrappers.map((item) => item.text())
expect(labels).toEqual([
'settings.GMS.publish-name.alias-or-initials',
'settings.GMS.publish-name.initials',
'settings.GMS.publish-name.first',
'settings.GMS.publish-name.first-initial',
'settings.GMS.publish-name.name-full',
])
})
it('updates selected option on click', async () => {
const dropdownItem = wrapper.findAll('.dropdown-item').at(3) // Click the fourth item
await dropdownItem.trigger('click')
expect(wrapper.emitted().gmsPublishName).toBeTruthy()
expect(wrapper.emitted().gmsPublishName.length).toBe(1)
expect(wrapper.emitted().gmsPublishName[0]).toEqual(['GMS_PUBLISH_NAME_FIRST_INITIAL'])
})
it('does not update when clicking on already selected option', async () => {
const dropdownItem = wrapper.findAll('.dropdown-item').at(0) // Click the first item (which is already selected)
await dropdownItem.trigger('click')
expect(wrapper.emitted().gmsPublishName).toBeFalsy()
})
describe('update with error', () => {
beforeEach(async () => {
mockAPIcall.mockRejectedValue({
message: 'Ouch',
})
const dropdownItem = wrapper.findAll('.dropdown-item').at(2) // Click the third item
await dropdownItem.trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch')
})
})
})

View File

@ -0,0 +1,87 @@
<template>
<div class="user-gms-naming-format">
<b-dropdown v-model="selectedOption">
<template slot="button-content">{{ selectedOptionLabel }}</template>
<b-dropdown-item
v-for="option in dropdownOptions"
@click.prevent="update(option)"
:key="option.value"
:value="option.value"
:title="option.title"
>
{{ option.label }}
</b-dropdown-item>
</b-dropdown>
</div>
</template>
<script>
import { updateUserInfos } from '@/graphql/mutations'
export default {
name: 'UserGMSNamingFormat',
data() {
return {
selectedOption: this.$store.state.gmsPublishName ?? 'GMS_PUBLISH_NAME_ALIAS_OR_INITALS',
dropdownOptions: [
{
label: this.$t('settings.GMS.publish-name.alias-or-initials'),
title: this.$t('settings.GMS.publish-name.alias-or-initials-tooltip'),
value: 'GMS_PUBLISH_NAME_ALIAS_OR_INITALS',
},
{
label: this.$t('settings.GMS.publish-name.initials'),
title: this.$t('settings.GMS.publish-name.initials-tooltip'),
value: 'GMS_PUBLISH_NAME_INITIALS',
},
{
label: this.$t('settings.GMS.publish-name.first'),
title: this.$t('settings.GMS.publish-name.first-tooltip'),
value: 'GMS_PUBLISH_NAME_FIRST',
},
{
label: this.$t('settings.GMS.publish-name.first-initial'),
title: this.$t('settings.GMS.publish-name.first-initial-tooltip'),
value: 'GMS_PUBLISH_NAME_FIRST_INITIAL',
},
{
label: this.$t('settings.GMS.publish-name.name-full'),
title: this.$t('settings.GMS.publish-name.name-full-tooltip'),
value: 'GMS_PUBLISH_NAME_FULL',
},
],
}
},
computed: {
selectedOptionLabel() {
return this.dropdownOptions.find((option) => option.value === this.selectedOption).label
},
},
methods: {
async update(option) {
if (option.value === this.selectedOption) {
return
}
try {
await this.$apollo.mutate({
mutation: updateUserInfos,
variables: {
gmsPublishName: option.value,
},
})
this.toastSuccess(this.$t('settings.GMS.publish-name.updated'))
this.selectedOption = option.value
this.$store.commit('gmsPublishName', option.value)
this.$emit('gmsPublishName', option.value)
} catch (error) {
this.toastError(error.message)
}
},
},
}
</script>
<style>
.user-gms-naming-format > .dropdown,
.user-gms-naming-format > .dropdown > .dropdown-toggle > ul.dropdown-menu {
width: 100%;
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<div class="form-user-gms-switch">
<b-form-checkbox
test="BFormCheckbox"
v-model="gmsAllowed"
name="check-button"
switch
@change="onChange"
></b-form-checkbox>
</div>
</template>
<script>
import { updateUserInfos } from '@/graphql/mutations'
export default {
name: 'UserGMSSwitch',
data() {
return {
gmsAllowed: this.$store.state.gmsAllowed,
}
},
methods: {
async onChange() {
this.$apollo
.mutate({
mutation: updateUserInfos,
variables: {
gmsAllowed: this.gmsAllowed,
},
})
.then(() => {
this.$store.commit('gmsAllowed', this.gmsAllowed)
this.$emit('gmsAllowed', this.gmsAllowed)
this.toastSuccess(
this.gmsAllowed ? this.$t('settings.GMS.enabled') : this.$t('settings.GMS.disabled'),
)
})
.catch((error) => {
this.gmsAllowed = this.$store.state.gmsAllowed
this.toastError(error.message)
})
},
},
}
</script>

View File

@ -8,7 +8,7 @@ const constants = {
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v5.2024-01-08',
EXPECTED: 'v6.2024-02-27',
CURRENT: '',
},
}
@ -20,6 +20,10 @@ const version = {
BUILD_COMMIT_SHORT: (process.env.BUILD_COMMIT ?? '0000000').slice(0, 7),
}
const features = {
GMS_ACTIVE: process.env.GMS_ACTIVE ?? false,
}
const environment = {
NODE_ENV: process.env.NODE_ENV,
DEBUG: process.env.NODE_ENV !== 'production' ?? false,
@ -81,6 +85,7 @@ if (
const CONFIG = {
...constants,
...version,
...features,
...environment,
...endpoints,
...community,

View File

@ -35,9 +35,9 @@ export const updateUserInfos = gql`
$hideAmountGDD: Boolean
$hideAmountGDT: Boolean
$gmsAllowed: Boolean
$gmsPublishName: Int
$gmsPublishName: GmsPublishNameType
$gmsLocation: Location
$gmsPublishLocation: Int
$gmsPublishLocation: GmsPublishLocationType
) {
updateUserInfos(
firstName: $firstName
@ -172,6 +172,9 @@ export const login = gql`
klickTipp {
newsletterState
}
gmsAllowed
gmsPublishName
gmsPublishLocation
hasElopage
publisherId
roles

View File

@ -5,8 +5,10 @@
"1000thanks": "1000 Dank, weil du bei uns bist!",
"125": "125%",
"85": "85%",
"ExternServices": "Verknüpfte Dienste",
"GDD": "GDD",
"GDT": "GDT",
"GMS": "Gradido Karte",
"PersonalDetails": "Persönliche Angaben",
"advanced-calculation": "Vorausberechnung",
"asterisks": "****",
@ -290,6 +292,36 @@
},
"settings": {
"emailInfo": "Kann aktuell noch nicht geändert werden.",
"GMS": {
"disabled": "Daten werden nicht nach GMS exportiert",
"enabled": "Daten werden nach GMS exportiert",
"location": {
"label": "Positionsbestimmung",
"button": "Klick mich!"
},
"location-format": "Positionstyp",
"naming-format": "Namensformat im GMS",
"publish-location": {
"exact": "Genaue Position",
"approximate": "Ungefähre Position",
"random": "Zufallsposition",
"updated": "Positionstyp für GMS aktualisiert"
},
"publish-name": {
"alias-or-initials": "Benutzername oder Initialen",
"alias-or-initials-tooltip": "Benutzername, falls vorhanden, oder die Initialen von Vorname und Nachname",
"first": "Vorname",
"first-tooltip": "Nur der Vornamen",
"first-initial": "Vorname und Initiale",
"first-initial-tooltip": "Vornamen plus Anfangsbuchstabe des Nachnamens",
"initials": "Initialen",
"initials-tooltip": "Initialen von Vor- und Nachname unabhängig von der Existenz des Benutzernamens",
"name-full": "Ganzer Name",
"name-full-tooltip": "Vollständiger Name: Vorname plus Nachname",
"updated": "Namensformat für GMS aktualisiert"
},
"switch": "Erlaubnis Daten nach GMS zu exportieren."
},
"hideAmountGDD": "Dein GDD Betrag ist versteckt.",
"hideAmountGDT": "Dein GDT Betrag ist versteckt.",
"info": "Transaktionen können nun per Benutzername oder E-Mail-Adresse getätigt werden.",

View File

@ -5,8 +5,10 @@
"1000thanks": "1000 thanks for being with us!",
"125": "125%",
"85": "85%",
"ExternServices": "Extern Services",
"GDD": "GDD",
"GDT": "GDT",
"GMS": "Gradido Map",
"PersonalDetails": "Personal details",
"advanced-calculation": "Advanced calculation",
"asterisks": "****",
@ -290,6 +292,36 @@
},
"settings": {
"emailInfo": "Cannot be changed at this time.",
"GMS": {
"disabled": "Data not exported to GMS",
"enabled": "Data exported to GMS",
"location": {
"label": "pinpoint location",
"button": "click me!"
},
"location-format": "location type",
"naming-format": "Format of name in GMS",
"publish-location": {
"exact": "exact position",
"approximate": "approximate position",
"random": "random position",
"updated": "format of location for GMS updated"
},
"publish-name": {
"alias-or-initials": "Username or initials",
"alias-or-initials-tooltip": "username if exists or Initials of firstname and lastname",
"first": "firstname",
"first-tooltip": "the firstname only",
"first-initial": "firstname and initial",
"first-initial-tooltip": "firstname plus initial of lastname",
"initials": "Initials of firstname and lastname independent if username exists",
"initials-tooltip": "Initials of firstname and lastname independent if username exists",
"name-full": "fullname",
"name-full-tooltip": "fullname: firstname plus lastname",
"updated": "format of name for GMS updated"
},
"switch": "Allow data export to GMS"
},
"hideAmountGDD": "Your GDD amount is hidden.",
"hideAmountGDT": "Your GDT amount is hidden.",
"info": "Transactions can now be made by username or email address.",

View File

@ -1,81 +1,129 @@
<template>
<div class="card bg-white gradido-border-radius appBoxShadow p-4 mt--3">
<div class="h2">{{ $t('PersonalDetails') }}</div>
<div class="my-4 text-small">
{{ $t('settings.info') }}
</div>
<b-row>
<b-col cols="12" md="6" lg="6">
<user-name />
</b-col>
<b-col cols="12" md="6" lg="6">
<b-form-group :label="$t('form.email')" :description="$t('settings.emailInfo')">
<b-form-input v-model="email" readonly></b-form-input>
</b-form-group>
</b-col>
</b-row>
<hr />
<b-form>
<b-row class="mt-3">
<b-col cols="12" md="6" lg="6">
<label>{{ $t('form.firstname') }}</label>
<b-form-input
v-model="firstName"
:placeholder="$t('settings.name.enterFirstname')"
data-test="firstname"
trim
></b-form-input>
</b-col>
<b-col cols="12" md="6" lg="6">
<label>{{ $t('form.lastname') }}</label>
<b-form-input
v-model="lastName"
:placeholder="$t('settings.name.enterLastname')"
data-test="lastname"
trim
></b-form-input>
</b-col>
</b-row>
<div v-if="!isDisabled" class="mt-4 pt-4 text-center">
<b-button
type="submit"
variant="primary"
@click.prevent="onSubmit"
data-test="submit-userdata"
>
{{ $t('form.save') }}
</b-button>
</div>
</b-form>
<hr />
<b-row>
<b-col cols="12" md="6" lg="6">{{ $t('language') }}</b-col>
<b-col cols="12" md="6" lg="6" class="text-right">
<user-language />
</b-col>
</b-row>
<hr />
<div class="mt-5">{{ $t('form.password') }}</div>
<user-password />
<hr />
<b-row class="mb-5">
<b-col cols="12" md="6" lg="6">
{{ $t('settings.newsletter.newsletter') }}
<div class="text-small">
{{
newsletterState
? $t('settings.newsletter.newsletterTrue')
: $t('settings.newsletter.newsletterFalse')
}}
<b-tabs content-class="mt-3">
<b-tab :title="$t('PersonalDetails')" active>
<div class="h2">{{ $t('PersonalDetails') }}</div>
<div class="my-4 text-small">
{{ $t('settings.info') }}
</div>
</b-col>
<b-col cols="12" md="6" lg="6" class="text-right">
<user-newsletter />
</b-col>
</b-row>
<b-row>
<b-col cols="12" md="6" lg="6">
<user-name />
</b-col>
<b-col cols="12" md="6" lg="6">
<b-form-group :label="$t('form.email')" :description="$t('settings.emailInfo')">
<b-form-input v-model="email" readonly></b-form-input>
</b-form-group>
</b-col>
</b-row>
<hr />
<b-form>
<b-row class="mt-3">
<b-col cols="12" md="6" lg="6">
<label>{{ $t('form.firstname') }}</label>
<b-form-input
v-model="firstName"
:placeholder="$t('settings.name.enterFirstname')"
data-test="firstname"
trim
></b-form-input>
</b-col>
<b-col cols="12" md="6" lg="6">
<label>{{ $t('form.lastname') }}</label>
<b-form-input
v-model="lastName"
:placeholder="$t('settings.name.enterLastname')"
data-test="lastname"
trim
></b-form-input>
</b-col>
</b-row>
<div v-if="!isDisabled" class="mt-4 pt-4 text-center">
<b-button
type="submit"
variant="primary"
@click.prevent="onSubmit"
data-test="submit-userdata"
>
{{ $t('form.save') }}
</b-button>
</div>
</b-form>
<hr />
<b-row>
<b-col cols="12" md="6" lg="6">{{ $t('language') }}</b-col>
<b-col cols="12" md="6" lg="6" class="text-right">
<user-language />
</b-col>
</b-row>
<hr />
<div class="mt-5">{{ $t('form.password') }}</div>
<user-password />
<hr />
<b-row class="mb-5">
<b-col cols="12" md="6" lg="6">
{{ $t('settings.newsletter.newsletter') }}
<div class="text-small">
{{
newsletterState
? $t('settings.newsletter.newsletterTrue')
: $t('settings.newsletter.newsletterFalse')
}}
</div>
</b-col>
<b-col cols="12" md="6" lg="6" class="text-right">
<user-newsletter />
</b-col>
</b-row>
</b-tab>
<div v-if="isGMS">
<b-tab :title="$t('ExternServices')">
<div class="h2">{{ $t('ExternServices') }}</div>
<div class="h3">{{ $t('GMS') }}</div>
<b-row class="mb-3">
<b-col cols="12" md="6" lg="6">
{{ $t('settings.GMS.switch') }}
<div class="text-small">
{{ gmsAllowed ? $t('settings.GMS.enabled') : $t('settings.GMS.disabled') }}
</div>
</b-col>
<b-col cols="12" md="6" lg="6" class="text-right">
<user-g-m-s-switch @gmsAllowed="gmsStateSwitch" />
</b-col>
</b-row>
<div v-if="gmsAllowed">
<b-row class="mb-4">
<b-col cols="12" md="6" lg="6">
{{ $t('settings.GMS.naming-format') }}
</b-col>
<b-col cols="12" md="6" lg="6">
<user-g-m-s-naming-format />
</b-col>
</b-row>
<b-row class="mb-4">
<b-col cols="12" md="6" lg="6">
{{ $t('settings.GMS.location-format') }}
</b-col>
<b-col cols="12" md="6" lg="6">
<user-g-m-s-location-format />
</b-col>
</b-row>
<b-row class="mb-5">
<b-col cols="12" md="6" lg="6">
{{ $t('settings.GMS.location.label') }}
</b-col>
<b-col cols="12" md="6" lg="6">
<user-g-m-s-location />
</b-col>
</b-row>
</div>
</b-tab>
</div>
</b-tabs>
<!-- TODO<b-row>
<b-col cols="12" md="6" lg="6">{{ $t('settings.darkMode') }}</b-col>
<b-col cols="12" md="6" lg="6" class="text-right">
@ -85,15 +133,24 @@
</div>
</template>
<script>
import UserGMSSwitch from '@/components/UserSettings/UserGMSSwitch'
import UserGMSNamingFormat from '@/components/UserSettings/UserGMSNamingFormat'
import UserGMSLocationFormat from '@/components/UserSettings/UserGMSLocationFormat'
import UserGMSLocation from '@/components/UserSettings/UserGMSLocation'
import UserName from '@/components/UserSettings/UserName.vue'
import UserPassword from '@/components/UserSettings/UserPassword'
import UserLanguage from '@/components/LanguageSwitch2.vue'
import UserNewsletter from '@/components/UserSettings/UserNewsletter.vue'
import { updateUserInfos } from '@/graphql/mutations'
import CONFIG from '../config'
export default {
name: 'Profile',
components: {
UserGMSSwitch,
UserGMSNamingFormat,
UserGMSLocationFormat,
UserGMSLocation,
UserName,
UserPassword,
UserLanguage,
@ -106,7 +163,7 @@ export default {
data() {
const { state } = this.$store
const { darkMode, firstName, lastName, email, newsletterState } = state
const { darkMode, firstName, lastName, email, newsletterState, gmsAllowed } = state
return {
darkMode,
@ -115,6 +172,7 @@ export default {
lastName,
email,
newsletterState,
gmsAllowed,
mutation: '',
variables: {},
}
@ -125,6 +183,9 @@ export default {
const { firstName, lastName } = this.$store.state
return firstName === this.firstName && lastName === this.lastName
},
isGMS() {
return CONFIG.GMS_ACTIVE
},
},
// TODO: watch: {
// darkMode(val) {
@ -150,6 +211,9 @@ export default {
this.toastSuccess(this.$t('settings.name.change-success'))
} catch (error) {}
},
gmsStateSwitch(eventData) {
this.gmsAllowed = eventData
},
},
}
</script>

View File

@ -35,6 +35,15 @@ export const mutations = {
newsletterState: (state, newsletterState) => {
state.newsletterState = newsletterState
},
gmsAllowed: (state, gmsAllowed) => {
state.gmsAllowed = gmsAllowed
},
gmsPublishName: (state, gmsPublishName) => {
state.gmsPublishName = gmsPublishName
},
gmsPublishLocation: (state, gmsPublishLocation) => {
state.gmsPublishLocation = gmsPublishLocation
},
publisherId: (state, publisherId) => {
let pubId = parseInt(publisherId)
if (isNaN(pubId)) pubId = null
@ -71,6 +80,9 @@ export const actions = {
commit('firstName', data.firstName)
commit('lastName', data.lastName)
commit('newsletterState', data.klickTipp.newsletterState)
commit('gmsAllowed', data.gmsAllowed)
commit('gmsPublishName', data.gmsPublishName)
commit('gmsPublishLocation', data.gmsPublishLocation)
commit('hasElopage', data.hasElopage)
commit('publisherId', data.publisherId)
commit('roles', data.roles)
@ -85,6 +97,9 @@ export const actions = {
commit('firstName', '')
commit('lastName', '')
commit('newsletterState', null)
commit('gmsAllowed', null)
commit('gmsPublishName', null)
commit('gmsPublishLocation', null)
commit('hasElopage', false)
commit('publisherId', null)
commit('roles', null)
@ -117,6 +132,9 @@ try {
tokenTime: null,
roles: [],
newsletterState: null,
gmsAllowed: null,
gmsPublishName: null,
gmsPublishLocation: null,
hasElopage: false,
publisherId: null,
hideAmountGDD: null,
@ -126,7 +144,7 @@ try {
redirectPath: '/overview',
},
getters: {},
// Syncronous mutation of the state
// Synchronous mutation of the state
mutations,
actions,
})

View File

@ -28,6 +28,9 @@ const {
lastName,
username,
newsletterState,
gmsAllowed,
gmsPublishName,
gmsPublishLocation,
publisherId,
roles,
hasElopage,
@ -122,6 +125,30 @@ describe('Vuex store', () => {
})
})
describe('gmsAllowed', () => {
it('sets the state of gmsAllowed', () => {
const state = { gmsAllowed: null }
gmsAllowed(state, true)
expect(state.gmsAllowed).toEqual(true)
})
})
describe('gmsPublishName', () => {
it('sets gmsPublishName', () => {
const state = { gmsPublishName: null }
gmsPublishName(state, 'GMS_PUBLISH_NAME_INITIALS')
expect(state.gmsPublishName).toEqual('GMS_PUBLISH_NAME_INITIALS')
})
})
describe('gmsPublishLocation', () => {
it('sets gmsPublishLocation', () => {
const state = { gmsPublishLocation: null }
gmsPublishLocation(state, 'GMS_LOCATION_TYPE_APPROXIMATE')
expect(state.gmsPublishLocation).toEqual('GMS_LOCATION_TYPE_APPROXIMATE')
})
})
describe('publisherId', () => {
it('sets the state of publisherId', () => {
const state = {}
@ -190,6 +217,9 @@ describe('Vuex store', () => {
klickTipp: {
newsletterState: true,
},
gmsAllowed: true,
gmsPublishName: 'GMS_PUBLISH_NAME_FULL',
gmsPublishLocation: 'GMS_LOCATION_TYPE_EXACT',
hasElopage: false,
publisherId: 1234,
roles: ['admin'],
@ -197,9 +227,9 @@ describe('Vuex store', () => {
hideAmountGDT: true,
}
it('calls eleven commits', () => {
it('calls fifteen commits', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenCalledTimes(12)
expect(commit).toHaveBeenCalledTimes(15)
})
it('commits gradidoID', () => {
@ -232,29 +262,44 @@ describe('Vuex store', () => {
expect(commit).toHaveBeenNthCalledWith(6, 'newsletterState', true)
})
it('commits gmsAllowed', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(7, 'gmsAllowed', true)
})
it('commits gmsPublishName', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(8, 'gmsPublishName', 'GMS_PUBLISH_NAME_FULL')
})
it('commits gmsPublishLocation', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(9, 'gmsPublishLocation', 'GMS_LOCATION_TYPE_EXACT')
})
it('commits hasElopage', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(7, 'hasElopage', false)
expect(commit).toHaveBeenNthCalledWith(10, 'hasElopage', false)
})
it('commits publisherId', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(8, 'publisherId', 1234)
expect(commit).toHaveBeenNthCalledWith(11, 'publisherId', 1234)
})
it('commits roles', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(9, 'roles', ['admin'])
expect(commit).toHaveBeenNthCalledWith(12, 'roles', ['admin'])
})
it('commits hideAmountGDD', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(10, 'hideAmountGDD', false)
expect(commit).toHaveBeenNthCalledWith(13, 'hideAmountGDD', false)
})
it('commits hideAmountGDT', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(11, 'hideAmountGDT', true)
expect(commit).toHaveBeenNthCalledWith(14, 'hideAmountGDT', true)
})
})
@ -262,9 +307,9 @@ describe('Vuex store', () => {
const commit = jest.fn()
const state = {}
it('calls twelve commits', () => {
it('calls seventeen commits', () => {
logout({ commit, state })
expect(commit).toHaveBeenCalledTimes(14)
expect(commit).toHaveBeenCalledTimes(17)
})
it('commits token', () => {
@ -297,34 +342,49 @@ describe('Vuex store', () => {
expect(commit).toHaveBeenNthCalledWith(6, 'newsletterState', null)
})
it('commits gmsAllowed', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(7, 'gmsAllowed', null)
})
it('commits gmsPublishName', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(8, 'gmsPublishName', null)
})
it('commits gmsPublishLocation', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(9, 'gmsPublishLocation', null)
})
it('commits hasElopage', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(7, 'hasElopage', false)
expect(commit).toHaveBeenNthCalledWith(10, 'hasElopage', false)
})
it('commits publisherId', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(8, 'publisherId', null)
expect(commit).toHaveBeenNthCalledWith(11, 'publisherId', null)
})
it('commits roles', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(9, 'roles', null)
expect(commit).toHaveBeenNthCalledWith(12, 'roles', null)
})
it('commits hideAmountGDD', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(10, 'hideAmountGDD', false)
expect(commit).toHaveBeenNthCalledWith(13, 'hideAmountGDD', false)
})
it('commits hideAmountGDT', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(11, 'hideAmountGDT', true)
expect(commit).toHaveBeenNthCalledWith(14, 'hideAmountGDT', true)
})
it('commits email', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(12, 'email', '')
expect(commit).toHaveBeenNthCalledWith(15, 'email', '')
})
// how to get this working?