diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb49eaf5..b05bd3e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/admin/package.json b/admin/package.json index b1a2bd044..97d4f6fda 100644 --- a/admin/package.json +++ b/admin/package.json @@ -3,7 +3,7 @@ "description": "Administraion Interface for Gradido", "main": "index.js", "author": "Moriz Wahl", - "version": "2.2.0", + "version": "2.2.1", "license": "Apache-2.0", "private": false, "scripts": { diff --git a/backend/.env.dist b/backend/.env.dist index 4ec60d856..a3cf28bd4 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -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= diff --git a/backend/.env.template b/backend/.env.template index 1cff23d5a..71fbcbf31 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -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 + diff --git a/backend/jest.config.js b/backend/jest.config.js index de649d66e..23b9ed387 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -7,7 +7,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], coverageThreshold: { global: { - lines: 84, + lines: 81, }, }, setupFiles: ['/test/testSetup.ts'], diff --git a/backend/package.json b/backend/package.json index 8f70ab11e..7e70b7b8c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/apis/gms/GmsClient.ts b/backend/src/apis/gms/GmsClient.ts index 46fa64006..a59f7f6b5 100644 --- a/backend/src/apis/gms/GmsClient.ts +++ b/backend/src/apis/gms/GmsClient.ts @@ -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 { - 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 { - 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 { } export async function userByUuid(uuid: string): Promise { - 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 { - 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 { + 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 { + 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) } } diff --git a/backend/src/apis/gms/model/GmsUser.ts b/backend/src/apis/gms/model/GmsUser.ts index 7f7db7660..db6826a2d 100644 --- a/backend/src/apis/gms/model/GmsUser.ts +++ b/backend/src/apis/gms/model/GmsUser.ts @@ -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) } diff --git a/backend/src/apis/humhub/ExportUsers.ts b/backend/src/apis/humhub/ExportUsers.ts new file mode 100644 index 000000000..8a40d480d --- /dev/null +++ b/backend/src/apis/humhub/ExportUsers.ts @@ -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> { + const start = new Date().getTime() + const humhubUsers = new Map() + 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[] = [] + 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) +}) diff --git a/backend/src/apis/humhub/HumHubClient.ts b/backend/src/apis/humhub/HumHubClient.ts new file mode 100644 index 000000000..42fe598c2 --- /dev/null +++ b/backend/src/apis/humhub/HumHubClient.ts @@ -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, + ): Promise { + const requestOptions: IRequestOptions = { + additionalHeaders: { authorization: 'Bearer ' + (await this.createJWTToken()) }, + } + if (queryParams) { + requestOptions.queryParameters = { params: queryParams } + } + return requestOptions + } + + private async createJWTToken(): Promise { + 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 { + const options = await this.createRequestOptions({ page, limit }) + const response = await this.restClient.get('/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 { + const options = await this.createRequestOptions({ email }) + const response = await this.restClient.get('/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> { + const options = await this.createRequestOptions({ email }) + return this.restClient.get('/api/v1/user/get-by-email', options) + } + + public async userByUsernameAsync(username: string): Promise> { + const options = await this.createRequestOptions({ username }) + return this.restClient.get('/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 { + const options = await this.createRequestOptions({ username }) + const response = await this.restClient.get('/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 { + 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 { + logger.info('update humhub user', new PostUserLoggingView(user)) + const options = await this.createRequestOptions() + const response = await this.restClient.update( + `/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 { + 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/') diff --git a/backend/src/apis/humhub/__mocks__/HumHubClient.ts b/backend/src/apis/humhub/__mocks__/HumHubClient.ts new file mode 100644 index 000000000..cc9af4d76 --- /dev/null +++ b/backend/src/apis/humhub/__mocks__/HumHubClient.ts @@ -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 { + return Promise.resolve(new UsersResponse()) + } + + public async userByEmail(email: string): Promise { + 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> { + 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 { + 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 { + return Promise.resolve() + } + + public async updateUser(inputUser: PostUser, humhubUserId: number): Promise { + 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 { + return Promise.resolve() + } +} diff --git a/backend/src/apis/humhub/__mocks__/syncUser.ts b/backend/src/apis/humhub/__mocks__/syncUser.ts new file mode 100644 index 000000000..7e0660da4 --- /dev/null +++ b/backend/src/apis/humhub/__mocks__/syncUser.ts @@ -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, +): Promise { + 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) +} diff --git a/backend/src/apis/humhub/compareHumhubUserDbUser.test.ts b/backend/src/apis/humhub/compareHumhubUserDbUser.test.ts new file mode 100644 index 000000000..cc636d17a --- /dev/null +++ b/backend/src/apis/humhub/compareHumhubUserDbUser.test.ts @@ -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) + }) +}) diff --git a/backend/src/apis/humhub/compareHumhubUserDbUser.ts b/backend/src/apis/humhub/compareHumhubUserDbUser.ts new file mode 100644 index 000000000..9b7f0b51b --- /dev/null +++ b/backend/src/apis/humhub/compareHumhubUserDbUser.ts @@ -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) + ) +} diff --git a/backend/src/apis/humhub/convertLanguage.test.ts b/backend/src/apis/humhub/convertLanguage.test.ts new file mode 100644 index 000000000..052e63075 --- /dev/null +++ b/backend/src/apis/humhub/convertLanguage.test.ts @@ -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) + }) + }) +}) diff --git a/backend/src/apis/humhub/convertLanguage.ts b/backend/src/apis/humhub/convertLanguage.ts new file mode 100644 index 000000000..9555b0a52 --- /dev/null +++ b/backend/src/apis/humhub/convertLanguage.ts @@ -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 +} diff --git a/backend/src/apis/humhub/logging/AccountLogging.view.ts b/backend/src/apis/humhub/logging/AccountLogging.view.ts new file mode 100644 index 000000000..e5a2df565 --- /dev/null +++ b/backend/src/apis/humhub/logging/AccountLogging.view.ts @@ -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, + } + } +} diff --git a/backend/src/apis/humhub/logging/PostUserLogging.view.ts b/backend/src/apis/humhub/logging/PostUserLogging.view.ts new file mode 100644 index 000000000..47123c08b --- /dev/null +++ b/backend/src/apis/humhub/logging/PostUserLogging.view.ts @@ -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, + }, + } + } +} diff --git a/backend/src/apis/humhub/logging/ProfileLogging.view.ts b/backend/src/apis/humhub/logging/ProfileLogging.view.ts new file mode 100644 index 000000000..1c107676d --- /dev/null +++ b/backend/src/apis/humhub/logging/ProfileLogging.view.ts @@ -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) + '...', + } + } +} diff --git a/backend/src/apis/humhub/model/Account.ts b/backend/src/apis/humhub/model/Account.ts new file mode 100644 index 000000000..1ac30fc98 --- /dev/null +++ b/backend/src/apis/humhub/model/Account.ts @@ -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 +} diff --git a/backend/src/apis/humhub/model/GetUser.ts b/backend/src/apis/humhub/model/GetUser.ts new file mode 100644 index 000000000..7d9814fc3 --- /dev/null +++ b/backend/src/apis/humhub/model/GetUser.ts @@ -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 +} diff --git a/backend/src/apis/humhub/model/Password.ts b/backend/src/apis/humhub/model/Password.ts new file mode 100644 index 000000000..04cede68b --- /dev/null +++ b/backend/src/apis/humhub/model/Password.ts @@ -0,0 +1,4 @@ +export class Password { + newPassword: string + mustChangePassword: boolean +} diff --git a/backend/src/apis/humhub/model/PostUser.ts b/backend/src/apis/humhub/model/PostUser.ts new file mode 100644 index 000000000..a164440fd --- /dev/null +++ b/backend/src/apis/humhub/model/PostUser.ts @@ -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 +} diff --git a/backend/src/apis/humhub/model/PostUserError.ts b/backend/src/apis/humhub/model/PostUserError.ts new file mode 100644 index 000000000..0e6b0e152 --- /dev/null +++ b/backend/src/apis/humhub/model/PostUserError.ts @@ -0,0 +1,6 @@ +export class PostUserError { + code: number + message: string + profile: string[] + account: string[] +} diff --git a/backend/src/apis/humhub/model/Profile.ts b/backend/src/apis/humhub/model/Profile.ts new file mode 100644 index 000000000..424c48026 --- /dev/null +++ b/backend/src/apis/humhub/model/Profile.ts @@ -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 +} diff --git a/backend/src/apis/humhub/model/UsersResponse.ts b/backend/src/apis/humhub/model/UsersResponse.ts new file mode 100644 index 000000000..22379739c --- /dev/null +++ b/backend/src/apis/humhub/model/UsersResponse.ts @@ -0,0 +1,7 @@ +import { GetUser } from './GetUser' + +export class UsersResponse { + total: number + page: number + results: GetUser[] +} diff --git a/backend/src/apis/humhub/syncUser.test.ts b/backend/src/apis/humhub/syncUser.test.ts new file mode 100644 index 000000000..20a6b2c33 --- /dev/null +++ b/backend/src/apis/humhub/syncUser.test.ts @@ -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() + 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() + 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() + 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() + + 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() + + defaultUser.humhubAllowed = true + const result = await syncUser(defaultUser, humhubUsers) + + expect(result).toBe(ExecutedHumhubAction.CREATE) + }) +}) diff --git a/backend/src/apis/humhub/syncUser.ts b/backend/src/apis/humhub/syncUser.ts new file mode 100644 index 000000000..fc6fcc99b --- /dev/null +++ b/backend/src/apis/humhub/syncUser.ts @@ -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, +): Promise { + 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 +} diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index c8f02976b..1f0bda2b3 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -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', diff --git a/backend/src/auth/USER_RIGHTS.ts b/backend/src/auth/USER_RIGHTS.ts index 9bf9fee93..de8e54af1 100644 --- a/backend/src/auth/USER_RIGHTS.ts +++ b/backend/src/auth/USER_RIGHTS.ts @@ -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, ] diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 0dbc0ea5a..82308a52b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -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, } diff --git a/backend/src/data/PublishName.logic.ts b/backend/src/data/PublishName.logic.ts new file mode 100644 index 000000000..e307a74d0 --- /dev/null +++ b/backend/src/data/PublishName.logic.ts @@ -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 '' + } +} diff --git a/backend/src/federation/authenticateCommunities.ts b/backend/src/federation/authenticateCommunities.ts index 8da8306fd..56899d4b0 100644 --- a/backend/src/federation/authenticateCommunities.ts +++ b/backend/src/federation/authenticateCommunities.ts @@ -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'), diff --git a/backend/src/federation/client/1_0/AuthenticationClient.ts b/backend/src/federation/client/1_0/AuthenticationClient.ts index f73393255..c1d921823 100644 --- a/backend/src/federation/client/1_0/AuthenticationClient.ts +++ b/backend/src/federation/client/1_0/AuthenticationClient.ts @@ -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: { diff --git a/backend/src/federation/client/1_0/FederationClient.ts b/backend/src/federation/client/1_0/FederationClient.ts index b9939a12c..0c2b4101b 100644 --- a/backend/src/federation/client/1_0/FederationClient.ts +++ b/backend/src/federation/client/1_0/FederationClient.ts @@ -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: { diff --git a/backend/src/federation/client/1_0/SendCoinsClient.ts b/backend/src/federation/client/1_0/SendCoinsClient.ts index bcf303584..2c3fcce4c 100644 --- a/backend/src/federation/client/1_0/SendCoinsClient.ts +++ b/backend/src/federation/client/1_0/SendCoinsClient.ts @@ -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: { diff --git a/backend/src/federation/client/FederationClientFactory.ts b/backend/src/federation/client/FederationClientFactory.ts index fe2ff0dbd..6010fa5eb 100644 --- a/backend/src/federation/client/FederationClientFactory.ts +++ b/backend/src/federation/client/FederationClientFactory.ts @@ -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 diff --git a/backend/src/graphql/arg/UpdateUserInfosArgs.ts b/backend/src/graphql/arg/UpdateUserInfosArgs.ts index 0920fb3bc..c368bbd8b 100644 --- a/backend/src/graphql/arg/UpdateUserInfosArgs.ts +++ b/backend/src/graphql/arg/UpdateUserInfosArgs.ts @@ -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 } diff --git a/backend/src/graphql/enum/GmsPublishNameType.ts b/backend/src/graphql/enum/GmsPublishNameType.ts deleted file mode 100644 index 08aaaf8ef..000000000 --- a/backend/src/graphql/enum/GmsPublishNameType.ts +++ /dev/null @@ -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 -}) diff --git a/backend/src/graphql/enum/PublishNameType.ts b/backend/src/graphql/enum/PublishNameType.ts new file mode 100644 index 000000000..a60be9f50 --- /dev/null +++ b/backend/src/graphql/enum/PublishNameType.ts @@ -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 +}) diff --git a/backend/src/graphql/model/FederatedCommunity.ts b/backend/src/graphql/model/FederatedCommunity.ts index fb30b0292..01a3996ce 100644 --- a/backend/src/graphql/model/FederatedCommunity.ts +++ b/backend/src/graphql/model/FederatedCommunity.ts @@ -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 diff --git a/backend/src/graphql/model/GmsUserAuthenticationResult.ts b/backend/src/graphql/model/GmsUserAuthenticationResult.ts new file mode 100644 index 000000000..b1fb2c246 --- /dev/null +++ b/backend/src/graphql/model/GmsUserAuthenticationResult.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from 'type-graphql' + +@ObjectType() +export class GmsUserAuthenticationResult { + @Field(() => String) + url: string + + @Field(() => String) + token: string +} diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index d24a717c4..328bec61b 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -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 diff --git a/backend/src/graphql/resolver/TransactionResolver.test.ts b/backend/src/graphql/resolver/TransactionResolver.test.ts index 1bb4a86f7..4bf5ab493 100644 --- a/backend/src/graphql/resolver/TransactionResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionResolver.test.ts @@ -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({ diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index c570dbd9f..83ee8f64e 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -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, }, }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 8c7d56985..0936528f5 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -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> | 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 { + 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 { + } = 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 { + 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 { + 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( diff --git a/backend/src/graphql/resolver/util/authenticateGmsUserPlayground.ts b/backend/src/graphql/resolver/util/authenticateGmsUserPlayground.ts new file mode 100644 index 000000000..ef3c199c9 --- /dev/null +++ b/backend/src/graphql/resolver/util/authenticateGmsUserPlayground.ts @@ -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 { + 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 +} diff --git a/backend/src/graphql/resolver/util/compareGmsRelevantUserSettings.ts b/backend/src/graphql/resolver/util/compareGmsRelevantUserSettings.ts new file mode 100644 index 000000000..4c9e51462 --- /dev/null +++ b/backend/src/graphql/resolver/util/compareGmsRelevantUserSettings.ts @@ -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 +} diff --git a/backend/src/graphql/resolver/util/sendUserToGms.ts b/backend/src/graphql/resolver/util/sendUserToGms.ts index 335141ffe..c21550b91 100644 --- a/backend/src/graphql/resolver/util/sendUserToGms.ts +++ b/backend/src/graphql/resolver/util/sendUserToGms.ts @@ -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) + } } } diff --git a/backend/src/graphql/resolver/util/syncHumhub.test.ts b/backend/src/graphql/resolver/util/syncHumhub.test.ts new file mode 100644 index 000000000..c25eb52a8 --- /dev/null +++ b/backend/src/graphql/resolver/util/syncHumhub.test.ts @@ -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... +}) diff --git a/backend/src/graphql/resolver/util/syncHumhub.ts b/backend/src/graphql/resolver/util/syncHumhub.ts new file mode 100644 index 000000000..426f89c92 --- /dev/null +++ b/backend/src/graphql/resolver/util/syncHumhub.ts @@ -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 { + // 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() + 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], + }) +} diff --git a/backend/src/seeds/factory/user.ts b/backend/src/seeds/factory/user.ts index 3ddddf336..f0bda3a3f 100644 --- a/backend/src/seeds/factory/user.ts +++ b/backend/src/seeds/factory/user.ts @@ -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) } diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index b10bb4b4e..d9618bd0c 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -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 diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index ed0fe6d26..b097a2710 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -15,6 +15,14 @@ export const verifyLogin = gql` } } ` +export const authenticateGmsUserSearch = gql` + query { + authenticateGmsUserSearch { + url + token + } + } +` export const queryOptIn = gql` query ($optIn: String!) { diff --git a/backend/src/server/createServer.ts b/backend/src/server/createServer.ts index 3f02b0afc..a901d8763 100644 --- a/backend/src/server/createServer.ts +++ b/backend/src/server/createServer.ts @@ -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(), diff --git a/backend/src/server/plugins.ts b/backend/src/server/plugins.ts index 3e0fc50e1..c4ffa4f3f 100644 --- a/backend/src/server/plugins.ts +++ b/backend/src/server/plugins.ts @@ -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)}`) } diff --git a/backend/src/util/communityUser.ts b/backend/src/util/communityUser.ts index 732c585d0..45a142ce8 100644 --- a/backend/src/util/communityUser.ts +++ b/backend/src/util/communityUser.ts @@ -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, diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts index c3895cb9e..bc2c2198a 100644 --- a/backend/src/util/utilities.ts +++ b/backend/src/util/utilities.ts @@ -29,3 +29,7 @@ export function resetInterface>(obj: T): T { } return obj } + +export const ensureUrlEndsWithSlash = (url: string): string => { + return url.endsWith('/') ? url : url.concat('/') +} diff --git a/backend/src/webhook/gms.ts b/backend/src/webhook/gms.ts new file mode 100644 index 000000000..3a4e9c3f3 --- /dev/null +++ b/backend/src/webhook/gms.ts @@ -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 => { + 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 }) +} diff --git a/backend/yarn.lock b/backend/yarn.lock index 91186187b..26f861772 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -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" diff --git a/database/entity/0084-introduce_humhub_registration/User.ts b/database/entity/0084-introduce_humhub_registration/User.ts new file mode 100644 index 000000000..a375f6748 --- /dev/null +++ b/database/entity/0084-introduce_humhub_registration/User.ts @@ -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[] +} diff --git a/database/entity/User.ts b/database/entity/User.ts index e3f15113d..993d983ef 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0082-introduce_gms_registration/User' +export { User } from './0084-introduce_humhub_registration/User' diff --git a/database/migrations/0084-introduce_humhub_registration.ts b/database/migrations/0084-introduce_humhub_registration.ts new file mode 100644 index 000000000..858283602 --- /dev/null +++ b/database/migrations/0084-introduce_humhub_registration.ts @@ -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>) { + 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>) { + await queryFn('ALTER TABLE `users` DROP COLUMN IF EXISTS `humhub_allowed`;') + await queryFn('ALTER TABLE `users` DROP COLUMN IF EXISTS `humhub_publish_name`;') +} diff --git a/database/package.json b/database/package.json index caeb917c4..c0f7496f0 100644 --- a/database/package.json +++ b/database/package.json @@ -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", diff --git a/deployment/bare_metal/.env.dist b/deployment/bare_metal/.env.dist index 9e6e911eb..3b7a19b6b 100644 --- a/deployment/bare_metal/.env.dist +++ b/deployment/bare_metal/.env.dist @@ -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= diff --git a/deployment/hetzner_cloud/README.md b/deployment/hetzner_cloud/README.md index d2cd66a35..a0dfe79e3 100644 --- a/deployment/hetzner_cloud/README.md +++ b/deployment/hetzner_cloud/README.md @@ -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 diff --git a/deployment/hetzner_cloud/install.sh b/deployment/hetzner_cloud/install.sh index b51f9b454..1be3548cc 100755 --- a/deployment/hetzner_cloud/install.sh +++ b/deployment/hetzner_cloud/install.sh @@ -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 < $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 \ No newline at end of file +sudo -u gradido $SCRIPT_PATH/start.sh $1 \ No newline at end of file diff --git a/deployment/hetzner_cloud/migration/2_2_0-2_2_1/README.md b/deployment/hetzner_cloud/migration/2_2_0-2_2_1/README.md new file mode 100644 index 000000000..38c4e8330 --- /dev/null +++ b/deployment/hetzner_cloud/migration/2_2_0-2_2_1/README.md @@ -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 + diff --git a/deployment/hetzner_cloud/migration/2_2_0-2_2_1/fixInstall.sh b/deployment/hetzner_cloud/migration/2_2_0-2_2_1/fixInstall.sh new file mode 100755 index 000000000..1995b220d --- /dev/null +++ b/deployment/hetzner_cloud/migration/2_2_0-2_2_1/fixInstall.sh @@ -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 < $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 \ No newline at end of file diff --git a/dht-node/package.json b/dht-node/package.json index 7a73791d5..a4859e394 100644 --- a/dht-node/package.json +++ b/dht-node/package.json @@ -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/", diff --git a/dht-node/src/config/index.ts b/dht-node/src/config/index.ts index 4e66aa5f9..f557eee83 100644 --- a/dht-node/src/config/index.ts +++ b/dht-node/src/config/index.ts @@ -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', diff --git a/dlt-connector/package.json b/dlt-connector/package.json index 7aa8aa10d..30cd4b33b 100644 --- a/dlt-connector/package.json +++ b/dlt-connector/package.json @@ -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/", diff --git a/dlt-database/migrations/0004-fix_spelling.ts b/dlt-database/migrations/0004-fix_spelling.ts index 3b2153a7d..1507ab590 100644 --- a/dlt-database/migrations/0004-fix_spelling.ts +++ b/dlt-database/migrations/0004-fix_spelling.ts @@ -1,7 +1,7 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { 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>) { await queryFn(` ALTER TABLE \`transactions\` - RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`, + RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\` ; `) } diff --git a/docu/Concepts/TechnicalRequirements/UC_publish_user_to_GMS.md b/docu/Concepts/TechnicalRequirements/UC_publish_user_to_GMS.md index 409534748..6abfdaaa6 100644 --- a/docu/Concepts/TechnicalRequirements/UC_publish_user_to_GMS.md +++ b/docu/Concepts/TechnicalRequirements/UC_publish_user_to_GMS.md @@ -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... diff --git a/docu/Concepts/TechnicalRequirements/image/usecase-user_search.svg b/docu/Concepts/TechnicalRequirements/image/usecase-user_search.svg new file mode 100644 index 000000000..7430ce4a8 --- /dev/null +++ b/docu/Concepts/TechnicalRequirements/image/usecase-user_search.svg @@ -0,0 +1,4 @@ + + + +
gradido-frontend
gradido-frontend
activation of menu-entry
"user-search"
activation of menu-entry...
gradido-backend
gradido-backend
6.a
6.a
6.b
6.b
authUserForGmsUserSearch
authUserForGmsUserSearch
1.a
1.a
1.b
1.b
gms-frontend
gms-frontend
user-playground uri
user-playground uri
gms-backend
gms-backend
3.b
3.b
3.a
3.a
verifyAuthToken
verifyAuthToken
2.a
2.a
2.b
2.b
4.a
4.a
4.b
4.b
community-auth-url
community-auth-url
5.b
5.b
5.a
5.a
7.a
7.a
7.b
7.b
Text is not SVG - cannot display
\ No newline at end of file diff --git a/federation/package.json b/federation/package.json index fcfaa0e46..9a74201ab 100644 --- a/federation/package.json +++ b/federation/package.json @@ -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", diff --git a/federation/src/config/index.ts b/federation/src/config/index.ts index f8992a360..26b727841 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -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 diff --git a/frontend/.env.dist b/frontend/.env.dist index f7e7edcd6..f11a70873 100644 --- a/frontend/.env.dist +++ b/frontend/.env.dist @@ -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 diff --git a/frontend/.env.template b/frontend/.env.template index c365ab8cf..40d0e7ee1 100644 --- a/frontend/.env.template +++ b/frontend/.env.template @@ -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 \ No newline at end of file diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 4c3e6ab73..472ad70f9 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -4,7 +4,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.{js,vue}', '!**/node_modules/**', '!**/?(*.)+(spec|test).js?(x)'], coverageThreshold: { global: { - lines: 94, + lines: 93, }, }, moduleFileExtensions: [ diff --git a/frontend/package.json b/frontend/package.json index e54ffa263..4dd840261 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/img/loupe.png b/frontend/public/img/loupe.png new file mode 100644 index 000000000..ab289e4ad Binary files /dev/null and b/frontend/public/img/loupe.png differ diff --git a/frontend/public/img/svg/circles.svg b/frontend/public/img/svg/circles.svg new file mode 100644 index 000000000..5deb96d1b --- /dev/null +++ b/frontend/public/img/svg/circles.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 46f7dacb8..663c9e7d4 100755 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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; +} diff --git a/frontend/src/assets/News/news.json b/frontend/src/assets/News/news.json index 45360631b..013eea7be 100644 --- a/frontend/src/assets/News/news.json +++ b/frontend/src/assets/News/news.json @@ -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\"!" } ] diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index ac9ce3182..4ff3d9781 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -90,20 +90,3 @@ export default { }, } - diff --git a/frontend/src/components/Menu/Sidebar.spec.js b/frontend/src/components/Menu/Sidebar.spec.js index 23c855557..8891d0516 100644 --- a/frontend/src/components/Menu/Sidebar.spec.js +++ b/frontend/src/components/Menu/Sidebar.spec.js @@ -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') }) }) diff --git a/frontend/src/components/Menu/Sidebar.vue b/frontend/src/components/Menu/Sidebar.vue index 1b18acc17..b7d4160e6 100644 --- a/frontend/src/components/Menu/Sidebar.vue +++ b/frontend/src/components/Menu/Sidebar.vue @@ -16,7 +16,7 @@ {{ $t('navigation.send') }} - + {{ $t('navigation.transactions') }} @@ -24,14 +24,18 @@ {{ $t('creation') }} - - - {{ $t('GDT') }} - - + {{ $t('navigation.info') }} + + + {{ $t('navigation.circles') }} + + + + {{ $t('navigation.usersearch') }} +
@@ -73,11 +77,27 @@ diff --git a/frontend/src/components/UserSettings/UserNamingFormat.spec.js b/frontend/src/components/UserSettings/UserNamingFormat.spec.js new file mode 100644 index 000000000..c92cca138 --- /dev/null +++ b/frontend/src/components/UserSettings/UserNamingFormat.spec.js @@ -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') + }) + }) +}) diff --git a/frontend/src/components/UserSettings/UserNamingFormat.vue b/frontend/src/components/UserSettings/UserNamingFormat.vue new file mode 100644 index 000000000..e5c5740ab --- /dev/null +++ b/frontend/src/components/UserSettings/UserNamingFormat.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/components/UserSettings/UserSettingsSwitch.vue b/frontend/src/components/UserSettings/UserSettingsSwitch.vue new file mode 100644 index 000000000..20878db86 --- /dev/null +++ b/frontend/src/components/UserSettings/UserSettingsSwitch.vue @@ -0,0 +1,58 @@ + + diff --git a/frontend/src/config/index.js b/frontend/src/config/index.js index dd2e85dac..d96b434be 100644 --- a/frontend/src/config/index.js +++ b/frontend/src/config/index.js @@ -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, diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index cade098da..dd812db4b 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -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 diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 9f961e402..d389f7715 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -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) { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 337722940..661a4f8e4 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -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" } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 982d22159..527ec3ddf 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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" } diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 6e52fd9a1..bc5479029 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -242,7 +242,19 @@ "profile": "Mi Perfil", "send": "Enviar", "support": "Soporte", - "transactions": "Transacciones" + "transactions": "Transacciones", + "usersearch": "Buscar usuarios" + }, + "openHours": "Open Hours", + "pageTitle": { + "community": "Create Gradido", + "gdt": "Tu GDT Transacciones", + "information": "{community}", + "overview": "Welcome {name}", + "send": "Enviar Gradidos", + "settings": "Soporte", + "transactions": "Tu Transacciones", + "usersearch": "Búsqueda geográfica de usuarios" }, "qrCode": "Código QR", "send_gdd": "Enviar GDD", @@ -334,6 +346,11 @@ "transaction-link": { "send_you": "te envía" }, + "usersearch": { + "headline": "Búsqueda geográfica de usuarios", + "text": "No importa a qué comunidad pertenezcas, con el Geo Matching System puedes encontrar miembros de todas las comunidades en un mapa. Puedes filtrar según ofertas y requisitos y visualizar los usuarios que coinciden con tu perfil.\n\nEl botón abre una nueva ventana del navegador en la que se muestran en un mapa los usuarios de tu zona.", + "button": "Iniciar la búsqueda de usuarios..." + }, "via_link": "atraves de un enlace", "welcome": "Bienvenido a la comunidad." } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 94bac00a6..999060f21 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -250,7 +250,8 @@ "send": "Envoyer", "settings": "Configuration", "support": "Aide", - "transactions": "Transactions" + "transactions": "Transactions", + "usersearch": "Recherche d'utilisateurs" }, "openHours": "Heures ouverte", "pageTitle": { @@ -260,7 +261,8 @@ "overview": "Bienvenue {name}", "send": "Envoyé Gradidos", "settings": "Configuration", - "transactions": "Vos transactions" + "transactions": "Vos transactions", + "usersearch": "Recherche géographique d'utilisateurs" }, "qrCode": "QR Code", "send_gdd": "Envoyer GDD", @@ -352,6 +354,11 @@ "transaction-link": { "send_you": "veut vous envoyer" }, + "usersearch": { + "headline": "Recherche géographique d'utilisateurs", + "text": "Quelle que soit la communauté à laquelle tu appartiens, le système de géo-matching te permet de trouver des membres de toutes les communautés sur une carte géographique. Tu peux filtrer selon les offres et les besoins et afficher les utilisateurs qui te correspondent.\n\nEn cliquant sur le bouton, une nouvelle fenêtre de navigateur s'ouvre et t'affiche les utilisateurs de ton entourage sur une carte.", + "button": "Commence la recherche d'utilisateurs..." + }, "via_link": "par lien", "welcome": "Bienvenu dans la communauté" } diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index a1f612f39..2e50f2968 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -242,7 +242,19 @@ "profile": "Mijn profiel", "send": "Sturen", "support": "Support", - "transactions": "Transacties" + "transactions": "Transacties", + "usersearch": "Gebruiker zoeken" + }, + "openHours": "Open Hours", + "pageTitle": { + "community": "Create Gradido", + "gdt": "Your GDT transactions", + "information": "{community}", + "overview": "Welcome {name}", + "send": "Send Gradidos", + "settings": "Settings", + "transactions": "Your transactions", + "usersearch": "Geografisch zoeken naar gebruikers" }, "qrCode": "QR Code", "send_gdd": "GDD sturen", @@ -334,6 +346,11 @@ "transaction-link": { "send_you": "stuurt jou" }, + "usersearch": { + "headline": "Geografisch zoeken naar gebruikers", + "text": "Het maakt niet uit tot welke community je behoort, met het Geo Matching System kun je leden van alle communities vinden op een kaart. Je kunt filteren op aanbiedingen en vereisten en de gebruikers weergeven die aan je profiel voldoen.\n\nDe knop opent een nieuw browservenster waarin de gebruikers in je omgeving op een kaart worden weergegeven.", + "button": "Start het zoeken naar gebruikers..." + }, "via_link": "via een link", "welcome": "Welkom in de gemeenschap" } diff --git a/frontend/src/pages/Circles.vue b/frontend/src/pages/Circles.vue new file mode 100644 index 000000000..ab91b9fe2 --- /dev/null +++ b/frontend/src/pages/Circles.vue @@ -0,0 +1,71 @@ + + diff --git a/frontend/src/pages/Overview.spec.js b/frontend/src/pages/Overview.spec.js index 32a14e7b5..b64855f65 100644 --- a/frontend/src/pages/Overview.spec.js +++ b/frontend/src/pages/Overview.spec.js @@ -1,7 +1,9 @@ -import { mount } from '@vue/test-utils' +import { RouterLinkStub, mount } from '@vue/test-utils' import Overview from './Overview' +import VueRouter from 'vue-router' const localVue = global.localVue +localVue.use(VueRouter) window.scrollTo = jest.fn() @@ -20,6 +22,9 @@ describe('Overview', () => { return mount(Overview, { localVue, mocks, + stubs: { + RouterLink: RouterLinkStub, + }, }) } diff --git a/frontend/src/pages/Settings.spec.js b/frontend/src/pages/Settings.spec.js index fef51edcc..c235503fe 100644 --- a/frontend/src/pages/Settings.spec.js +++ b/frontend/src/pages/Settings.spec.js @@ -22,12 +22,17 @@ describe('Settings', () => { email: 'john.doe@test.com', language: 'en', newsletterState: false, + gmsAllowed: false, + humhubAllowed: false, }, commit: storeCommitMock, }, $apollo: { mutate: mockAPIcall, }, + $route: { + params: {}, + }, } const Wrapper = () => { diff --git a/frontend/src/pages/Settings.vue b/frontend/src/pages/Settings.vue index 1530e5e97..2116bcdf9 100644 --- a/frontend/src/pages/Settings.vue +++ b/frontend/src/pages/Settings.vue @@ -1,81 +1,180 @@