Merge branch 'master' into dlt_register_address

This commit is contained in:
einhornimmond 2024-05-14 11:58:09 +02:00
commit ed8f289405
114 changed files with 3255 additions and 315 deletions

View File

@ -4,8 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [2.2.1](https://github.com/gradido/gradido/compare/2.2.0...2.2.1)
- fix(other): deployment bugfixes [`#3290`](https://github.com/gradido/gradido/pull/3290)
#### [2.2.0](https://github.com/gradido/gradido/compare/2.1.1...2.2.0)
> 9 February 2024
- chore(release): v2.2.0 [`#3283`](https://github.com/gradido/gradido/pull/3283)
- fix(backend): prevent warning, fix env error [`#3282`](https://github.com/gradido/gradido/pull/3282)
- feat(frontend): update news text [`#3279`](https://github.com/gradido/gradido/pull/3279)
- feat(frontend): use params instead of query for send/identifier route [`#3277`](https://github.com/gradido/gradido/pull/3277)
- fix(other): deployment bugfixes [`#3276`](https://github.com/gradido/gradido/pull/3276)

View File

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

View File

@ -68,5 +68,11 @@ FEDERATION_XCOM_SENDCOINS_ENABLED=false
# GMS
# GMS_ACTIVE=true
# Coordinates of Illuminz test instance
#GMS_URL=http://54.176.169.179:3071
GMS_URL=http://localhost:4044/
#GMS_API_URL=http://54.176.169.179:3071
GMS_API_URL=http://localhost:4044/
GMS_DASHBOARD_URL=http://localhost:8080/
# HUMHUB
HUMHUB_ACTIVE=false
#HUMHUB_API_URL=https://community.gradido.net/
#HUMHUB_JWT_KEY=

View File

@ -66,4 +66,13 @@ FEDERATION_XCOM_SENDCOINS_ENABLED=$FEDERATION_XCOM_SENDCOINS_ENABLED
# GMS
GMS_ACTIVE=$GMS_ACTIVE
GMS_URL=$GMS_URL
GMS_API_URL=$GMS_API_URL
GMS_DASHBOARD_URL=$GMS_DASHBOARD_URL
GMS_WEBHOOK_SECRET=$GMS_WEBHOOK_SECRET
GMS_CREATE_USER_THROW_ERRORS=$GMS_CREATE_USER_THROW_ERRORS
# HUMHUB
HUMHUB_ACTIVE=$HUMHUB_ACTIVE
HUMHUB_API_URL=$HUMHUB_API_URL
HUMHUB_JWT_KEY=$HUMHUB_JWT_KEY

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "2.2.0",
"version": "2.2.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",
@ -18,6 +18,7 @@
"klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts",
"gmsusers": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/gmsUsers.ts",
"gmsuserList": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/gmsUserList.ts",
"humhubUserExport": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/humhub/ExportUsers.ts",
"locales": "scripts/sort.sh"
},
"dependencies": {
@ -47,6 +48,7 @@
"reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0",
"type-graphql": "^1.1.1",
"typed-rest-client": "^1.8.11",
"uuid": "^8.3.2"
},
"devDependencies": {

View File

@ -7,12 +7,13 @@ import axios from 'axios'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { GmsUser } from './model/GmsUser'
/*
export async function communityList(): Promise<GmsCommunity[] | string | undefined> {
const baseUrl = CONFIG.GMS_URL.endsWith('/') ? CONFIG.GMS_URL : CONFIG.GMS_URL.concat('/')
const baseUrl = ensureUrlEndsWithSlash(CONFIG.GMS_URL)
const service = 'community/list?page=1&perPage=20'
const config = {
headers: {
@ -44,7 +45,7 @@ export async function communityList(): Promise<GmsCommunity[] | string | undefin
}
export async function userList(): Promise<GmsUser[] | string | undefined> {
const baseUrl = CONFIG.GMS_URL.endsWith('/') ? CONFIG.GMS_URL : CONFIG.GMS_URL.concat('/')
const baseUrl = ensureUrlEndsWithSlash(CONFIG.GMS_URL)
const service = 'community-user/list?page=1&perPage=20'
const config = {
headers: {
@ -80,7 +81,7 @@ export async function userList(): Promise<GmsUser[] | string | undefined> {
}
export async function userByUuid(uuid: string): Promise<GmsUser[] | string | undefined> {
const baseUrl = CONFIG.GMS_URL.endsWith('/') ? CONFIG.GMS_URL : CONFIG.GMS_URL.concat('/')
const baseUrl = ensureUrlEndsWithSlash(CONFIG.GMS_URL)
const service = 'community-user/list?page=1&perPage=20'
const config = {
headers: {
@ -117,30 +118,106 @@ export async function userByUuid(uuid: string): Promise<GmsUser[] | string | und
*/
export async function createGmsUser(apiKey: string, user: GmsUser): Promise<boolean> {
const baseUrl = CONFIG.GMS_URL.endsWith('/') ? CONFIG.GMS_URL : CONFIG.GMS_URL.concat('/')
const service = 'community-user'
if (CONFIG.GMS_ACTIVE) {
const baseUrl = ensureUrlEndsWithSlash(CONFIG.GMS_API_URL)
const service = 'community-user'
const config = {
headers: {
accept: 'application/json',
language: 'en',
timezone: 'UTC',
connection: 'keep-alive',
authorization: apiKey,
},
}
try {
const result = await axios.post(baseUrl.concat(service), user, config)
logger.debug('POST-Response of community-user:', result)
if (result.status !== 200) {
throw new LogError('HTTP Status Error in community-user:', result.status, result.statusText)
}
logger.debug('responseData:', result.data.responseData)
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
// const gmsUser = JSON.parse(result.data.responseData)
// logger.debug('gmsUser:', gmsUser)
return true
} catch (error: any) {
logger.error('Error in post community-user:', error)
throw new LogError(error.message)
}
} else {
logger.info('GMS-Communication disabled per ConfigKey GMS_ACTIVE=false!')
return false
}
}
export async function updateGmsUser(apiKey: string, user: GmsUser): Promise<boolean> {
if (CONFIG.GMS_ACTIVE) {
const baseUrl = ensureUrlEndsWithSlash(CONFIG.GMS_API_URL)
const service = 'community-user'
const config = {
headers: {
accept: 'application/json',
language: 'en',
timezone: 'UTC',
connection: 'keep-alive',
authorization: apiKey,
},
}
try {
const result = await axios.patch(baseUrl.concat(service), user, config)
logger.debug('PATCH-Response of community-user:', result)
if (result.status !== 200) {
throw new LogError('HTTP Status Error in community-user:', result.status, result.statusText)
}
logger.debug('responseData:', result.data.responseData)
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
// const gmsUser = JSON.parse(result.data.responseData)
// logger.debug('gmsUser:', gmsUser)
return true
} catch (error: any) {
logger.error('Error in patch community-user:', error)
throw new LogError(error.message)
}
} else {
logger.info('GMS-Communication disabled per ConfigKey GMS_ACTIVE=false!')
return false
}
}
export async function verifyAuthToken(
// apiKey: string,
communityUuid: string,
token: string,
): Promise<string> {
const baseUrl = ensureUrlEndsWithSlash(CONFIG.GMS_API_URL)
const service = 'verify-auth-token?token='.concat(token).concat('&uuid=').concat(communityUuid)
const config = {
headers: {
accept: 'application/json',
language: 'en',
timezone: 'UTC',
connection: 'keep-alive',
authorization: apiKey,
// authorization: apiKey,
},
}
try {
const result = await axios.post(baseUrl.concat(service), user, config)
logger.debug('POST-Response of community-user:', result)
const result = await axios.get(baseUrl.concat(service), config)
logger.debug('GET-Response of verify-auth-token:', result)
if (result.status !== 200) {
throw new LogError('HTTP Status Error in community-user:', result.status, result.statusText)
throw new LogError(
'HTTP Status Error in verify-auth-token:',
result.status,
result.statusText,
)
}
logger.debug('responseData:', result.data.responseData)
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
// const gmsUser = JSON.parse(result.data.responseData)
// logger.debug('gmsUser:', gmsUser)
return true
const token: string = result.data.responseData.token
logger.debug('verifyAuthToken=', token)
return token
} catch (error: any) {
logger.error('Error in Get community-user:', error)
logger.error('Error in verifyAuthToken:', error)
throw new LogError(error.message)
}
}

View File

@ -1,13 +1,14 @@
import { User as dbUser } from '@entity/User'
import { GmsPublishLocationType } from '@/graphql/enum/GmsPublishLocationType'
import { GmsPublishNameType } from '@/graphql/enum/GmsPublishNameType'
import { GmsPublishPhoneType } from '@/graphql/enum/GmsPublishPhoneType'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
export class GmsUser {
constructor(user: dbUser) {
this.userUuid = user.gradidoID
// this.communityUuid = user.communityUuid
this.language = user.language
this.email = this.getGmsEmail(user)
this.countryCode = this.getGmsCountryCode(user)
this.mobile = this.getGmsPhone(user)
@ -43,7 +44,7 @@ export class GmsUser {
if (
user.gmsAllowed &&
user.alias &&
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS
user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS
) {
return user.alias
}
@ -52,32 +53,30 @@ export class GmsUser {
private getGmsFirstName(user: dbUser): string | undefined {
if (
user.gmsAllowed &&
(user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_FIRST ||
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL ||
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_FULL)
(user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST_INITIAL ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_FULL)
) {
return user.firstName
}
if (
user.gmsAllowed &&
((!user.alias &&
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_INITIALS)
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return user.firstName.substring(0, 1)
}
}
private getGmsLastName(user: dbUser): string | undefined {
if (user.gmsAllowed && user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_FULL) {
if (user.gmsAllowed && user.gmsPublishName === PublishNameType.PUBLISH_NAME_FULL) {
return user.lastName
}
if (
user.gmsAllowed &&
((!user.alias &&
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL ||
user.gmsPublishName === GmsPublishNameType.GMS_PUBLISH_NAME_INITIALS)
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST_INITIAL ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return user.lastName.substring(0, 1)
}

View File

@ -0,0 +1,119 @@
import { IsNull, Not } from '@dbTools/typeorm'
import { User } from '@entity/User'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { Connection } from '@/typeorm/connection'
import { checkDBVersion } from '@/typeorm/DBVersion'
import { HumHubClient } from './HumHubClient'
import { GetUser } from './model/GetUser'
import { ExecutedHumhubAction, syncUser } from './syncUser'
const USER_BULK_SIZE = 20
function getUsersPage(page: number, limit: number): Promise<[User[], number]> {
return User.findAndCount({
relations: { emailContact: true },
skip: page * limit,
take: limit,
where: { emailContact: { email: Not(IsNull()) } },
})
}
/**
* @param client
* @returns user map indices with email
*/
async function loadUsersFromHumHub(client: HumHubClient): Promise<Map<string, GetUser>> {
const start = new Date().getTime()
const humhubUsers = new Map<string, GetUser>()
const firstPage = await client.users(0, 50)
if (!firstPage) {
throw new LogError('not a single user found on humhub, please check config and setup')
}
firstPage.results.forEach((user) => {
humhubUsers.set(user.account.email.trim(), user)
})
let page = 1
while (humhubUsers.size < firstPage.total) {
const usersPage = await client.users(page, 50)
if (!usersPage) {
throw new LogError('error requesting next users page from humhub')
}
usersPage.results.forEach((user) => {
humhubUsers.set(user.account.email.trim(), user)
})
page++
}
const elapsed = new Date().getTime() - start
logger.info('load users from humhub', {
total: humhubUsers.size,
timeSeconds: elapsed / 1000.0,
})
return humhubUsers
}
async function main() {
const start = new Date().getTime()
// open mysql connection
const con = await Connection.getInstance()
if (!con?.isConnected) {
logger.fatal(`Couldn't open connection to database!`)
throw new Error(`Fatal: Couldn't open connection to database`)
}
// check for correct database version
const dbVersion = await checkDBVersion(CONFIG.DB_VERSION)
if (!dbVersion) {
logger.fatal('Fatal: Database Version incorrect')
throw new Error('Fatal: Database Version incorrect')
}
let userCount = 0
let page = 0
const humHubClient = HumHubClient.getInstance()
if (!humHubClient) {
throw new LogError('error creating humhub client')
}
const humhubUsers = await loadUsersFromHumHub(humHubClient)
let dbUserCount = 0
const executedHumhubActionsCount = [0, 0, 0, 0]
do {
const [users, totalUsers] = await getUsersPage(page, USER_BULK_SIZE)
dbUserCount += users.length
userCount = users.length
page++
const promises: Promise<ExecutedHumhubAction>[] = []
users.forEach((user: User) => promises.push(syncUser(user, humhubUsers)))
const executedActions = await Promise.all(promises)
executedActions.forEach((executedAction: ExecutedHumhubAction) => {
executedHumhubActionsCount[executedAction as number]++
})
// using process.stdout.write here so that carriage-return is working analog to c
// printf("\rchecked user: %d/%d", dbUserCount, totalUsers);
process.stdout.write(`checked user: ${dbUserCount}/${totalUsers}\r`)
} while (userCount === USER_BULK_SIZE)
await con.destroy()
const elapsed = new Date().getTime() - start
logger.info('export user to humhub, statistics:', {
timeSeconds: elapsed / 1000.0,
gradidoUserCount: dbUserCount,
createdCount: executedHumhubActionsCount[ExecutedHumhubAction.CREATE],
updatedCount: executedHumhubActionsCount[ExecutedHumhubAction.UPDATE],
skippedCount: executedHumhubActionsCount[ExecutedHumhubAction.SKIP],
deletedCount: executedHumhubActionsCount[ExecutedHumhubAction.DELETE],
})
}
main().catch((e) => {
// eslint-disable-next-line no-console
console.error(e)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
})

View File

@ -0,0 +1,189 @@
import { SignJWT } from 'jose'
import { IRequestOptions, IRestResponse, RestClient } from 'typed-rest-client'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { PostUserLoggingView } from './logging/PostUserLogging.view'
import { GetUser } from './model/GetUser'
import { PostUser } from './model/PostUser'
import { UsersResponse } from './model/UsersResponse'
/**
* HumHubClient as singleton class
*/
export class HumHubClient {
// eslint-disable-next-line no-use-before-define
private static instance: HumHubClient
private restClient: RestClient
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {
this.restClient = new RestClient('gradido-backend', CONFIG.HUMHUB_API_URL)
logger.info('create rest client for', CONFIG.HUMHUB_API_URL)
}
public static getInstance(): HumHubClient | undefined {
if (!CONFIG.HUMHUB_ACTIVE || !CONFIG.HUMHUB_API_URL) {
logger.info(`humhub are disabled via config...`)
return
}
if (!HumHubClient.instance) {
HumHubClient.instance = new HumHubClient()
}
return HumHubClient.instance
}
protected async createRequestOptions(
queryParams?: Record<string, string | number | (string | number)[]>,
): Promise<IRequestOptions> {
const requestOptions: IRequestOptions = {
additionalHeaders: { authorization: 'Bearer ' + (await this.createJWTToken()) },
}
if (queryParams) {
requestOptions.queryParameters = { params: queryParams }
}
return requestOptions
}
private async createJWTToken(): Promise<string> {
const secret = new TextEncoder().encode(CONFIG.HUMHUB_JWT_KEY)
const token = await new SignJWT({ 'urn:gradido:claim': true, uid: 1 })
.setProtectedHeader({ alg: 'HS512' })
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime('5m')
.sign(secret)
return token
}
public async createAutoLoginUrl(username: string) {
const secret = new TextEncoder().encode(CONFIG.HUMHUB_JWT_KEY)
logger.info(`user ${username} as username for humhub auto-login`)
const token = await new SignJWT({ username })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('2m')
.sign(secret)
return `${CONFIG.HUMHUB_API_URL}user/auth/external?authclient=jwt&jwt=${token}`
}
/**
* Get all users from humhub
* https://marketplace.humhub.com/module/rest/docs/html/user.html#tag/User/paths/~1user/get
* @param page The number of page of the result set >= 0
* @param limit The numbers of items to return per page, Default: 20, [1 .. 50]
* @returns list of users
*/
public async users(page = 0, limit = 20): Promise<UsersResponse | null> {
const options = await this.createRequestOptions({ page, limit })
const response = await this.restClient.get<UsersResponse>('/api/v1/user', options)
if (response.statusCode !== 200) {
throw new LogError('error requesting users from humhub', response)
}
return response.result
}
/**
* get user by email
* https://marketplace.humhub.com/module/rest/docs/html/user.html#tag/User/paths/~1user~1get-by-email/get
* @param email for user search
* @returns user object if found
*/
public async userByEmail(email: string): Promise<GetUser | null> {
const options = await this.createRequestOptions({ email })
const response = await this.restClient.get<GetUser>('/api/v1/user/get-by-email', options)
// 404 = user not found
if (response.statusCode === 404) {
return null
}
return response.result
}
public async userByEmailAsync(email: string): Promise<IRestResponse<GetUser>> {
const options = await this.createRequestOptions({ email })
return this.restClient.get<GetUser>('/api/v1/user/get-by-email', options)
}
public async userByUsernameAsync(username: string): Promise<IRestResponse<GetUser>> {
const options = await this.createRequestOptions({ username })
return this.restClient.get<GetUser>('/api/v1/user/get-by-username', options)
}
/**
* get user by username
* https://marketplace.humhub.com/module/rest/docs/html/user.html#tag/User/paths/~1user~1get-by-username/get
* @param username for user search
* @returns user object if found
*/
public async userByUsername(username: string): Promise<GetUser | null> {
const options = await this.createRequestOptions({ username })
const response = await this.restClient.get<GetUser>('/api/v1/user/get-by-username', options)
if (response.statusCode === 404) {
return null
}
return response.result
}
/**
* create user
* https://marketplace.humhub.com/module/rest/docs/html/user.html#tag/User/paths/~1user/post
* @param user for saving on humhub instance
*/
public async createUser(user: PostUser): Promise<void> {
logger.info('create new humhub user', new PostUserLoggingView(user))
const options = await this.createRequestOptions()
try {
const response = await this.restClient.create('/api/v1/user', user, options)
if (response.statusCode !== 200) {
throw new LogError('error creating user on humhub', { user, response })
}
} catch (error) {
throw new LogError('error on creating new user', {
user,
error: JSON.stringify(error, null, 2),
})
}
}
/**
* update user
* https://marketplace.humhub.com/module/rest/docs/html/user.html#tag/User/operation/updateUser
* @param user user object to update
* @param humhubUserId humhub user id
* @returns updated user object on success
*/
public async updateUser(user: PostUser, humhubUserId: number): Promise<GetUser | null> {
logger.info('update humhub user', new PostUserLoggingView(user))
const options = await this.createRequestOptions()
const response = await this.restClient.update<GetUser>(
`/api/v1/user/${humhubUserId}`,
user,
options,
)
if (response.statusCode === 400) {
throw new LogError('Invalid user supplied', { user, response })
} else if (response.statusCode === 404) {
throw new LogError('User not found', { user, response })
}
return response.result
}
public async deleteUser(humhubUserId: number): Promise<void> {
logger.info('delete humhub user', { userId: humhubUserId })
const options = await this.createRequestOptions()
const response = await this.restClient.del(`/api/v1/user/${humhubUserId}`, options)
if (response.statusCode === 400) {
throw new LogError('invalid user supplied', { userId: humhubUserId, response })
} else if (response.statusCode === 404) {
throw new LogError('User not found', { userId: humhubUserId, response })
} else if (response.statusCode !== 200) {
throw new LogError('error deleting user', { userId: humhubUserId, response })
}
}
}
// new RestClient('gradido', 'api/v1/')

View File

@ -0,0 +1,70 @@
import { User } from '@entity/User'
import { UserContact } from '@entity/UserContact'
import { IRestResponse } from 'typed-rest-client'
import { GetUser } from '@/apis/humhub/model/GetUser'
import { PostUser } from '@/apis/humhub/model/PostUser'
import { UsersResponse } from '@/apis/humhub/model/UsersResponse'
/**
* HumHubClient as singleton class
*/
export class HumHubClient {
// eslint-disable-next-line no-use-before-define
private static instance: HumHubClient
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {}
public static getInstance(): HumHubClient {
if (!HumHubClient.instance) {
HumHubClient.instance = new HumHubClient()
}
return HumHubClient.instance
}
public async users(): Promise<UsersResponse | null> {
return Promise.resolve(new UsersResponse())
}
public async userByEmail(email: string): Promise<GetUser | null> {
const user = new User()
user.emailContact = new UserContact()
user.emailContact.email = email
return Promise.resolve(new GetUser(user, 1))
}
public async userByEmailAsync(email: string): Promise<IRestResponse<GetUser>> {
const user = new User()
user.emailContact = new UserContact()
user.emailContact.email = email
return Promise.resolve({
statusCode: 200,
result: new GetUser(user, 1),
headers: {},
})
}
public async userByUsername(username: string): Promise<GetUser | null> {
const user = new User()
user.alias = username
user.emailContact = new UserContact()
user.emailContact.email = 'testemail@gmail.com'
return Promise.resolve(new GetUser(user, 1))
}
public async createUser(): Promise<void> {
return Promise.resolve()
}
public async updateUser(inputUser: PostUser, humhubUserId: number): Promise<GetUser | null> {
const user = new User()
user.emailContact = new UserContact()
user.emailContact.email = inputUser.account.email
return Promise.resolve(new GetUser(user, humhubUserId))
}
public async deleteUser(): Promise<void> {
return Promise.resolve()
}
}

View File

@ -0,0 +1,44 @@
import { User } from '@entity/User'
import { isHumhubUserIdenticalToDbUser } from '@/apis/humhub/compareHumhubUserDbUser'
import { GetUser } from '@/apis/humhub/model/GetUser'
export enum ExecutedHumhubAction {
UPDATE,
CREATE,
SKIP,
DELETE,
}
/**
* Trigger action according to conditions
* | User exist on humhub | export to humhub allowed | changes in user data | ACTION
* | true | false | ignored | DELETE
* | true | true | true | UPDATE
* | true | true | false | SKIP
* | false | false | ignored | SKIP
* | false | true | ignored | CREATE
* @param user
* @param humHubClient
* @param humhubUsers
* @returns
*/
export async function syncUser(
user: User,
humhubUsers: Map<string, GetUser>,
): Promise<ExecutedHumhubAction> {
const humhubUser = humhubUsers.get(user.emailContact.email.trim())
if (humhubUser) {
if (!user.humhubAllowed) {
return Promise.resolve(ExecutedHumhubAction.DELETE)
}
if (!isHumhubUserIdenticalToDbUser(humhubUser, user)) {
// if humhub allowed
return Promise.resolve(ExecutedHumhubAction.UPDATE)
}
} else {
if (user.humhubAllowed) {
return Promise.resolve(ExecutedHumhubAction.CREATE)
}
}
return Promise.resolve(ExecutedHumhubAction.SKIP)
}

View File

@ -0,0 +1,64 @@
/* eslint-disable prettier/prettier */
import { communityDbUser } from '@/util/communityUser'
import { isHumhubUserIdenticalToDbUser } from './compareHumhubUserDbUser'
import { GetUser } from './model/GetUser'
const defaultUser = communityDbUser
describe('isHumhubUserIdenticalToDbUser', () => {
beforeEach(() => {
defaultUser.firstName = 'first name'
defaultUser.lastName = 'last name'
defaultUser.alias = 'alias'
defaultUser.emailContact.email = 'email@gmail.com'
defaultUser.language = 'en'
})
it('Should return true because humhubUser was created from entity user', () => {
const humhubUser = new GetUser(defaultUser, 1)
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(true)
})
it('Should return false because first name differ', () => {
const humhubUser = new GetUser(defaultUser, 1)
humhubUser.profile.firstname = 'changed first name'
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(false)
})
it('Should return false because last name differ', () => {
const humhubUser = new GetUser(defaultUser, 1)
humhubUser.profile.lastname = 'changed last name'
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(false)
})
it('Should return false because username differ', () => {
const humhubUser = new GetUser(defaultUser, 1)
humhubUser.account.username = 'changed username'
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(false)
})
it('Should return false because email differ', () => {
const humhubUser = new GetUser(defaultUser, 1)
humhubUser.account.email = 'new@gmail.com'
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(false)
})
it('Should return false because language differ', () => {
const humhubUser = new GetUser(defaultUser, 1)
humhubUser.account.language = 'de'
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(false)
})
it('Should return false because gradido_address differ', () => {
const humhubUser = new GetUser(defaultUser, 1)
// eslint-disable-next-line camelcase
humhubUser.profile.gradido_address = 'changed gradido address'
const result = isHumhubUserIdenticalToDbUser(humhubUser, defaultUser)
expect(result).toBe(false)
})
})

View File

@ -0,0 +1,35 @@
import { User } from '@entity/User'
import { Account } from './model/Account'
import { GetUser } from './model/GetUser'
import { Profile } from './model/Profile'
function profileIsTheSame(profile: Profile, user: User): boolean {
const gradidoUserProfile = new Profile(user)
if (profile.firstname !== gradidoUserProfile.firstname) return false
if (profile.lastname !== gradidoUserProfile.lastname) return false
if (profile.gradido_address !== gradidoUserProfile.gradido_address) return false
return true
}
function accountIsTheSame(account: Account, user: User): boolean {
const gradidoUserAccount = new Account(user)
if (account.username !== gradidoUserAccount.username) return false
if (account.email !== gradidoUserAccount.email) return false
if (account.language !== gradidoUserAccount.language) return false
if (account.status !== gradidoUserAccount.status) return false
return true
}
/**
* compare if gradido user (db entity) differ from humhub user
* @param humhubUser
* @param gradidoUse
* @return true if no differences
*/
export function isHumhubUserIdenticalToDbUser(humhubUser: GetUser, gradidoUser: User): boolean {
return (
profileIsTheSame(humhubUser.profile, gradidoUser) &&
accountIsTheSame(humhubUser.account, gradidoUser)
)
}

View File

@ -0,0 +1,31 @@
import { convertGradidoLanguageToHumhub, convertHumhubLanguageToGradido } from './convertLanguage'
describe('convertGradidoLanguageToHumhub', () => {
it('Should convert "en" to "en-US"', () => {
const result = convertGradidoLanguageToHumhub('en')
expect(result).toBe('en-US')
})
it('Should return the same language for other values', () => {
const languages = ['de', 'fr', 'es', 'pt']
languages.forEach((lang) => {
const result = convertGradidoLanguageToHumhub(lang)
expect(result).toBe(lang)
})
})
})
describe('convertHumhubLanguageToGradido', () => {
it('Should convert "en-US" to "en"', () => {
const result = convertHumhubLanguageToGradido('en-US')
expect(result).toBe('en')
})
it('Should return the same language for other values', () => {
const languages = ['de', 'fr', 'es', 'pt']
languages.forEach((lang) => {
const result = convertHumhubLanguageToGradido(lang)
expect(result).toBe(lang)
})
})
})

View File

@ -0,0 +1,18 @@
/**
* convert gradido language in valid humhub language
* humhub doesn't know en for example, only en-US and en-GB
* @param gradidoLanguage
*/
export function convertGradidoLanguageToHumhub(gradidoLanguage: string): string {
if (gradidoLanguage === 'en') {
return 'en-US'
}
return gradidoLanguage
}
export function convertHumhubLanguageToGradido(humhubLanguage: string): string {
if (humhubLanguage === 'en-US') {
return 'en'
}
return humhubLanguage
}

View File

@ -0,0 +1,18 @@
import { AbstractLoggingView } from '@logging/AbstractLogging.view'
import { Account } from '@/apis/humhub/model/Account'
export class AccountLoggingView extends AbstractLoggingView {
public constructor(private self: Account) {
super()
}
public toJSON(): Account {
return {
username: this.self.username.substring(0, 3) + '...',
email: this.self.email.substring(0, 3) + '...',
language: this.self.language,
status: this.self.status,
}
}
}

View File

@ -0,0 +1,23 @@
import { AbstractLoggingView } from '@logging/AbstractLogging.view'
import { PostUser } from '@/apis/humhub/model/PostUser'
import { AccountLoggingView } from './AccountLogging.view'
import { ProfileLoggingView } from './ProfileLogging.view'
export class PostUserLoggingView extends AbstractLoggingView {
public constructor(private self: PostUser) {
super()
}
public toJSON(): PostUser {
return {
account: new AccountLoggingView(this.self.account).toJSON(),
profile: new ProfileLoggingView(this.self.profile).toJSON(),
password: {
newPassword: '',
mustChangePassword: false,
},
}
}
}

View File

@ -0,0 +1,20 @@
import { AbstractLoggingView } from '@logging/AbstractLogging.view'
import { Profile } from '@/apis/humhub/model/Profile'
export class ProfileLoggingView extends AbstractLoggingView {
public constructor(private self: Profile) {
super()
}
public toJSON(): Profile {
const gradidoAddressParts = this.self.gradido_address.split('/')
return {
firstname: this.self.firstname.substring(0, 3) + '...',
lastname: this.self.lastname.substring(0, 3) + '...',
// eslint-disable-next-line camelcase
gradido_address:
gradidoAddressParts[0] + '/' + gradidoAddressParts[1].substring(0, 3) + '...',
}
}
}

View File

@ -0,0 +1,23 @@
/* eslint-disable camelcase */
import { User } from '@entity/User'
import { convertGradidoLanguageToHumhub } from '@/apis/humhub/convertLanguage'
export class Account {
public constructor(user: User) {
if (user.alias && user.alias.length > 2) {
this.username = user.alias
} else {
this.username = user.gradidoID
}
this.email = user.emailContact.email
this.language = convertGradidoLanguageToHumhub(user.language)
this.status = 1
}
username: string
email: string
language: string
status: number
}

View File

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

View File

@ -0,0 +1,4 @@
export class Password {
newPassword: string
mustChangePassword: boolean
}

View File

@ -0,0 +1,16 @@
import { User } from '@entity/User'
import { Account } from './Account'
import { Password } from './Password'
import { Profile } from './Profile'
export class PostUser {
public constructor(user: User) {
this.account = new Account(user)
this.profile = new Profile(user)
}
account: Account
profile: Profile
password: Password
}

View File

@ -0,0 +1,6 @@
export class PostUserError {
code: number
message: string
profile: string[]
account: string[]
}

View File

@ -0,0 +1,23 @@
/* eslint-disable camelcase */
import { User } from '@entity/User'
import { CONFIG } from '@/config'
import { PublishNameLogic } from '@/data/PublishName.logic'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
export class Profile {
public constructor(user: User) {
const publishNameLogic = new PublishNameLogic(user)
this.firstname = publishNameLogic.getFirstName(user.humhubPublishName as PublishNameType)
this.lastname = publishNameLogic.getLastName(user.humhubPublishName as PublishNameType)
if (user.alias && user.alias.length > 2) {
this.gradido_address = CONFIG.COMMUNITY_NAME + '/' + user.alias
} else {
this.gradido_address = CONFIG.COMMUNITY_NAME + '/' + user.gradidoID
}
}
firstname: string
lastname: string
gradido_address: string
}

View File

@ -0,0 +1,7 @@
import { GetUser } from './GetUser'
export class UsersResponse {
total: number
page: number
results: GetUser[]
}

View File

@ -0,0 +1,79 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { User } from '@entity/User'
import { UserContact } from '@entity/UserContact'
import { GetUser } from './model/GetUser'
import { syncUser, ExecutedHumhubAction } from './syncUser'
jest.mock('@/apis/humhub/HumHubClient')
const defaultUser = new User()
defaultUser.emailContact = new UserContact()
defaultUser.emailContact.email = 'email@gmail.com'
describe('syncUser function', () => {
afterAll(() => {
jest.resetAllMocks()
})
/*
* Trigger action according to conditions
* | User exist on humhub | export to humhub allowed | changes in user data | ACTION
* | true | false | ignored | DELETE
* | true | true | true | UPDATE
* | true | true | false | SKIP
* | false | false | ignored | SKIP
* | false | true | ignored | CREATE
* */
it('When humhubUser exists and user.humhubAllowed is false, should return DELETE action', async () => {
const humhubUsers = new Map<string, GetUser>()
humhubUsers.set(defaultUser.emailContact.email, new GetUser(defaultUser, 1))
defaultUser.humhubAllowed = false
const result = await syncUser(defaultUser, humhubUsers)
expect(result).toBe(ExecutedHumhubAction.DELETE)
})
it('When humhubUser exists and user.humhubAllowed is true and there are changes in user data, should return UPDATE action', async () => {
const humhubUsers = new Map<string, GetUser>()
const humhubUser = new GetUser(defaultUser, 1)
humhubUser.account.username = 'test username'
humhubUsers.set(defaultUser.emailContact.email, humhubUser)
defaultUser.humhubAllowed = true
const result = await syncUser(defaultUser, humhubUsers)
expect(result).toBe(ExecutedHumhubAction.UPDATE)
})
it('When humhubUser exists and user.humhubAllowed is true and there are no changes in user data, should return SKIP action', async () => {
const humhubUsers = new Map<string, GetUser>()
const humhubUser = new GetUser(defaultUser, 1)
humhubUsers.set(defaultUser.emailContact.email, humhubUser)
defaultUser.humhubAllowed = true
const result = await syncUser(defaultUser, humhubUsers)
expect(result).toBe(ExecutedHumhubAction.SKIP)
})
it('When humhubUser not exists and user.humhubAllowed is false, should return SKIP action', async () => {
const humhubUsers = new Map<string, GetUser>()
defaultUser.humhubAllowed = false
const result = await syncUser(defaultUser, humhubUsers)
expect(result).toBe(ExecutedHumhubAction.SKIP)
})
it('When humhubUser not exists and user.humhubAllowed is true, should return CREATE action', async () => {
const humhubUsers = new Map<string, GetUser>()
defaultUser.humhubAllowed = true
const result = await syncUser(defaultUser, humhubUsers)
expect(result).toBe(ExecutedHumhubAction.CREATE)
})
})

View File

@ -0,0 +1,57 @@
import { User } from '@entity/User'
import { LogError } from '@/server/LogError'
import { isHumhubUserIdenticalToDbUser } from './compareHumhubUserDbUser'
import { HumHubClient } from './HumHubClient'
import { GetUser } from './model/GetUser'
import { PostUser } from './model/PostUser'
export enum ExecutedHumhubAction {
UPDATE,
CREATE,
SKIP,
DELETE,
}
/**
* Trigger action according to conditions
* | User exist on humhub | export to humhub allowed | changes in user data | ACTION
* | true | false | ignored | DELETE
* | true | true | true | UPDATE
* | true | true | false | SKIP
* | false | false | ignored | SKIP
* | false | true | ignored | CREATE
* @param user
* @param humHubClient
* @param humhubUsers
* @returns
*/
export async function syncUser(
user: User,
humhubUsers: Map<string, GetUser>,
): Promise<ExecutedHumhubAction> {
const postUser = new PostUser(user)
const humhubUser = humhubUsers.get(user.emailContact.email.trim())
const humHubClient = HumHubClient.getInstance()
if (!humHubClient) {
throw new LogError('Error creating humhub client')
}
if (humhubUser) {
if (!user.humhubAllowed) {
await humHubClient.deleteUser(humhubUser.id)
return ExecutedHumhubAction.DELETE
}
if (!isHumhubUserIdenticalToDbUser(humhubUser, user)) {
// if humhub allowed
await humHubClient.updateUser(postUser, humhubUser.id)
return ExecutedHumhubAction.UPDATE
}
} else {
if (user.humhubAllowed) {
await humHubClient.createUser(postUser)
return ExecutedHumhubAction.CREATE
}
}
return ExecutedHumhubAction.SKIP
}

View File

@ -37,6 +37,8 @@ export enum RIGHTS {
LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES',
OPEN_CREATIONS = 'OPEN_CREATIONS',
USER = 'USER',
GMS_USER_PLAYGROUND = 'GMS_USER_PLAYGROUND',
HUMHUB_AUTO_LOGIN = 'HUMHUB_AUTO_LOGIN',
// Moderator
SEARCH_USERS = 'SEARCH_USERS',
ADMIN_CREATE_CONTRIBUTION = 'ADMIN_CREATE_CONTRIBUTION',

View File

@ -29,4 +29,6 @@ export const USER_RIGHTS = [
RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES,
RIGHTS.OPEN_CREATIONS,
RIGHTS.USER,
RIGHTS.GMS_USER_PLAYGROUND,
RIGHTS.HUMHUB_AUTO_LOGIN,
]

View File

@ -12,14 +12,14 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0083-join_community_federated_communities',
DB_VERSION: '0084-introduce_humhub_registration',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v21.2024-01-06',
EXPECTED: 'v23.2024-04-04',
CURRENT: '',
},
}
@ -143,8 +143,18 @@ const federation = {
const gms = {
GMS_ACTIVE: process.env.GMS_ACTIVE === 'true' || false,
GMS_CREATE_USER_THROW_ERRORS: process.env.GMS_CREATE_USER_THROW_ERRORS === 'true' || false,
// koordinates of Illuminz-instance of GMS
GMS_URL: process.env.GMS_HOST ?? 'http://localhost:4044/',
GMS_API_URL: process.env.GMS_API_URL ?? 'http://localhost:4044/',
GMS_DASHBOARD_URL: process.env.GMS_DASHBOARD_URL ?? 'http://localhost:8080/',
// used as secret postfix attached at the gms community-auth-url endpoint ('/hook/gms/' + 'secret')
GMS_WEBHOOK_SECRET: process.env.GMS_WEBHOOK_SECRET ?? 'secret',
}
const humhub = {
HUMHUB_ACTIVE: process.env.HUMHUB_ACTIVE === 'true' || false,
HUMHUB_API_URL: process.env.HUMHUB_API_URL ?? COMMUNITY_URL + '/community/',
HUMHUB_JWT_KEY: process.env.HUMHUB_JWT_KEY ?? '',
}
export const CONFIG = {
@ -159,4 +169,5 @@ export const CONFIG = {
...webhook,
...federation,
...gms,
...humhub,
}

View File

@ -0,0 +1,61 @@
import { User } from '@entity/User'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
export class PublishNameLogic {
constructor(private user: User) {}
/**
* get first name based on publishNameType: PublishNameType value
* @param publishNameType
* @returns user.firstName for PUBLISH_NAME_FIRST, PUBLISH_NAME_FIRST_INITIAL or PUBLISH_NAME_FULL
* first initial from user.firstName for PUBLISH_NAME_INITIALS or PUBLISH_NAME_INITIAL_LAST
*/
public getFirstName(publishNameType: PublishNameType): string {
if (
[
PublishNameType.PUBLISH_NAME_FIRST,
PublishNameType.PUBLISH_NAME_FIRST_INITIAL,
PublishNameType.PUBLISH_NAME_FULL,
].includes(publishNameType)
) {
return this.user.firstName
}
if (PublishNameType.PUBLISH_NAME_INITIALS === publishNameType) {
return this.user.firstName.substring(0, 1)
}
if (PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS === publishNameType) {
if (this.user.alias) {
return this.user.alias
} else {
return this.user.firstName.substring(0, 1)
}
}
return ''
}
/**
* get last name based on publishNameType: GmsPublishNameType value
* @param publishNameType
* @returns user.lastName for PUBLISH_NAME_LAST, PUBLISH_NAME_INITIAL_LAST, PUBLISH_NAME_FULL
* first initial from user.lastName for PUBLISH_NAME_FIRST_INITIAL, PUBLISH_NAME_INITIALS
*/
public getLastName(publishNameType: PublishNameType): string {
if (PublishNameType.PUBLISH_NAME_FULL === publishNameType) {
return this.user.lastName
} else if (
[PublishNameType.PUBLISH_NAME_FIRST_INITIAL, PublishNameType.PUBLISH_NAME_INITIALS].includes(
publishNameType,
)
) {
return this.user.lastName.substring(0, 1)
} else if (
PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS === publishNameType &&
!this.user.alias
) {
return this.user.lastName.substring(0, 1)
}
return ''
}
}

View File

@ -6,6 +6,7 @@ import { CONFIG } from '@/config'
// eslint-disable-next-line camelcase
import { AuthenticationClient as V1_0_AuthenticationClient } from '@/federation/client/1_0/AuthenticationClient'
import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { OpenConnectionArgs } from './client/1_0/model/OpenConnectionArgs'
import { AuthenticationClientFactory } from './client/AuthenticationClientFactory'
@ -39,9 +40,7 @@ export async function startCommunityAuthentication(
const args = new OpenConnectionArgs()
args.publicKey = homeCom.publicKey.toString('hex')
// TODO encrypt url with foreignCom.publicKey and sign it with homeCom.privateKey
args.url = homeFedCom.endPoint.endsWith('/')
? homeFedCom.endPoint
: homeFedCom.endPoint + '/' + homeFedCom.apiVersion
args.url = ensureUrlEndsWithSlash(homeFedCom.endPoint).concat(homeFedCom.apiVersion)
logger.debug(
'Authentication: before client.openConnection() args:',
homeCom.publicKey.toString('hex'),

View File

@ -2,6 +2,7 @@ import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCom
import { GraphQLClient } from 'graphql-request'
import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { OpenConnectionArgs } from './model/OpenConnectionArgs'
import { openConnection } from './query/openConnection'
@ -13,9 +14,7 @@ export class AuthenticationClient {
constructor(dbCom: DbFederatedCommunity) {
this.dbCom = dbCom
this.endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${
dbCom.apiVersion
}/`
this.endpoint = ensureUrlEndsWithSlash(dbCom.endPoint).concat(dbCom.apiVersion).concat('/')
this.client = new GraphQLClient(this.endpoint, {
method: 'POST',
jsonSerializer: {

View File

@ -4,6 +4,7 @@ import { GraphQLClient } from 'graphql-request'
import { getPublicCommunityInfo } from '@/federation/client/1_0/query/getPublicCommunityInfo'
import { getPublicKey } from '@/federation/client/1_0/query/getPublicKey'
import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { PublicCommunityInfoLoggingView } from './logging/PublicCommunityInfoLogging.view'
import { GetPublicKeyResult } from './model/GetPublicKeyResult'
@ -16,9 +17,7 @@ export class FederationClient {
constructor(dbCom: DbFederatedCommunity) {
this.dbCom = dbCom
this.endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${
dbCom.apiVersion
}/`
this.endpoint = ensureUrlEndsWithSlash(dbCom.endPoint).concat(dbCom.apiVersion).concat('/')
this.client = new GraphQLClient(this.endpoint, {
method: 'GET',
jsonSerializer: {

View File

@ -3,6 +3,7 @@ import { GraphQLClient } from 'graphql-request'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
import { SendCoinsArgsLoggingView } from './logging/SendCoinsArgsLogging.view'
import { SendCoinsResultLoggingView } from './logging/SendCoinsResultLogging.view'
@ -20,9 +21,7 @@ export class SendCoinsClient {
constructor(dbCom: DbFederatedCommunity) {
this.dbCom = dbCom
this.endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${
dbCom.apiVersion
}/`
this.endpoint = ensureUrlEndsWithSlash(dbCom.endPoint).concat(dbCom.apiVersion).concat('/')
this.client = new GraphQLClient(this.endpoint, {
method: 'POST',
jsonSerializer: {

View File

@ -5,6 +5,7 @@ import { FederationClient as V1_0_FederationClient } from '@/federation/client/1
// eslint-disable-next-line camelcase
import { FederationClient as V1_1_FederationClient } from '@/federation/client/1_1/FederationClient'
import { ApiVersionType } from '@/federation/enum/apiVersionType'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
// eslint-disable-next-line camelcase
type FederationClient = V1_0_FederationClient | V1_1_FederationClient
@ -47,10 +48,7 @@ export class FederationClientFactory {
const instance = FederationClientFactory.instanceArray.find(
(instance) => instance.id === dbCom.id,
)
// TODO: found a way to prevent double code with FederationClient::constructor
const endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${
dbCom.apiVersion
}/`
const endpoint = ensureUrlEndsWithSlash(dbCom.endPoint).concat(dbCom.apiVersion)
// check if endpoint is still the same and not changed meanwhile
if (instance && instance.client.getEndpoint() === endpoint) {
return instance.client

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 { PublishNameType } from '@enum/PublishNameType'
import { Location } from '@model/Location'
import { isValidLocation } from '@/graphql/validator/Location'
@ -44,19 +46,27 @@ export class UpdateUserInfosArgs {
@IsBoolean()
hideAmountGDT?: boolean
@Field({ nullable: true, defaultValue: true })
@Field({ nullable: true })
@IsBoolean()
humhubAllowed?: boolean
@Field({ nullable: true })
@IsBoolean()
gmsAllowed?: boolean
@Field(() => Int, { nullable: true, defaultValue: 0 })
@IsInt()
gmsPublishName?: number | null
@Field(() => PublishNameType, { nullable: true })
@IsEnum(PublishNameType)
gmsPublishName?: PublishNameType | null
@Field(() => PublishNameType, { nullable: true })
@IsEnum(PublishNameType)
humhubPublishName?: PublishNameType | 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

@ -1,14 +0,0 @@
import { registerEnumType } from 'type-graphql'
export enum GmsPublishNameType {
GMS_PUBLISH_NAME_ALIAS_OR_INITALS = 0,
GMS_PUBLISH_NAME_INITIALS = 1,
GMS_PUBLISH_NAME_FIRST = 2,
GMS_PUBLISH_NAME_FIRST_INITIAL = 3,
GMS_PUBLISH_NAME_FULL = 4,
}
registerEnumType(GmsPublishNameType, {
name: 'GmsPublishNameType', // this one is mandatory
description: 'Type of name publishing', // this one is optional
})

View File

@ -0,0 +1,14 @@
import { registerEnumType } from 'type-graphql'
export enum PublishNameType {
PUBLISH_NAME_ALIAS_OR_INITALS = 0,
PUBLISH_NAME_INITIALS = 1,
PUBLISH_NAME_FIRST = 2,
PUBLISH_NAME_FIRST_INITIAL = 3,
PUBLISH_NAME_FULL = 4,
}
registerEnumType(PublishNameType, {
name: 'PublishNameType', // this one is mandatory
description: 'Type of name publishing', // this one is optional
})

View File

@ -1,6 +1,8 @@
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { ObjectType, Field, Int } from 'type-graphql'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
@ObjectType()
export class FederatedCommunity {
constructor(dbCom: DbFederatedCommunity) {
@ -8,7 +10,7 @@ export class FederatedCommunity {
this.foreign = dbCom.foreign
this.publicKey = dbCom.publicKey.toString('hex')
this.apiVersion = dbCom.apiVersion
this.endPoint = dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'
this.endPoint = ensureUrlEndsWithSlash(dbCom.endPoint)
this.lastAnnouncedAt = dbCom.lastAnnouncedAt
this.verifiedAt = dbCom.verifiedAt
this.lastErrorAt = dbCom.lastErrorAt

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from 'type-graphql'
@ObjectType()
export class GmsUserAuthenticationResult {
@Field(() => String)
url: string
@Field(() => String)
token: string
}

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 { PublishNameType } from '@enum/PublishNameType'
import { KlickTipp } from './KlickTipp'
@ObjectType()
@ -29,6 +32,11 @@ export class User {
this.hasElopage = null
this.hideAmountGDD = user.hideAmountGDD
this.hideAmountGDT = user.hideAmountGDT
this.humhubAllowed = user.humhubAllowed
this.gmsAllowed = user.gmsAllowed
this.gmsPublishName = user.gmsPublishName
this.humhubPublishName = user.humhubPublishName
this.gmsPublishLocation = user.gmsPublishLocation
}
}
@ -74,6 +82,21 @@ export class User {
@Field(() => Boolean)
hideAmountGDT: boolean
@Field(() => Boolean)
humhubAllowed: boolean
@Field(() => Boolean)
gmsAllowed: boolean
@Field(() => PublishNameType, { nullable: true })
gmsPublishName: PublishNameType | null
@Field(() => PublishNameType, { nullable: true })
humhubPublishName: PublishNameType | 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

@ -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

@ -17,7 +17,6 @@ import { GraphQLError } from 'graphql'
import { v4 as uuidv4, validate as validateUUID, version as versionUUID } from 'uuid'
import { GmsPublishLocationType } from '@enum/GmsPublishLocationType'
import { GmsPublishNameType } from '@enum/GmsPublishNameType'
import { OptInType } from '@enum/OptInType'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
import { RoleNames } from '@enum/RoleNames'
@ -35,6 +34,7 @@ import {
sendResetPasswordEmail,
} from '@/emails/sendEmailVariants'
import { EventType } from '@/event/Events'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
import { encryptPassword } from '@/password/PasswordEncryptor'
import { writeHomeCommunityEntry } from '@/seeds/community'
@ -73,6 +73,8 @@ import { objectValuesToArray } from '@/util/utilities'
import { Location2Point } from './util/Location2Point'
jest.mock('@/apis/humhub/HumHubClient')
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
return {
@ -183,7 +185,9 @@ describe('UserResolver', () => {
communityUuid: homeCom.communityUuid,
foreign: false,
gmsAllowed: true,
humhubAllowed: false,
gmsPublishName: 0,
humhubPublishName: 0,
gmsPublishLocation: 2,
location: null,
gmsRegistered: false,
@ -1230,7 +1234,7 @@ describe('UserResolver', () => {
lastName: 'Blümchen',
language: 'en',
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishName: PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
}),
])
@ -1258,6 +1262,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: {
@ -1268,7 +1274,7 @@ describe('UserResolver', () => {
expect.objectContaining({
alias: 'bibi_Bloxberg',
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishName: PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
}),
])
@ -1290,7 +1296,7 @@ describe('UserResolver', () => {
await expect(User.find()).resolves.toEqual([
expect.objectContaining({
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishName: PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
}),
])
@ -1303,14 +1309,15 @@ describe('UserResolver', () => {
mutation: updateUserInfos,
variables: {
gmsAllowed: false,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_APPROXIMATE,
gmsPublishName: PublishNameType[PublishNameType.PUBLISH_NAME_FIRST_INITIAL],
gmsPublishLocation:
GmsPublishLocationType[GmsPublishLocationType.GMS_LOCATION_TYPE_APPROXIMATE],
},
})
await expect(User.find()).resolves.toEqual([
expect.objectContaining({
gmsAllowed: false,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL,
gmsPublishName: PublishNameType.PUBLISH_NAME_FIRST_INITIAL,
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_APPROXIMATE,
}),
])
@ -1326,15 +1333,16 @@ describe('UserResolver', () => {
mutation: updateUserInfos,
variables: {
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishName: PublishNameType[PublishNameType.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([
expect.objectContaining({
gmsAllowed: true,
gmsPublishName: GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS,
gmsPublishName: PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS,
location: Location2Point(loc),
gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM,
}),
@ -2670,13 +2678,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

@ -10,6 +10,7 @@ import { UserContact as DbUserContact } from '@entity/UserContact'
import { UserRole } from '@entity/UserRole'
import i18n from 'i18n'
import { Resolver, Query, Args, Arg, Authorized, Ctx, Mutation, Int } from 'type-graphql'
import { IRestResponse } from 'typed-rest-client'
import { v4 as uuidv4 } from 'uuid'
import { UserArgs } from '@arg//UserArgs'
@ -19,18 +20,21 @@ 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'
import { UserContactType } from '@enum/UserContactType'
import { SearchAdminUsersResult } from '@model/AdminUser'
// import { Location } from '@model/Location'
import { GmsUserAuthenticationResult } from '@model/GmsUserAuthenticationResult'
import { User } from '@model/User'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { DltConnectorClient } from '@/apis/dltConnector/DltConnectorClient'
import { updateGmsUser } from '@/apis/gms/GmsClient'
import { GmsUser } from '@/apis/gms/model/GmsUser'
import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { GetUser } from '@/apis/humhub/model/GetUser'
import { subscribe } from '@/apis/KlicktippController'
import { encode } from '@/auth/JWT'
import { RIGHTS } from '@/auth/RIGHTS'
@ -69,7 +73,9 @@ import random from 'random-bigint'
import { randombytes_random } from 'sodium-native'
import { FULL_CREATION_AVAILABLE } from './const/const'
import { authenticateGmsUserPlayground } from './util/authenticateGmsUserPlayground'
import { getHomeCommunity } from './util/communities'
import { compareGmsRelevantUserSettings } from './util/compareGmsRelevantUserSettings'
import { getUserCreations } from './util/creations'
import { findUserByIdentifier } from './util/findUserByIdentifier'
import { findUsers } from './util/findUsers'
@ -77,6 +83,7 @@ import { getKlicktippState } from './util/getKlicktippState'
import { Location2Point } from './util/Location2Point'
import { setUserRole, deleteUserRole } from './util/modifyUserRole'
import { sendUserToGms } from './util/sendUserToGms'
import { syncHumhub } from './util/syncHumhub'
import { validateAlias } from './util/validateAlias'
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
@ -154,11 +161,19 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new LogError('The User has not set a password yet', dbUser)
}
if (!verifyPassword(dbUser, password)) {
throw new LogError('No user with this credentials', dbUser)
}
// request to humhub and klicktipp run in parallel
let humhubUserPromise: Promise<IRestResponse<GetUser>> | undefined
const klicktippStatePromise = getKlicktippState(dbUser.emailContact.email)
if (CONFIG.HUMHUB_ACTIVE && dbUser.humhubAllowed) {
humhubUserPromise = HumHubClient.getInstance()?.userByUsernameAsync(
dbUser.alias ?? dbUser.gradidoID,
)
}
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
dbUser.password = encryptPassword(dbUser, password)
@ -180,7 +195,6 @@ export class UserResolver {
dbUser.publisherId = publisherId
await DbUser.save(dbUser)
}
user.klickTipp = await getKlicktippState(dbUser.emailContact.email)
context.setHeaders.push({
key: 'token',
@ -188,6 +202,12 @@ export class UserResolver {
})
await EVENT_USER_LOGIN(dbUser)
// load humhub state
if (humhubUserPromise) {
const result = await humhubUserPromise
user.humhubAllowed = result?.result?.account.status === 1
}
user.klickTipp = await klicktippStatePromise
logger.info(`successful Login: ${JSON.stringify(user, null, 2)}`)
return user
}
@ -382,7 +402,11 @@ export class UserResolver {
await sendUserToGms(dbUser, homeCom)
}
} catch (err) {
logger.error('Error publishing new created user to GMS:', err)
if (CONFIG.GMS_CREATE_USER_THROW_ERRORS) {
throw new LogError('Error publishing new created user to GMS:', err)
} else {
logger.error('Error publishing new created user to GMS:', err)
}
}
}
return new User(dbUser)
@ -394,15 +418,14 @@ export class UserResolver {
logger.addContext('user', 'unknown')
logger.info(`forgotPassword(${email})...`)
email = email.trim().toLowerCase()
const user = await findUserByEmail(email).catch(() => {
logger.warn(`fail on find UserContact per ${email}`)
const user = await findUserByEmail(email).catch((error) => {
logger.warn(`fail on find UserContact per ${email} because: ${error}`)
})
if (!user || user.deletedAt) {
logger.warn(`no user found with ${email}`)
return true
}
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
throw new LogError(
`Email already sent less than ${printTimeDuration(CONFIG.EMAIL_CODE_REQUEST_TIME)} ago`,
@ -548,8 +571,10 @@ export class UserResolver {
@Authorized([RIGHTS.UPDATE_USER_INFOS])
@Mutation(() => Boolean)
async updateUserInfos(
@Args()
{
@Args() updateUserInfosArgs: UpdateUserInfosArgs,
@Ctx() context: Context,
): Promise<boolean> {
const {
firstName,
lastName,
alias,
@ -558,28 +583,19 @@ export class UserResolver {
passwordNew,
hideAmountGDD,
hideAmountGDT,
humhubAllowed,
gmsAllowed,
gmsPublishName,
humhubPublishName,
gmsLocation,
gmsPublishLocation,
}: UpdateUserInfosArgs,
@Ctx() context: Context,
): Promise<boolean> {
} = updateUserInfosArgs
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)
const updateUserInGMS = compareGmsRelevantUserSettings(user, updateUserInfosArgs)
// try {
if (firstName) {
user.firstName = firstName
@ -589,7 +605,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
}
@ -626,13 +643,24 @@ export class UserResolver {
if (hideAmountGDT !== undefined) {
user.hideAmountGDT = hideAmountGDT
}
user.gmsAllowed = gmsAllowed
user.gmsPublishName = gmsPublishName
if (humhubAllowed !== undefined) {
user.humhubAllowed = humhubAllowed
}
if (gmsAllowed !== undefined) {
user.gmsAllowed = gmsAllowed
}
if (gmsPublishName !== null && gmsPublishName !== undefined) {
user.gmsPublishName = gmsPublishName
}
if (humhubPublishName !== null && humhubPublishName !== undefined) {
user.humhubPublishName = humhubPublishName
}
if (gmsLocation) {
user.location = Location2Point(gmsLocation)
}
user.gmsPublishLocation = gmsPublishLocation
if (gmsPublishLocation !== null && gmsPublishLocation !== undefined) {
user.gmsPublishLocation = gmsPublishLocation
}
// } catch (err) {
// console.log('error:', err)
// }
@ -656,6 +684,20 @@ export class UserResolver {
logger.info('updateUserInfos() successfully finished...')
await EVENT_USER_INFO_UPDATE(user)
// validate if user settings are changed with relevance to update gms-user
if (CONFIG.GMS_ACTIVE && updateUserInGMS) {
logger.debug(`changed user-settings relevant for gms-user update...`)
const homeCom = await getHomeCommunity()
if (homeCom.gmsApiKey !== null) {
logger.debug(`gms-user update...`, user)
await updateGmsUser(homeCom.gmsApiKey, new GmsUser(user))
logger.debug(`gms-user update successfully.`)
}
}
if (CONFIG.HUMHUB_ACTIVE) {
await syncHumhub(updateUserInfosArgs, user)
}
return true
}
@ -669,6 +711,44 @@ export class UserResolver {
return elopageBuys
}
@Authorized([RIGHTS.GMS_USER_PLAYGROUND])
@Query(() => GmsUserAuthenticationResult)
async authenticateGmsUserSearch(@Ctx() context: Context): Promise<GmsUserAuthenticationResult> {
logger.info(`authUserForGmsUserSearch()...`)
const dbUser = getUser(context)
let result: GmsUserAuthenticationResult
if (context.token) {
result = await authenticateGmsUserPlayground(context.token, dbUser)
logger.info('authUserForGmsUserSearch=', result)
} else {
throw new LogError('authUserForGmsUserSearch without token')
}
return result
}
@Authorized([RIGHTS.HUMHUB_AUTO_LOGIN])
@Query(() => String)
async authenticateHumhubAutoLogin(@Ctx() context: Context): Promise<string> {
logger.info(`authenticateHumhubAutoLogin()...`)
const dbUser = getUser(context)
const humhubClient = HumHubClient.getInstance()
if (!humhubClient) {
throw new LogError('cannot create humhub client')
}
const username = dbUser.alias ?? dbUser.gradidoID
let humhubUser = await humhubClient.userByUsername(username)
if (!humhubUser) {
humhubUser = await humhubClient.userByEmail(dbUser.emailContact.email)
}
if (!humhubUser) {
throw new LogError("user don't exist (any longer) on humhub")
}
if (humhubUser.account.status !== 1) {
throw new LogError('user status is not 1', humhubUser.account.status)
}
return await humhubClient.createAutoLoginUrl(username)
}
@Authorized([RIGHTS.SEARCH_ADMIN_USERS])
@Query(() => SearchAdminUsersResult)
async searchAdminUsers(

View File

@ -0,0 +1,20 @@
import { User as DbUser } from '@entity/User'
import { verifyAuthToken } from '@/apis/gms/GmsClient'
import { CONFIG } from '@/config'
import { GmsUserAuthenticationResult } from '@/graphql/model/GmsUserAuthenticationResult'
import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
export async function authenticateGmsUserPlayground(
token: string,
dbUser: DbUser,
): Promise<GmsUserAuthenticationResult> {
const result = new GmsUserAuthenticationResult()
const dashboardUrl = ensureUrlEndsWithSlash(CONFIG.GMS_DASHBOARD_URL)
result.url = dashboardUrl.concat('playground')
result.token = await verifyAuthToken(dbUser.communityUuid, token)
logger.info('GmsUserAuthenticationResult:', result)
return result
}

View File

@ -0,0 +1,89 @@
import { Point } from '@dbTools/typeorm'
import { User as DbUser } from '@entity/User'
import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { Point2Location } from './Location2Point'
export function compareGmsRelevantUserSettings(
orgUser: DbUser,
updateUserInfosArgs: UpdateUserInfosArgs,
): boolean {
if (!orgUser) {
throw new LogError('comparison without any user is impossible')
}
logger.debug('compareGmsRelevantUserSettings:', orgUser, updateUserInfosArgs)
// nach GMS updaten, wenn alias gesetzt wird oder ist und PublishLevel die alias-Übermittlung erlaubt
if (
updateUserInfosArgs.alias &&
orgUser.alias !== updateUserInfosArgs.alias &&
((updateUserInfosArgs.gmsPublishName &&
updateUserInfosArgs.gmsPublishName.valueOf ===
PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS.valueOf) ||
(!updateUserInfosArgs.gmsPublishName &&
orgUser.gmsPublishName &&
orgUser.gmsPublishName.valueOf === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS.valueOf))
) {
return true
}
if (
(updateUserInfosArgs.firstName && orgUser.firstName !== updateUserInfosArgs.firstName) ||
(updateUserInfosArgs.lastName && orgUser.lastName !== updateUserInfosArgs.lastName)
) {
return true
}
if (
updateUserInfosArgs.gmsAllowed !== undefined &&
updateUserInfosArgs.gmsAllowed &&
orgUser.gmsAllowed !== updateUserInfosArgs.gmsAllowed
) {
return true
}
if (
updateUserInfosArgs.gmsPublishLocation &&
orgUser.gmsPublishLocation !== updateUserInfosArgs.gmsPublishLocation
) {
return true
}
if (
updateUserInfosArgs.gmsPublishName &&
orgUser.gmsPublishName !== updateUserInfosArgs.gmsPublishName
) {
return true
}
if (updateUserInfosArgs.language && orgUser.language !== updateUserInfosArgs.language) {
return true
}
if (
updateUserInfosArgs.gmsLocation &&
orgUser.location === null &&
updateUserInfosArgs.gmsLocation !== null
) {
return true
}
if (
updateUserInfosArgs.gmsLocation &&
orgUser.location !== null &&
updateUserInfosArgs.gmsLocation === null
) {
return true
}
if (
updateUserInfosArgs.gmsLocation &&
orgUser.location !== null &&
updateUserInfosArgs.gmsLocation !== null
) {
const orgLocation = Point2Location(orgUser.location as Point)
const changedLocation = updateUserInfosArgs.gmsLocation
if (
orgLocation.latitude !== changedLocation.latitude ||
orgLocation.longitude !== changedLocation.longitude
) {
return true
}
}
return false
}

View File

@ -3,6 +3,7 @@ import { User as DbUser } from '@entity/User'
import { createGmsUser } from '@/apis/gms/GmsClient'
import { GmsUser } from '@/apis/gms/model/GmsUser'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
@ -22,6 +23,10 @@ export async function sendUserToGms(user: DbUser, homeCom: DbCommunity): Promise
logger.debug('mark user as gms published:', user)
}
} catch (err) {
logger.warn('publishing user fails with ', err)
if (CONFIG.GMS_CREATE_USER_THROW_ERRORS) {
throw new LogError('publishing user fails with ', err)
} else {
logger.warn('publishing user fails with ', err)
}
}
}

View File

@ -0,0 +1,55 @@
import { User } from '@entity/User'
import { UserContact } from '@entity/UserContact'
import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { GetUser } from '@/apis/humhub/model/GetUser'
import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs'
import { PublishNameType } from '@/graphql/enum/PublishNameType'
import { backendLogger as logger } from '@/server/logger'
import { syncHumhub } from './syncHumhub'
jest.mock('@/apis/humhub/HumHubClient')
jest.mock('@/apis/humhub/syncUser')
const mockUser = new User()
mockUser.humhubAllowed = true
mockUser.emailContact = new UserContact()
mockUser.emailContact.email = 'email@gmail.com'
mockUser.humhubPublishName = PublishNameType.PUBLISH_NAME_FULL
const mockUpdateUserInfosArg = new UpdateUserInfosArgs()
const mockHumHubUser = new GetUser(mockUser, 1)
describe('syncHumhub', () => {
beforeEach(() => {
jest.spyOn(logger, 'debug').mockImplementation()
jest.spyOn(logger, 'info').mockImplementation()
jest.spyOn(HumHubClient, 'getInstance')
})
afterAll(() => {
jest.resetAllMocks()
})
it('Should not sync if no relevant changes', async () => {
await syncHumhub(mockUpdateUserInfosArg, new User())
expect(HumHubClient.getInstance).not.toBeCalled()
// language logging from some other place
expect(logger.debug).toBeCalledTimes(5)
expect(logger.info).toBeCalledTimes(0)
})
it('Should retrieve user from humhub and sync if relevant changes', async () => {
mockUpdateUserInfosArg.firstName = 'New' // Relevant changes
mockUser.firstName = 'New'
await syncHumhub(mockUpdateUserInfosArg, mockUser)
expect(logger.debug).toHaveBeenCalledTimes(8) // Four language logging calls, two debug calls in function, one for not syncing
expect(logger.info).toHaveBeenLastCalledWith('finished sync user with humhub', {
localId: mockUser.id,
externId: mockHumHubUser.id,
result: 'UPDATE',
})
})
// Add more test cases as needed...
})

View File

@ -0,0 +1,49 @@
import { User } from '@entity/User'
import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { GetUser } from '@/apis/humhub/model/GetUser'
import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser'
import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs'
import { backendLogger as logger } from '@/server/logger'
export async function syncHumhub(
updateUserInfosArg: UpdateUserInfosArgs,
user: User,
): Promise<void> {
// check for humhub relevant changes
if (
updateUserInfosArg.alias === undefined &&
updateUserInfosArg.firstName === undefined &&
updateUserInfosArg.lastName === undefined &&
updateUserInfosArg.humhubAllowed === undefined &&
updateUserInfosArg.humhubPublishName === undefined &&
updateUserInfosArg.language === undefined
) {
logger.debug('no relevant changes')
return
}
logger.debug('changed user-settings relevant for humhub-user update...')
const humhubClient = HumHubClient.getInstance()
if (!humhubClient) {
return
}
logger.debug('retrieve user from humhub')
let humhubUser = await humhubClient.userByUsername(user.alias ?? user.gradidoID)
if (!humhubUser) {
humhubUser = await humhubClient.userByEmail(user.emailContact.email)
}
const humhubUsers = new Map<string, GetUser>()
if (humhubUser) {
humhubUsers.set(user.emailContact.email, humhubUser)
}
logger.debug('update user at humhub')
const result = await syncUser(user, humhubUsers)
logger.info('finished sync user with humhub', {
localId: user.id,
externId: humhubUser?.id,
// for preventing this warning https://github.com/eslint-community/eslint-plugin-security/blob/main/docs/rules/detect-object-injection.md
// and possible danger coming with it
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
result: ExecutedHumhubAction[result as ExecutedHumhubAction],
})
}

View File

@ -39,8 +39,12 @@ export const userFactory = async (
dbUser = await User.findOneOrFail({ where: { id }, relations: ['userRoles'] })
if (user.createdAt || user.deletedAt || user.role) {
if (user.createdAt) dbUser.createdAt = user.createdAt
if (user.deletedAt) dbUser.deletedAt = user.deletedAt
if (user.createdAt) {
dbUser.createdAt = user.createdAt
}
if (user.deletedAt) {
dbUser.deletedAt = user.deletedAt
}
if (user.role && (user.role === RoleNames.ADMIN || user.role === RoleNames.MODERATOR)) {
await setUserRole(dbUser, user.role)
}

View File

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

View File

@ -15,6 +15,14 @@ export const verifyLogin = gql`
}
}
`
export const authenticateGmsUserSearch = gql`
query {
authenticateGmsUserSearch {
url
token
}
}
`
export const queryOptIn = gql`
query ($optIn: String!) {

View File

@ -13,6 +13,7 @@ import { schema } from '@/graphql/schema'
import { Connection } from '@/typeorm/connection'
import { checkDBVersion } from '@/typeorm/DBVersion'
import { elopageWebhook } from '@/webhook/elopage'
import { gmsWebhook } from '@/webhook/gms'
import { context as serverContext } from './context'
import { cors } from './cors'
@ -94,6 +95,10 @@ export const createServer = async (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook)
// GMS Webhook
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.get('/hook/gms/' + CONFIG.GMS_WEBHOOK_SECRET, gmsWebhook)
// Apollo Server
const apollo = new ApolloServer({
schema: await schema(),

View File

@ -37,6 +37,7 @@ const logPlugin = {
const { logger } = requestContext
const { query, mutation, variables, operationName } = requestContext.request
if (operationName !== 'IntrospectionQuery') {
logger.debug('requestDidStart:', requestContext)
logger.info(`Request:
${mutation || query}variables: ${JSON.stringify(filterVariables(variables), null, 2)}`)
}

View File

@ -52,7 +52,9 @@ const communityDbUser: dbUser = {
communityUuid: '55555555-4444-4333-2222-11111111',
community: null,
gmsPublishName: 0,
humhubPublishName: 0,
gmsAllowed: false,
humhubAllowed: false,
location: null,
gmsPublishLocation: 2,
gmsRegistered: false,

View File

@ -29,3 +29,7 @@ export function resetInterface<T extends Record<string, any>>(obj: T): T {
}
return obj
}
export const ensureUrlEndsWithSlash = (url: string): string => {
return url.endsWith('/') ? url : url.concat('/')
}

View File

@ -0,0 +1,36 @@
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { User as DbUser } from '@entity/User'
import { decode } from '@/auth/JWT'
export const gmsWebhook = async (req: any, res: any): Promise<void> => {
console.log('GMS Hook received', req.query)
const { token } = req.query
if (!token) {
console.log('gmsWebhook: missing token')
res.status(400).json({ message: 'false' })
return
}
const payload = await decode(token)
console.log('gmsWebhook: decoded token=', payload)
if (!payload) {
console.log('gmsWebhook: invalid token')
res.status(400).json({ message: 'false' })
return
}
const user = await DbUser.findOne({ where: { gradidoID: payload.gradidoID } })
if (!user) {
console.log('gmsWebhook: missing user')
res.status(400).json({ message: 'false' })
return
}
console.log('gmsWebhook: authenticate user=', user.gradidoID, user.firstName, user.lastName)
console.log('gmsWebhook: authentication successful')
res.status(200).json({ userUuid: user.gradidoID })
}

View File

@ -3696,7 +3696,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
"gradido-database@file:../database":
version "2.1.1"
version "2.2.1"
dependencies:
"@types/uuid" "^8.3.4"
cross-env "^7.0.3"
@ -6070,6 +6070,13 @@ qs@^6.11.0:
dependencies:
side-channel "^1.0.4"
qs@^6.9.1:
version "6.11.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
dependencies:
side-channel "^1.0.4"
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@ -6916,6 +6923,11 @@ tsutils@^3.21.0:
dependencies:
tslib "^1.8.1"
tunnel@0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@ -6981,6 +6993,15 @@ typed-array-length@^1.0.4:
for-each "^0.3.3"
is-typed-array "^1.1.9"
typed-rest-client@^1.8.11:
version "1.8.11"
resolved "https://registry.yarnpkg.com/typed-rest-client/-/typed-rest-client-1.8.11.tgz#6906f02e3c91e8d851579f255abf0fd60800a04d"
integrity sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==
dependencies:
qs "^6.9.1"
tunnel "0.0.6"
underscore "^1.12.1"
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@ -7051,6 +7072,11 @@ underscore.deep@~0.5.1:
resolved "https://registry.yarnpkg.com/underscore.deep/-/underscore.deep-0.5.3.tgz#210969d58025339cecabd2a2ad8c3e8925e5c095"
integrity sha512-4OuSOlFNkiVFVc3khkeG112Pdu1gbitMj7t9B9ENb61uFmN70Jq7Iluhi3oflcSgexkKfDdJ5XAJET2gEq6ikA==
underscore@^1.12.1:
version "1.13.6"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441"
integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==
underscore@~1.13.1:
version "1.13.4"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee"

View File

@ -0,0 +1,176 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
Geometry,
ManyToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
import { UserRole } from '../UserRole'
import { GeometryTransformer } from '../../src/typeorm/GeometryTransformer'
import { Community } from '../Community'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ type: 'bool', default: false })
foreign: boolean
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'community_uuid',
type: 'char',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
communityUuid: string
@ManyToOne(() => Community, (community) => community.users)
@JoinColumn({ name: 'community_uuid', referencedColumnName: 'communityUuid' })
community: Community | null
@Column({
name: 'alias',
length: 20,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@Column({ name: 'gms_publish_name', type: 'int', unsigned: true, nullable: false, default: 0 })
gmsPublishName: number
@Column({ name: 'humhub_publish_name', type: 'int', unsigned: true, nullable: false, default: 0 })
humhubPublishName: number
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP(3)', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ type: 'bool', default: false })
hideAmountGDD: boolean
@Column({ type: 'bool', default: false })
hideAmountGDT: boolean
@OneToMany(() => UserRole, (userRole) => userRole.user)
@JoinColumn({ name: 'user_id' })
userRoles: UserRole[]
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({ name: 'gms_allowed', type: 'bool', default: true })
gmsAllowed: boolean
@Column({
name: 'location',
type: 'geometry',
default: null,
nullable: true,
transformer: GeometryTransformer,
})
location: Geometry | null
@Column({
name: 'gms_publish_location',
type: 'int',
unsigned: true,
nullable: false,
default: 2,
})
gmsPublishLocation: number
@Column({ name: 'gms_registered', type: 'bool', default: false })
gmsRegistered: boolean
@Column({ name: 'gms_registered_at', type: 'datetime', default: null, nullable: true })
gmsRegisteredAt: Date | null
@Column({ name: 'humhub_allowed', type: 'bool', default: false })
humhubAllowed: boolean
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
}

View File

@ -1 +1 @@
export { User } from './0082-introduce_gms_registration/User'
export { User } from './0084-introduce_humhub_registration/User'

View File

@ -0,0 +1,16 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `users` ADD COLUMN IF NOT EXISTS `humhub_allowed` tinyint(1) NOT NULL DEFAULT 0 AFTER `gms_registered_at`;',
)
await queryFn(
'ALTER TABLE `users` ADD COLUMN IF NOT EXISTS `humhub_publish_name` int unsigned NOT NULL DEFAULT 0 AFTER `gms_publish_name`;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `users` DROP COLUMN IF EXISTS `humhub_allowed`;')
await queryFn('ALTER TABLE `users` DROP COLUMN IF EXISTS `humhub_publish_name`;')
}

View File

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

View File

@ -25,8 +25,8 @@ EMAIL_CODE_REQUEST_TIME=10
# Need to adjust by updates
# config versions
DATABASE_CONFIG_VERSION=v1.2022-03-18
BACKEND_CONFIG_VERSION=v21.2024-01-06
FRONTEND_CONFIG_VERSION=v5.2024-01-08
BACKEND_CONFIG_VERSION=v23.2024-04-04
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,15 @@ 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_API_URL=http://54.176.169.179:3071
GMS_API_URL=http://localhost:4044/
GMS_DASHBOARD_URL=http://localhost:8080/
GMS_WEBHOOK_SECRET=secret
GMS_CREATE_USER_THROW_ERRORS=false
# HUMHUB
HUMHUB_ACTIVE=false
#HUMHUB_API_URL=https://community.gradido.net
#HUMHUB_JWT_KEY=

View File

@ -1,3 +1,6 @@
# Migration
[Migration from 2.2.0 to 2.2.1](migration/2_2_0-2_2_1/README.md)
# Setup on Hetzner Cloud Server
Suggested OS:
Debian 12

View File

@ -144,7 +144,13 @@ cp $SCRIPT_PATH/logrotate/gradido.conf /etc/logrotate.d/gradido.conf
export DB_USER=gradido
# create a new password only if it not already exist
if [ -z "${DB_PASSWORD}" ]; then
export DB_PASSWORD=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c${1:-32};echo);
export DB_PASSWORD=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c 32; echo);
fi
# Check if DB_PASSWORD is still empty, then exit with an error
if [ -z "${DB_PASSWORD}" ]; then
echo "Error: Failed to generate DB_PASSWORD."
exit 1
fi
mysql <<EOFMYSQL
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD';
@ -156,7 +162,7 @@ EOFMYSQL
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/database/.env.template > $PROJECT_ROOT/database/.env
# Configure backend
export JWT_SECRET=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c${1:-32};echo);
export JWT_SECRET=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c 32; echo);
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/backend/.env.template > $PROJECT_ROOT/backend/.env
# Configure frontend
@ -166,7 +172,7 @@ envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/frontend/.env
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/admin/.env.template > $PROJECT_ROOT/admin/.env
# Configure dht-node
export FEDERATION_DHT_SEED=$(< /dev/urandom tr -dc a-f0-9 | head -c 32;echo);
export FEDERATION_DHT_SEED=$(< /dev/urandom tr -dc a-f0-9 | head -c 32; echo);
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/dht-node/.env.template > $PROJECT_ROOT/dht-node/.env
# Configure federation
@ -180,4 +186,4 @@ sudo -u gradido crontab < $LOCAL_SCRIPT_DIR/crontabs.txt
# Start gradido
# Note: on first startup some errors will occur - nothing serious
sudo -u gradido $SCRIPT_PATH/start.sh
sudo -u gradido $SCRIPT_PATH/start.sh $1

View File

@ -0,0 +1,18 @@
## Migrate from Gradido Version 2.2.0 to 2.2.1
### What was wrong
In [hetzner_cloud/install.sh](../../install.sh) there was an error.
$DB_PASSWORD and $JWT_SECRET password generation method don't work with `release-2_2_0` as parameter for install.sh
The Parameter forwarding from branch, `release-2_2_0` in this case to start.sh was also missing.
### What you can do now
You need to only run this [fixInstall.sh](fixInstall.sh) with `release_2_2_1` as parameter
```bash
cd /home/gradido/gradido/deployment/hetzner_cloud/migration/2_2_0-2_2_1
sudo ./fixInstall.sh `release_2_2_1`
```
Basically it will create a new $DB_PASSWORD, $JWT_SECRET and $FEDERATION_DHT_SEED,
update db user with new db password and update .env files in module folders.
Then it will call start.sh with first parameter if ./fixInstall.sh as his first parameter

View File

@ -0,0 +1,66 @@
#!/bin/bash
# check for parameter
if [ -z "$1" ]; then
echo "Usage: Please provide a branch name as the first argument."
exit 1
fi
set -o allexport
SCRIPT_PATH=$(realpath ../../../bare_metal)
SCRIPT_DIR=$(dirname $SCRIPT_PATH)
LOCAL_SCRIPT_PATH=$(realpath $0)
LOCAL_SCRIPT_DIR=$(dirname $LOCAL_SCRIPT_PATH)
PROJECT_ROOT=$SCRIPT_DIR/..
set +o allexport
# Load .env or .env.dist if not present
# NOTE: all config values will be in process.env when starting
# the services and will therefore take precedence over the .env
if [ -f "$SCRIPT_PATH/.env" ]; then
set -o allexport
source $SCRIPT_PATH/.env
set +o allexport
else
set -o allexport
source $SCRIPT_PATH/.env.dist
set +o allexport
fi
# create db user
export DB_USER=gradido
# create a new password only if it not already exist
export DB_PASSWORD=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c 32; echo);
mysql <<EOFMYSQL
ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD';
FLUSH PRIVILEGES;
EOFMYSQL
# Configure database
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/database/.env.template > $PROJECT_ROOT/database/.env
# Configure backend
export JWT_SECRET=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c 32; echo);
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/backend/.env.template > $PROJECT_ROOT/backend/.env
# Configure frontend
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/frontend/.env.template > $PROJECT_ROOT/frontend/.env
# Configure admin
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/admin/.env.template > $PROJECT_ROOT/admin/.env
# Configure dht-node
export FEDERATION_DHT_SEED=$(< /dev/urandom tr -dc a-f0-9 | head -c 32; echo);
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/dht-node/.env.template > $PROJECT_ROOT/dht-node/.env
# Configure federation
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/federation/.env.template > $PROJECT_ROOT/federation/.env
# set all created or modified files back to belonging to gradido
chown -R gradido:gradido $PROJECT_ROOT
# Start gradido
# Note: on first startup some errors will occur - nothing serious
sudo -u gradido $SCRIPT_PATH/start.sh $1

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "gradido-dlt-connector",
"version": "2.2.0",
"version": "2.2.1",
"description": "Gradido DLT-Connector",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/",

View File

@ -1,7 +1,7 @@
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
ALTER TABLE \`transactions\`
RENAME COLUMN \`paring_transaction_id\` TO \`pairing_transaction_id\`,
RENAME COLUMN \`paring_transaction_id\` TO \`pairing_transaction_id\`
;
`)
}
@ -9,7 +9,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
ALTER TABLE \`transactions\`
RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`,
RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`
;
`)
}

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

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

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0083-join_community_federated_communities',
DB_VERSION: '0084-introduce_humhub_registration',
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,6 @@ 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
HUMHUB_ACTIVE=false

View File

@ -24,3 +24,6 @@ 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
HUMHUB_ACTIVE=$HUMHUB_ACTIVE

View File

@ -4,7 +4,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.{js,vue}', '!**/node_modules/**', '!**/?(*.)+(spec|test).js?(x)'],
coverageThreshold: {
global: {
lines: 94,
lines: 93,
},
},
moduleFileExtensions: [

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200" height="200">
<!-- Personen in Kreisen -->
<circle cx="30" cy="50" r="15" fill="none" stroke="black" stroke-width="2"/>
<circle cx="50" cy="30" r="15" fill="none" stroke="black" stroke-width="2"/>
<circle cx="70" cy="50" r="15" fill="none" stroke="black" stroke-width="2"/>
<circle cx="50" cy="70" r="15" fill="none" stroke="black" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 442 B

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

@ -1,37 +1,42 @@
[
{
"locale": "de",
"text": "Gradido-Kreise Gemeinsam mit Freunden die Zukunft gestalten",
"button": "Mehr erfahren",
"url": "https://gradido.net/de/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten",
"extra": "Ganz gleich, ob Ihr bereits einer Gruppe zugehörig seid oder ob Ihr Euch über Gradido gefunden habt wenn Ihr gemeinsam Gradido nutzen wollt, braucht Ihr nicht gleich einen eigenen Gradido-Server."
"text": "Gradido-Kreise: neue interne Kooperationsplattform",
"button": "Jetzt freischalten...",
"internUrl": "https://gradido.net/de/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten",
"extra": "Gemeinsam unterstützen wir einander, achtsam in Kreiskultur. In geschützten Räumen können wir nun frei kommunizieren und zusammenarbeiten, ohne auf die sozialen Medien angewiesen zu sein.",
"extra2": "Schalte Gradido-Kreise in den Einstellungen/Community frei und klicke anschließend auf das Menü \"Kreise\"!"
},
{
"locale": "en",
"text": "Gradido circles - Shaping the future together with friends",
"button": "Learn more",
"url": "https://gradido.net/en/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "No matter whether you already belong to a group or whether you found each other via Gradido - if you want to use Gradido together, you don't need your own Gradido server."
"text": "Gradido circles: new internal cooperation platform",
"button": "Activate now...",
"internUrl": "https://gradido.net/en/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "Together, we support each other, mindfully in a circular culture. We can now communicate and collaborate freely in protected spaces without having to rely on social media.",
"extra2": "Activate Gradido circles in the settings/community and then click on the menu \"Circles\"!"
},
{
"locale": "fr",
"text": "Cercles Gradido - Construire l'avenir ensemble avec des amis ",
"text": "Cercles Gradido : nouvelle plateforme de coopération interne",
"button": "En savoir plus",
"url": "https://gradido.net/fr/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "Que vous fassiez déjà partie d'un groupe ou que vous vous soyez trouvés par le biais de Gradido, si vous voulez utiliser Gradido ensemble, vous n'avez pas besoin de votre propre serveur Gradido."
"internUrl": "https://gradido.net/fr/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "Ensemble, nous nous soutenons mutuellement, en étant attentifs à la culture du cercle. Dans des espaces protégés, nous pouvons désormais communiquer et collaborer librement, sans être tributaires des médias sociaux.",
"extra2": "Débloque les cercles Gradido dans les paramètres/communauté et clique ensuite sur le menu \"Cercles\" !"
},
{
"locale": "es",
"text": "Círculos Gradido - Forjar el futuro entre amigos ",
"button": "Más información",
"url": "https://gradido.net/es/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "No importa si ya pertenecéis a un grupo o si os habéis encontrado a través de Gradido: si queréis utilizar Gradido juntos, no necesitáis vuestro propio servidor Gradido."
"text": "Círculos Gradido: nueva plataforma de cooperación interna",
"button": "Activar ahora...",
"internUrl": "https://gradido.net/es/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "Juntos, nos apoyamos mutuamente, de forma consciente, en una cultura circular. Ahora podemos comunicarnos y colaborar libremente en espacios protegidos sin tener que depender de las redes sociales.",
"extra2": "Activa los círculos de Gradido en los ajustes/comunidad y luego haz clic en el menú \"Círculos\"."
},
{
"locale": "nl",
"text": "Gradidokringen - Samen met vrienden de toekomst vormgeven",
"button": "Meer informatie",
"url": "https://gradido.net/nl/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "Het maakt niet uit of je al tot een groep behoort of dat je elkaar via Gradido hebt gevonden - als je Gradido samen wilt gebruiken, heb je geen eigen Gradido-server nodig."
"text": "Gradido cirkels: nieuw intern samenwerkingsplatform",
"button": "Nu activeren...",
"internUrl": "https://gradido.net/nl/gradido-kreise-gemeinsam-mit-freunden-die-zukunft-gestalten/",
"extra": "Samen ondersteunen we elkaar, mindful in een circulaire cultuur. We kunnen nu vrij communiceren en samenwerken in beschermde ruimtes zonder afhankelijk te zijn van sociale media.",
"extra2": "Activeer Gradido cirkels in de instellingen/community en klik dan op het menu \"Cirkels\"!"
}
]

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

@ -1,8 +1,12 @@
import { mount } from '@vue/test-utils'
import Sidebar from './Sidebar'
import CONFIG from '../../config'
const localVue = global.localVue
CONFIG.GMS_ACTIVE = 'true'
CONFIG.HUMHUB_ACTIVE = 'true'
describe('Sidebar', () => {
let wrapper
@ -17,6 +21,9 @@ describe('Sidebar', () => {
roles: [],
},
},
$route: {
path: '/',
},
}
const Wrapper = () => {
@ -32,9 +39,9 @@ describe('Sidebar', () => {
expect(wrapper.find('div#component-sidebar').exists()).toBe(true)
})
describe('the genaral section', () => {
it('has six nav-items', () => {
expect(wrapper.findAll('ul').at(0).findAll('.nav-item')).toHaveLength(6)
describe('the general section', () => {
it('has seven nav-items', () => {
expect(wrapper.findAll('ul').at(0).findAll('.nav-item')).toHaveLength(7)
})
it('has nav-item "navigation.overview" in navbar', () => {
@ -53,12 +60,16 @@ describe('Sidebar', () => {
expect(wrapper.findAll('.nav-item').at(3).text()).toEqual('creation')
})
it('has nav-item "GDT" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(4).text()).toContain('GDT')
it('has nav-item "navigation.info" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(4).text()).toContain('navigation.info')
})
it('has nav-item "navigation.info" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(5).text()).toContain('navigation.info')
it('has nav-item "navigation.circles" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(5).text()).toContain('navigation.circles')
})
it('has nav-item "navigation.usersearch" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(6).text()).toContain('navigation.usersearch')
})
})

View File

@ -16,7 +16,7 @@
<b-icon icon="cash" aria-hidden="true"></b-icon>
<span class="ml-2">{{ $t('navigation.send') }}</span>
</b-nav-item>
<b-nav-item to="/transactions" class="mb-3" active-class="activeRoute">
<b-nav-item to="/transactions" :class="transactionClass" active-class="activeRoute">
<b-img src="/img/svg/transaction.svg" height="20" class="svg-icon" />
<span class="ml-2">{{ $t('navigation.transactions') }}</span>
</b-nav-item>
@ -24,14 +24,18 @@
<b-img src="/img/svg/community.svg" height="20" class="svg-icon" />
<span class="ml-2">{{ $t('creation') }}</span>
</b-nav-item>
<b-nav-item to="/gdt" class="mb-3" active-class="activeRoute">
<b-icon icon="layers" aria-hidden="true"></b-icon>
<span class="ml-2">{{ $t('GDT') }}</span>
</b-nav-item>
<b-nav-item to="/information" active-class="activeRoute">
<b-nav-item to="/information" class="mb-3" active-class="activeRoute">
<b-img src="/img/svg/info.svg" height="20" class="svg-icon" />
<span class="ml-2">{{ $t('navigation.info') }}</span>
</b-nav-item>
<b-nav-item to="/circles" v-if="isHumhub" class="mb-3" active-class="activeRoute">
<b-img src="/img/svg/circles.svg" height="20" class="svg-icon" />
<span class="ml-2">{{ $t('navigation.circles') }}</span>
</b-nav-item>
<b-nav-item to="/usersearch" v-if="isGMS" active-class="activeRoute">
<b-img src="/img/loupe.png" height="20" />
<span class="ml-2">{{ $t('navigation.usersearch') }}</span>
</b-nav-item>
</b-nav>
<hr class="m-3" />
<b-nav vertical class="w-100">
@ -73,11 +77,27 @@
</div>
</template>
<script>
import CONFIG from '../../config'
export default {
name: 'Sidebar',
props: {
shadow: { type: Boolean, required: false, default: true },
},
computed: {
transactionClass() {
if (this.$route.path === '/gdt') {
return 'mb-3 activeRoute'
}
return 'mb-3'
},
isHumhub() {
return CONFIG.HUMHUB_ACTIVE === 'true'
},
isGMS() {
return CONFIG.GMS_ACTIVE === 'true'
},
},
}
</script>
<style>

View File

@ -27,9 +27,14 @@
<b-row class="my-5">
<b-col cols="12">
<div class="text-lg-right">
<b-button variant="gradido" :href="item.url" target="_blank">
<b-button v-if="item.url" variant="gradido" :href="item.url" target="_blank">
{{ item.button }}
</b-button>
<RouterLink v-else-if="item.internUrl" to="/settings/extern">
<b-button variant="gradido">
{{ item.button }}
</b-button>
</RouterLink>
</div>
</b-col>
</b-row>

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,84 @@
import { mount } from '@vue/test-utils'
import UserNamingFormat from './UserNamingFormat.vue'
import { toastErrorSpy } from '@test/testSetup'
const mockAPIcall = jest.fn()
const storeCommitMock = jest.fn()
const localVue = global.localVue
describe('UserNamingFormat', () => {
let wrapper
beforeEach(() => {
wrapper = mount(UserNamingFormat, {
mocks: {
$t: (key) => key, // Mocking the translation function
$store: {
state: {
gmsPublishName: null,
},
commit: storeCommitMock,
},
$apollo: {
mutate: mockAPIcall,
},
},
localVue,
propsData: {
selectedOption: 'PUBLISH_NAME_ALIAS_OR_INITALS',
initialValue: 'PUBLISH_NAME_ALIAS_OR_INITALS',
attrName: 'gmsPublishName',
successMessage: 'success message',
},
})
})
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.publish-name.alias-or-initials',
'settings.publish-name.initials',
'settings.publish-name.first',
'settings.publish-name.first-initial',
'settings.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().valueChanged).toBeTruthy()
expect(wrapper.emitted().valueChanged.length).toBe(1)
expect(wrapper.emitted().valueChanged[0]).toEqual(['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().valueChanged).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,95 @@
<template>
<div class="user-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: 'UserNamingFormat',
props: {
initialValue: { type: String, default: 'PUBLISH_NAME_ALIAS_OR_INITALS' },
attrName: { type: String },
successMessage: { type: String },
},
data() {
return {
selectedOption: this.initialValue,
dropdownOptions: [
{
label: this.$t('settings.publish-name.alias-or-initials'),
title: this.$t('settings.publish-name.alias-or-initials-tooltip'),
value: 'PUBLISH_NAME_ALIAS_OR_INITALS',
},
{
label: this.$t('settings.publish-name.initials'),
title: this.$t('settings.publish-name.initials-tooltip'),
value: 'PUBLISH_NAME_INITIALS',
},
{
label: this.$t('settings.publish-name.first'),
title: this.$t('settings.publish-name.first-tooltip'),
value: 'PUBLISH_NAME_FIRST',
},
{
label: this.$t('settings.publish-name.first-initial'),
title: this.$t('settings.publish-name.first-initial-tooltip'),
value: 'PUBLISH_NAME_FIRST_INITIAL',
},
{
label: this.$t('settings.publish-name.name-full'),
title: this.$t('settings.publish-name.name-full-tooltip'),
value: 'PUBLISH_NAME_FULL',
},
],
}
},
computed: {
selectedOptionLabel() {
const selected = this.dropdownOptions.find((option) => option.value === this.selectedOption)
.label
return selected || this.$t('settings.publish-name.alias-or-initials')
// return this.dropdownOptions.find((option) => option.value === this.selectedOption).label
},
},
methods: {
async update(option) {
if (option.value === this.selectedOption) {
return
}
try {
const variables = []
variables[this.attrName] = option.value
await this.$apollo.mutate({
mutation: updateUserInfos,
variables,
})
this.toastSuccess(this.successMessage)
this.selectedOption = option.value
this.$store.commit(this.attrName, option.value)
this.$emit('valueChanged', option.value)
} catch (error) {
this.toastError(error.message)
}
},
},
}
</script>
<style>
.user-naming-format > .dropdown,
.user-naming-format > .dropdown > .dropdown-toggle > ul.dropdown-menu {
width: 100%;
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<div class="form-user-switch" @click="onClick">
<b-form-checkbox
test="BFormCheckbox"
v-model="value"
name="check-button"
:disabled="disabled"
switch
@change="onChange"
></b-form-checkbox>
</div>
</template>
<script>
import { updateUserInfos } from '@/graphql/mutations'
export default {
name: 'UserSettingsSwitch',
props: {
initialValue: { type: Boolean, default: false },
attrName: { type: String },
enabledText: { type: String },
disabledText: { type: String },
disabled: { type: Boolean, default: false },
notAllowedText: { type: String, default: undefined },
},
data() {
return {
value: this.initialValue,
}
},
methods: {
async onChange() {
if (this.isDisabled) return
const variables = []
variables[this.attrName] = this.value
this.$apollo
.mutate({
mutation: updateUserInfos,
variables,
})
.then(() => {
this.$store.commit(this.attrName, this.value)
this.$emit('valueChanged', this.value)
this.toastSuccess(this.value ? this.enabledText : this.disabledText)
})
.catch((error) => {
this.value = this.initialValue
this.toastError(error.message)
})
},
onClick() {
if (this.notAllowedText && this.disabled) {
this.toastError(this.notAllowedText)
}
},
},
}
</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,11 @@ const version = {
BUILD_COMMIT_SHORT: (process.env.BUILD_COMMIT ?? '0000000').slice(0, 7),
}
const features = {
GMS_ACTIVE: process.env.GMS_ACTIVE ?? false,
HUMHUB_ACTIVE: process.env.HUMHUB_ACTIVE ?? false,
}
const environment = {
NODE_ENV: process.env.NODE_ENV,
DEBUG: process.env.NODE_ENV !== 'production' ?? false,
@ -81,6 +86,7 @@ if (
const CONFIG = {
...constants,
...version,
...features,
...environment,
...endpoints,
...community,

View File

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

View File

@ -19,6 +19,20 @@ export const verifyLogin = gql`
}
}
`
export const authenticateGmsUserSearch = gql`
query {
authenticateGmsUserSearch {
url
token
}
}
`
export const authenticateHumhubAutoLogin = gql`
query {
authenticateHumhubAutoLogin
}
`
export const transactionsQuery = gql`
query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {

View File

@ -7,6 +7,14 @@
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"GMS": {
"title": "Geo Matching System GMS (in Entwicklung)",
"desc": "Finde Mitglieder aller Communities auf einer Landkarte."
},
"Humhub": {
"title": "Gradido-Kreise",
"desc": "Gemeinsam unterstützen wir einander achtsam in Kreiskultur."
},
"PersonalDetails": "Persönliche Angaben",
"advanced-calculation": "Vorausberechnung",
"asterisks": "****",
@ -22,6 +30,11 @@
}
},
"back": "Zurück",
"circles": {
"headline": "Gemeinsam unterstützen wir einander achtsam in Kreiskultur.",
"text": "In geschützten Räumen können wir frei kommunizieren und kooperieren, ohne auf die sozialen Medien angewiesen zu sein. Mit Klick auf den Button öffnest Du die Kooperationsplattform in einem neuen Browser-Fenster.",
"button": "Gradido-Kreise starten..."
},
"community": {
"admins": "Administratoren",
"choose-another-community": "Eine andere Gemeinschaft auswählen",
@ -265,8 +278,10 @@
"overview": "Übersicht",
"send": "Senden",
"settings": "Einstellung",
"circles": "Kreise",
"support": "Support",
"transactions": "Transaktionen"
"transactions": "Transaktionen",
"usersearch": "Nutzersuche"
},
"openHours": "Offene Stunden",
"pageTitle": {
@ -276,7 +291,9 @@
"overview": "Willkommen {name}",
"send": "Sende Gradidos",
"settings": "Einstellungen",
"transactions": "Deine Transaktionen"
"circles": "Gradido Kreise (Beta)",
"transactions": "Deine Transaktionen",
"usersearch": "Geografische Nutzersuche"
},
"qrCode": "QR Code",
"send_gdd": "GDD versenden",
@ -289,9 +306,39 @@
"warningText": "Bist du noch da?"
},
"settings": {
"allow-community-services": "Community-Dienste erlauben",
"community": "Community",
"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": "Position auf Karte anzeigen:",
"naming-format": "Namen anzeigen:",
"publish-location": {
"exact": "Genaue Position",
"approximate": "Ungefähre Position",
"random": "Zufallsposition",
"updated": "Positionstyp für GMS aktualisiert"
},
"publish-name": {
"updated": "Namensformat für GMS aktualisiert"
}
},
"hideAmountGDD": "Dein GDD Betrag ist versteckt.",
"hideAmountGDT": "Dein GDT Betrag ist versteckt.",
"humhub": {
"delete-disabled": "Das Benutzerkonto kann nur im Profil-Menü der Kooperationsplattform gelöscht werden.",
"disabled": "Daten werden nicht in die Gradido Community exportiert",
"enabled": "Daten werden in die Gradido Community exportiert",
"naming-format": "Namen anzeigen:",
"publish-name": {
"updated": "Namensformat für die Gradido Community aktualisiert."
}
},
"info": "Transaktionen können nun per Benutzername oder E-Mail-Adresse getätigt werden.",
"language": {
"changeLanguage": "Sprache ändern",
@ -329,6 +376,18 @@
},
"subtitle": "Wenn du dein Passwort vergessen hast, kannst du es hier zurücksetzen."
},
"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 Initial",
"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": "Vorname und Nachname",
"name-full-tooltip": "Vollständiger Name: Vorname plus Nachname"
},
"showAmountGDD": "Dein GDD Betrag ist sichtbar.",
"showAmountGDT": "Dein GDT Betrag ist sichtbar.",
"username": {
@ -379,6 +438,11 @@
"transaction-link": {
"send_you": "sendet dir"
},
"usersearch": {
"headline": "Geografische Nutzersuche",
"text": "Ganz gleich zu welcher Community du gehörst, mit dem Geo Matching System findest du Mitglieder aller Communities auf einer Landkarte. Du kannst nach Angeboten und Bedürfnissen filtern und dir die Nutzer anzeigen lassen, die zu Dir passen.\n\nMit dem Button wird ein neues Browser-Fenster geöffnet, in dem dir die Nutzer in deinem Umfeld auf einer Karte angezeigt werden.",
"button": "Starte die Nutzersuche..."
},
"via_link": "über einen Link",
"welcome": "Willkommen in der Gemeinschaft"
}

View File

@ -7,6 +7,14 @@
"85": "85%",
"GDD": "GDD",
"GDT": "GDT",
"GMS": {
"title": "Geo Matching System GMS (in develop)",
"desc": "Find members of all communities on a map."
},
"Humhub": {
"title": "Gradido-circles",
"desc": "Together we support each other - mindful in circle culture."
},
"PersonalDetails": "Personal details",
"advanced-calculation": "Advanced calculation",
"asterisks": "****",
@ -22,6 +30,11 @@
}
},
"back": "Back",
"circles": {
"headline": "Together we support each other - mindful in circle culture.",
"text": "We can communicate and collaborate freely in protected spaces without having to rely on social media. Click on the button to open the collaboration platform in a new browser window.",
"button": "Gradido circles start..."
},
"community": {
"admins": "Administrators",
"choose-another-community": "Choose another community",
@ -265,8 +278,10 @@
"overview": "Overview",
"send": "Send",
"settings": "Settings",
"circles": "Circle",
"support": "Support",
"transactions": "Transactions"
"transactions": "Transactions",
"usersearch": "Geographical User Search"
},
"openHours": "Open Hours",
"pageTitle": {
@ -276,7 +291,9 @@
"overview": "Welcome {name}",
"send": "Send Gradidos",
"settings": "Settings",
"transactions": "Your transactions"
"transactions": "Your transactions",
"circles": "Gradido Circles (Beta)",
"usersearch": "Geographical User Search"
},
"qrCode": "QR Code",
"send_gdd": "Send GDD",
@ -289,9 +306,39 @@
"warningText": "Are you still there?"
},
"settings": {
"allow-community-services": "Allow Community Services",
"community": "Community",
"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": "Show position on map:",
"naming-format": "Show Name:",
"publish-location": {
"exact": "exact position",
"approximate": "approximate position",
"random": "random position",
"updated": "format of location for GMS updated"
},
"publish-name": {
"updated": "format of name for GMS updated"
}
},
"hideAmountGDD": "Your GDD amount is hidden.",
"hideAmountGDT": "Your GDT amount is hidden.",
"humhub": {
"delete-disabled": "The user account can only be deleted in the profile menu of the cooperation platform.",
"disabled": "Data not exported into the Gradido Community",
"enabled": "Data exported into the Gradido Community",
"naming-format": "Show Name:",
"publish-name": {
"updated": "Format of name for the Gradido Community updated."
}
},
"info": "Transactions can now be made by username or email address.",
"language": {
"changeLanguage": "Change language",
@ -329,6 +376,18 @@
},
"subtitle": "If you have forgotten your password, you can reset it here."
},
"publish-name": {
"alias-or-initials": "Username or initials (Default)",
"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",
"initials-tooltip": "Initials of firstname and lastname independent if username exists",
"name-full": "firstname and lastname",
"name-full-tooltip": "fullname: firstname plus lastname"
},
"showAmountGDD": "Your GDD amount is visible.",
"showAmountGDT": "Your GDT amount is visible.",
"username": {
@ -379,6 +438,11 @@
"transaction-link": {
"send_you": "wants to send you"
},
"usersearch": {
"headline": "Geographical User Search",
"text": "No matter which community you belong to, with the Geo Matching System you can find members of all communities on a map. You can filter according to offers and needs and display the users that match you.\n\nThe button opens a new browser window in which the users in your area are displayed on a map.",
"button": "Start user search... "
},
"via_link": "via Link",
"welcome": "Welcome to the community"
}

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