diff --git a/backend/jest.config.js b/backend/jest.config.js index 867aeea5e..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: 82, + lines: 81, }, }, setupFiles: ['/test/testSetup.ts'], diff --git a/backend/src/apis/gms/model/GmsUser.ts b/backend/src/apis/gms/model/GmsUser.ts index 0b16f00fb..db6826a2d 100644 --- a/backend/src/apis/gms/model/GmsUser.ts +++ b/backend/src/apis/gms/model/GmsUser.ts @@ -1,8 +1,8 @@ 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) { @@ -44,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 } @@ -53,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/HumHubClient.ts b/backend/src/apis/humhub/HumHubClient.ts index b69944773..42fe598c2 100644 --- a/backend/src/apis/humhub/HumHubClient.ts +++ b/backend/src/apis/humhub/HumHubClient.ts @@ -1,10 +1,11 @@ import { SignJWT } from 'jose' -import { IRequestOptions, RestClient } from 'typed-rest-client' +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' @@ -58,6 +59,18 @@ export class HumHubClient { 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 @@ -90,12 +103,38 @@ export class HumHubClient { 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) @@ -118,6 +157,7 @@ export class HumHubClient { * @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}`, @@ -133,6 +173,7 @@ export class HumHubClient { } 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) { diff --git a/backend/src/apis/humhub/__mocks__/HumHubClient.ts b/backend/src/apis/humhub/__mocks__/HumHubClient.ts index a11d8f407..cc9af4d76 100644 --- a/backend/src/apis/humhub/__mocks__/HumHubClient.ts +++ b/backend/src/apis/humhub/__mocks__/HumHubClient.ts @@ -1,5 +1,6 @@ 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' @@ -33,6 +34,25 @@ export class HumHubClient { 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() } diff --git a/backend/src/apis/humhub/compareHumhubUserDbUser.ts b/backend/src/apis/humhub/compareHumhubUserDbUser.ts index 95192be89..9b7f0b51b 100644 --- a/backend/src/apis/humhub/compareHumhubUserDbUser.ts +++ b/backend/src/apis/humhub/compareHumhubUserDbUser.ts @@ -17,6 +17,7 @@ function accountIsTheSame(account: Account, user: User): boolean { 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 } 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 index 0926af2c2..1ac30fc98 100644 --- a/backend/src/apis/humhub/model/Account.ts +++ b/backend/src/apis/humhub/model/Account.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import { User } from '@entity/User' import { convertGradidoLanguageToHumhub } from '@/apis/humhub/convertLanguage' @@ -12,9 +13,11 @@ export class Account { 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/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index c7a23c13b..1f0bda2b3 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -38,6 +38,7 @@ export enum RIGHTS { 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 0c56b0d02..de8e54af1 100644 --- a/backend/src/auth/USER_RIGHTS.ts +++ b/backend/src/auth/USER_RIGHTS.ts @@ -30,4 +30,5 @@ export const USER_RIGHTS = [ RIGHTS.OPEN_CREATIONS, RIGHTS.USER, RIGHTS.GMS_USER_PLAYGROUND, + RIGHTS.HUMHUB_AUTO_LOGIN, ] diff --git a/backend/src/data/PublishName.logic.ts b/backend/src/data/PublishName.logic.ts index 831c27e0d..e307a74d0 100644 --- a/backend/src/data/PublishName.logic.ts +++ b/backend/src/data/PublishName.logic.ts @@ -21,13 +21,16 @@ export class PublishNameLogic { ) { return this.user.firstName } - if ( - [PublishNameType.PUBLISH_NAME_INITIALS, PublishNameType.PUBLISH_NAME_INITIAL_LAST].includes( - publishNameType, - ) - ) { + 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 '' } @@ -38,22 +41,21 @@ export class PublishNameLogic { * first initial from user.lastName for PUBLISH_NAME_FIRST_INITIAL, PUBLISH_NAME_INITIALS */ public getLastName(publishNameType: PublishNameType): string { - if ( - [ - PublishNameType.PUBLISH_NAME_LAST, - PublishNameType.PUBLISH_NAME_INITIAL_LAST, - PublishNameType.PUBLISH_NAME_FULL, - ].includes(publishNameType) - ) { + if (PublishNameType.PUBLISH_NAME_FULL === publishNameType) { return this.user.lastName - } - if ( + } 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/graphql/arg/UpdateUserInfosArgs.ts b/backend/src/graphql/arg/UpdateUserInfosArgs.ts index b9617f6df..c368bbd8b 100644 --- a/backend/src/graphql/arg/UpdateUserInfosArgs.ts +++ b/backend/src/graphql/arg/UpdateUserInfosArgs.ts @@ -2,7 +2,6 @@ import { IsBoolean, IsEnum, IsInt, IsString } from 'class-validator' import { ArgsType, Field, InputType, Int } from 'type-graphql' import { GmsPublishLocationType } from '@enum/GmsPublishLocationType' -import { GmsPublishNameType } from '@enum/GmsPublishNameType' import { PublishNameType } from '@enum/PublishNameType' import { Location } from '@model/Location' @@ -55,9 +54,9 @@ export class UpdateUserInfosArgs { @IsBoolean() gmsAllowed?: boolean - @Field(() => GmsPublishNameType, { nullable: true }) - @IsEnum(GmsPublishNameType) - gmsPublishName?: GmsPublishNameType | null + @Field(() => PublishNameType, { nullable: true }) + @IsEnum(PublishNameType) + gmsPublishName?: PublishNameType | null @Field(() => PublishNameType, { nullable: true }) @IsEnum(PublishNameType) 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 index 5fa86ee9f..a60be9f50 100644 --- a/backend/src/graphql/enum/PublishNameType.ts +++ b/backend/src/graphql/enum/PublishNameType.ts @@ -1,19 +1,14 @@ import { registerEnumType } from 'type-graphql' -/** - * Enum for decide which parts from first- and last-name are allowed to be published in an extern service - */ export enum PublishNameType { - PUBLISH_NAME_NONE = 0, + PUBLISH_NAME_ALIAS_OR_INITALS = 0, PUBLISH_NAME_INITIALS = 1, PUBLISH_NAME_FIRST = 2, PUBLISH_NAME_FIRST_INITIAL = 3, - PUBLISH_NAME_LAST = 4, - PUBLISH_NAME_INITIAL_LAST = 5, - PUBLISH_NAME_FULL = 6, + PUBLISH_NAME_FULL = 4, } registerEnumType(PublishNameType, { name: 'PublishNameType', // this one is mandatory - description: 'Type of first- and last-name publishing for extern service', // this one is optional + description: 'Type of name publishing', // this one is optional }) diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index aa4baaac0..328bec61b 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -2,7 +2,6 @@ import { User as dbUser } from '@entity/User' import { ObjectType, Field, Int } from 'type-graphql' import { GmsPublishLocationType } from '@enum/GmsPublishLocationType' -import { GmsPublishNameType } from '@enum/GmsPublishNameType' import { PublishNameType } from '@enum/PublishNameType' import { KlickTipp } from './KlickTipp' @@ -89,8 +88,8 @@ export class User { @Field(() => Boolean) gmsAllowed: boolean - @Field(() => GmsPublishNameType, { nullable: true }) - gmsPublishName: GmsPublishNameType | null + @Field(() => PublishNameType, { nullable: true }) + gmsPublishName: PublishNameType | null @Field(() => PublishNameType, { nullable: true }) humhubPublishName: PublishNameType | null diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 4fdf387b7..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 { @@ -1232,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, }), ]) @@ -1272,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, }), ]) @@ -1294,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, }), ]) @@ -1307,8 +1309,7 @@ describe('UserResolver', () => { mutation: updateUserInfos, variables: { gmsAllowed: false, - gmsPublishName: - GmsPublishNameType[GmsPublishNameType.GMS_PUBLISH_NAME_FIRST_INITIAL], + gmsPublishName: PublishNameType[PublishNameType.PUBLISH_NAME_FIRST_INITIAL], gmsPublishLocation: GmsPublishLocationType[GmsPublishLocationType.GMS_LOCATION_TYPE_APPROXIMATE], }, @@ -1316,7 +1317,7 @@ describe('UserResolver', () => { 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, }), ]) @@ -1332,8 +1333,7 @@ describe('UserResolver', () => { mutation: updateUserInfos, variables: { gmsAllowed: true, - gmsPublishName: - GmsPublishNameType[GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS], + gmsPublishName: PublishNameType[PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS], gmsLocation: loc, gmsPublishLocation: GmsPublishLocationType[GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM], @@ -1342,7 +1342,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, location: Location2Point(loc), gmsPublishLocation: GmsPublishLocationType.GMS_LOCATION_TYPE_RANDOM, }), diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 33a351730..056cf15c2 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' @@ -31,6 +32,8 @@ import { UserAdmin, SearchUsersResult } from '@model/UserAdmin' 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' @@ -157,11 +160,17 @@ 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(email) + } + if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) { dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID dbUser.password = encryptPassword(dbUser, password) @@ -183,7 +192,6 @@ export class UserResolver { dbUser.publisherId = publisherId await DbUser.save(dbUser) } - user.klickTipp = await getKlicktippState(dbUser.emailContact.email) context.setHeaders.push({ key: 'token', @@ -191,6 +199,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 } @@ -703,6 +717,26 @@ export class UserResolver { 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 + const humhubUser = await humhubClient.userByUsername(username) + 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/compareGmsRelevantUserSettings.ts b/backend/src/graphql/resolver/util/compareGmsRelevantUserSettings.ts index e40cdcdfe..4c9e51462 100644 --- a/backend/src/graphql/resolver/util/compareGmsRelevantUserSettings.ts +++ b/backend/src/graphql/resolver/util/compareGmsRelevantUserSettings.ts @@ -2,7 +2,7 @@ import { Point } from '@dbTools/typeorm' import { User as DbUser } from '@entity/User' import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs' -import { GmsPublishNameType } from '@/graphql/enum/GmsPublishNameType' +import { PublishNameType } from '@/graphql/enum/PublishNameType' import { LogError } from '@/server/LogError' import { backendLogger as logger } from '@/server/logger' @@ -22,11 +22,10 @@ export function compareGmsRelevantUserSettings( orgUser.alias !== updateUserInfosArgs.alias && ((updateUserInfosArgs.gmsPublishName && updateUserInfosArgs.gmsPublishName.valueOf === - GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS.valueOf) || + PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS.valueOf) || (!updateUserInfosArgs.gmsPublishName && orgUser.gmsPublishName && - orgUser.gmsPublishName.valueOf === - GmsPublishNameType.GMS_PUBLISH_NAME_ALIAS_OR_INITALS.valueOf)) + orgUser.gmsPublishName.valueOf === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS.valueOf)) ) { return true } diff --git a/backend/src/graphql/resolver/util/syncHumhub.test.ts b/backend/src/graphql/resolver/util/syncHumhub.test.ts index c7b187f23..c25eb52a8 100644 --- a/backend/src/graphql/resolver/util/syncHumhub.test.ts +++ b/backend/src/graphql/resolver/util/syncHumhub.test.ts @@ -3,7 +3,6 @@ import { UserContact } from '@entity/UserContact' import { HumHubClient } from '@/apis/humhub/HumHubClient' import { GetUser } from '@/apis/humhub/model/GetUser' -import { ExecutedHumhubAction } from '@/apis/humhub/syncUser' import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs' import { PublishNameType } from '@/graphql/enum/PublishNameType' import { backendLogger as logger } from '@/server/logger' @@ -22,19 +21,12 @@ const mockUpdateUserInfosArg = new UpdateUserInfosArgs() const mockHumHubUser = new GetUser(mockUser, 1) describe('syncHumhub', () => { - beforeAll(() => { - // humhubClientMockbBeforeAll() - }) - beforeEach(() => { jest.spyOn(logger, 'debug').mockImplementation() jest.spyOn(logger, 'info').mockImplementation() jest.spyOn(HumHubClient, 'getInstance') }) - afterEach(() => { - // humhubClientMockbAfterEach() - }) afterAll(() => { jest.resetAllMocks() }) @@ -43,7 +35,7 @@ describe('syncHumhub', () => { await syncHumhub(mockUpdateUserInfosArg, new User()) expect(HumHubClient.getInstance).not.toBeCalled() // language logging from some other place - expect(logger.debug).toBeCalledTimes(4) + expect(logger.debug).toBeCalledTimes(5) expect(logger.info).toBeCalledTimes(0) }) @@ -51,11 +43,11 @@ describe('syncHumhub', () => { mockUpdateUserInfosArg.firstName = 'New' // Relevant changes mockUser.firstName = 'New' await syncHumhub(mockUpdateUserInfosArg, mockUser) - expect(logger.debug).toHaveBeenCalledTimes(7) // Four language logging calls, two debug calls in function, one for not syncing + 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: ExecutedHumhubAction.UPDATE, + result: 'UPDATE', }) }) diff --git a/backend/src/graphql/resolver/util/syncHumhub.ts b/backend/src/graphql/resolver/util/syncHumhub.ts index 1ed1af7ab..73ec755ee 100644 --- a/backend/src/graphql/resolver/util/syncHumhub.ts +++ b/backend/src/graphql/resolver/util/syncHumhub.ts @@ -2,7 +2,7 @@ import { User } from '@entity/User' import { HumHubClient } from '@/apis/humhub/HumHubClient' import { GetUser } from '@/apis/humhub/model/GetUser' -import { syncUser } from '@/apis/humhub/syncUser' +import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser' import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs' import { backendLogger as logger } from '@/server/logger' @@ -12,13 +12,14 @@ export async function syncHumhub( ): Promise { // check for humhub relevant changes if ( - !updateUserInfosArg.alias && - !updateUserInfosArg.firstName && - !updateUserInfosArg.lastName && - !updateUserInfosArg.humhubAllowed && - !updateUserInfosArg.humhubPublishName && - !updateUserInfosArg.language + 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...') @@ -27,7 +28,7 @@ export async function syncHumhub( return } logger.debug('retrieve user from humhub') - const humhubUser = await humhubClient.userByEmail(user.emailContact.email) + const humhubUser = await humhubClient.userByUsername(user.alias ?? user.gradidoID) const humhubUsers = new Map() if (humhubUser) { humhubUsers.set(user.emailContact.email, humhubUser) @@ -37,6 +38,9 @@ export async function syncHumhub( logger.info('finished sync user with humhub', { localId: user.id, externId: humhubUser?.id, - result, + // 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/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index 22c402e65..d9618bd0c 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -35,7 +35,7 @@ export const updateUserInfos = gql` $hideAmountGDD: Boolean $hideAmountGDT: Boolean $gmsAllowed: Boolean - $gmsPublishName: GmsPublishNameType + $gmsPublishName: PublishNameType $gmsLocation: Location $gmsPublishLocation: GmsPublishLocationType ) { diff --git a/deployment/bare_metal/.env.dist b/deployment/bare_metal/.env.dist index 341bae554..3b7a19b6b 100644 --- a/deployment/bare_metal/.env.dist +++ b/deployment/bare_metal/.env.dist @@ -25,7 +25,7 @@ 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 +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 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/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/components/Menu/Sidebar.spec.js b/frontend/src/components/Menu/Sidebar.spec.js index 5629174ff..de11f1041 100644 --- a/frontend/src/components/Menu/Sidebar.spec.js +++ b/frontend/src/components/Menu/Sidebar.spec.js @@ -35,9 +35,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', () => { @@ -60,8 +60,12 @@ describe('Sidebar', () => { expect(wrapper.findAll('.nav-item').at(4).text()).toContain('navigation.info') }) - it('has nav-item "usersearch" in navbar', () => { - expect(wrapper.findAll('.nav-item').at(5).text()).toContain('navigation.usersearch') + 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 69925b110..ade0aad29 100644 --- a/frontend/src/components/Menu/Sidebar.vue +++ b/frontend/src/components/Menu/Sidebar.vue @@ -28,6 +28,10 @@ {{ $t('navigation.info') }} + + + {{ $t('navigation.circles') }} + {{ $t('navigation.usersearch') }} diff --git a/frontend/src/components/UserSettings/UserGMSNamingFormat.spec.js b/frontend/src/components/UserSettings/UserGMSNamingFormat.spec.js deleted file mode 100644 index e48f6baba..000000000 --- a/frontend/src/components/UserSettings/UserGMSNamingFormat.spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import { mount } from '@vue/test-utils' -import UserGMSNamingFormat from './UserGMSNamingFormat.vue' -import { toastErrorSpy } from '@test/testSetup' - -const mockAPIcall = jest.fn() - -const storeCommitMock = jest.fn() - -const localVue = global.localVue - -describe('UserNamingFormat', () => { - let wrapper - beforeEach(() => { - wrapper = mount(UserGMSNamingFormat, { - mocks: { - $t: (key) => key, // Mocking the translation function - $store: { - state: { - gmsPublishName: null, - }, - commit: storeCommitMock, - }, - $apollo: { - mutate: mockAPIcall, - }, - }, - localVue, - propsData: { - selectedOption: 'GMS_PUBLISH_NAME_ALIAS_OR_INITALS', - initialValue: 'GMS_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.GMS.publish-name.alias-or-initials', - 'settings.GMS.publish-name.initials', - 'settings.GMS.publish-name.first', - 'settings.GMS.publish-name.first-initial', - 'settings.GMS.publish-name.name-full', - ]) - }) - - it('updates selected option on click', async () => { - const dropdownItem = wrapper.findAll('.dropdown-item').at(3) // Click the fourth item - await dropdownItem.trigger('click') - - expect(wrapper.emitted().valueChanged).toBeTruthy() - expect(wrapper.emitted().valueChanged.length).toBe(1) - expect(wrapper.emitted().valueChanged[0]).toEqual(['GMS_PUBLISH_NAME_FIRST_INITIAL']) - }) - - it('does not update when clicking on already selected option', async () => { - const dropdownItem = wrapper.findAll('.dropdown-item').at(0) // Click the first item (which is already selected) - await dropdownItem.trigger('click') - - expect(wrapper.emitted().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/UserGMSNamingFormat.vue b/frontend/src/components/UserSettings/UserGMSNamingFormat.vue deleted file mode 100644 index 98e0911ed..000000000 --- a/frontend/src/components/UserSettings/UserGMSNamingFormat.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/frontend/src/components/UserSettings/UserNamingFormat.spec.js b/frontend/src/components/UserSettings/UserNamingFormat.spec.js index 8ded29ca7..c92cca138 100644 --- a/frontend/src/components/UserSettings/UserNamingFormat.spec.js +++ b/frontend/src/components/UserSettings/UserNamingFormat.spec.js @@ -26,9 +26,9 @@ describe('UserNamingFormat', () => { }, localVue, propsData: { - selectedOption: 'PUBLISH_NAME_NONE', - initialValue: 'PUBLISH_NAME_NONE', - attrName: 'publishName', + selectedOption: 'PUBLISH_NAME_ALIAS_OR_INITALS', + initialValue: 'PUBLISH_NAME_ALIAS_OR_INITALS', + attrName: 'gmsPublishName', successMessage: 'success message', }, }) @@ -40,17 +40,15 @@ describe('UserNamingFormat', () => { it('renders the correct dropdown options', () => { const dropdownItems = wrapper.findAll('.dropdown-item') - expect(dropdownItems.length).toBe(7) + expect(dropdownItems.length).toBe(5) const labels = dropdownItems.wrappers.map((item) => item.text()) expect(labels).toEqual([ - 'settings.publish-name.none', + 'settings.publish-name.alias-or-initials', 'settings.publish-name.initials', 'settings.publish-name.first', 'settings.publish-name.first-initial', - 'settings.publish-name.last', - 'settings.publish-name.last-initial', - 'settings.publish-name.full', + 'settings.publish-name.name-full', ]) }) diff --git a/frontend/src/components/UserSettings/UserNamingFormat.vue b/frontend/src/components/UserSettings/UserNamingFormat.vue index 658f276ca..e5c5740ab 100644 --- a/frontend/src/components/UserSettings/UserNamingFormat.vue +++ b/frontend/src/components/UserSettings/UserNamingFormat.vue @@ -20,7 +20,7 @@ import { updateUserInfos } from '@/graphql/mutations' export default { name: 'UserNamingFormat', props: { - initialValue: { type: String, default: 'PUBLISH_NAME_NONE' }, + initialValue: { type: String, default: 'PUBLISH_NAME_ALIAS_OR_INITALS' }, attrName: { type: String }, successMessage: { type: String }, }, @@ -29,9 +29,9 @@ export default { selectedOption: this.initialValue, dropdownOptions: [ { - label: this.$t('settings.publish-name.none'), - title: this.$t('settings.publish-name.none-tooltip'), - value: 'PUBLISH_NAME_NONE', + label: this.$t('settings.publish-name.alias-or-initials'), + title: this.$t('settings.publish-name.alias-or-initials-tooltip'), + value: 'PUBLISH_NAME_ALIAS_OR_INITALS', }, { label: this.$t('settings.publish-name.initials'), @@ -49,18 +49,8 @@ export default { value: 'PUBLISH_NAME_FIRST_INITIAL', }, { - label: this.$t('settings.publish-name.last'), - title: this.$t('settings.publish-name.last-tooltip'), - value: 'PUBLISH_NAME_LAST', - }, - { - label: this.$t('settings.publish-name.last-initial'), - title: this.$t('settings.publish-name.last-initial-tooltip'), - value: 'PUBLISH_NAME_INITIAL_LAST', - }, - { - label: this.$t('settings.publish-name.full'), - title: this.$t('settings.publish-name.full-tooltip'), + label: this.$t('settings.publish-name.name-full'), + title: this.$t('settings.publish-name.name-full-tooltip'), value: 'PUBLISH_NAME_FULL', }, ], @@ -68,7 +58,10 @@ export default { }, computed: { selectedOptionLabel() { - return this.dropdownOptions.find((option) => option.value === this.selectedOption).label + const selected = this.dropdownOptions.find((option) => option.value === this.selectedOption) + .label + return selected || this.$t('settings.publish-name.alias-or-initials') + // return this.dropdownOptions.find((option) => option.value === this.selectedOption).label }, }, methods: { diff --git a/frontend/src/components/UserSettings/UserSettingsSwitch.vue b/frontend/src/components/UserSettings/UserSettingsSwitch.vue index 8e819cba8..20878db86 100644 --- a/frontend/src/components/UserSettings/UserSettingsSwitch.vue +++ b/frontend/src/components/UserSettings/UserSettingsSwitch.vue @@ -1,9 +1,10 @@