Admin panel init (#8742)
WIP Related issues - #7090 #8547 Master issue - #4499 --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, Workspace, FeatureFlagEntity], 'core'),
|
||||
AuthModule,
|
||||
],
|
||||
providers: [AdminPanelResolver, AdminPanelService],
|
||||
exports: [AdminPanelService],
|
||||
})
|
||||
export class AdminPanelModule {}
|
||||
@ -0,0 +1,57 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
|
||||
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
|
||||
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
|
||||
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
|
||||
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
|
||||
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
export class AdminPanelResolver {
|
||||
constructor(private adminService: AdminPanelService) {}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@Mutation(() => Verify)
|
||||
async impersonate(
|
||||
@Args() impersonateInput: ImpersonateInput,
|
||||
@AuthUser() user: User,
|
||||
): Promise<Verify> {
|
||||
return await this.adminService.impersonate(impersonateInput.userId, user);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@Mutation(() => UserLookup)
|
||||
async userLookupAdminPanel(
|
||||
@Args() userLookupInput: UserLookupInput,
|
||||
@AuthUser() user: User,
|
||||
): Promise<UserLookup> {
|
||||
return await this.adminService.userLookup(
|
||||
userLookupInput.userIdentifier,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async updateWorkspaceFeatureFlag(
|
||||
@Args() updateFlagInput: UpdateWorkspaceFeatureFlagInput,
|
||||
@AuthUser() user: User,
|
||||
): Promise<boolean> {
|
||||
await this.adminService.updateWorkspaceFeatureFlags(
|
||||
updateFlagInput.workspaceId,
|
||||
updateFlagInput.featureFlag,
|
||||
user,
|
||||
updateFlagInput.value,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPanelService {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
) {}
|
||||
|
||||
async impersonate(userIdentifier: string, userImpersonating: User) {
|
||||
if (!userImpersonating.canImpersonate) {
|
||||
throw new AuthException(
|
||||
'User cannot impersonate',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const isEmail = userIdentifier.includes('@');
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: isEmail ? { email: userIdentifier } : { id: userIdentifier },
|
||||
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.defaultWorkspace.allowImpersonation) {
|
||||
throw new AuthException(
|
||||
'Impersonation not allowed',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async userLookup(
|
||||
userIdentifier: string,
|
||||
userImpersonating: User,
|
||||
): Promise<UserLookup> {
|
||||
if (!userImpersonating.canImpersonate) {
|
||||
throw new AuthException(
|
||||
'User cannot access user info',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const isEmail = userIdentifier.includes('@');
|
||||
|
||||
const targetUser = await this.userRepository.findOne({
|
||||
where: isEmail ? { email: userIdentifier } : { id: userIdentifier },
|
||||
relations: [
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceUsers',
|
||||
'workspaces.workspace.workspaceUsers.user',
|
||||
'workspaces.workspace.featureFlags',
|
||||
],
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: targetUser.id,
|
||||
email: targetUser.email,
|
||||
firstName: targetUser.firstName,
|
||||
lastName: targetUser.lastName,
|
||||
},
|
||||
workspaces: targetUser.workspaces.map((userWorkspace) => ({
|
||||
id: userWorkspace.workspace.id,
|
||||
name: userWorkspace.workspace.displayName ?? '',
|
||||
totalUsers: userWorkspace.workspace.workspaceUsers.length,
|
||||
logo: userWorkspace.workspace.logo,
|
||||
users: userWorkspace.workspace.workspaceUsers.map((workspaceUser) => ({
|
||||
id: workspaceUser.user.id,
|
||||
email: workspaceUser.user.email,
|
||||
firstName: workspaceUser.user.firstName,
|
||||
lastName: workspaceUser.user.lastName,
|
||||
})),
|
||||
featureFlags: allFeatureFlagKeys.map((key) => ({
|
||||
key,
|
||||
value:
|
||||
userWorkspace.workspace.featureFlags?.find(
|
||||
(flag) => flag.key === key,
|
||||
)?.value ?? false,
|
||||
})) as FeatureFlagEntity[],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async updateWorkspaceFeatureFlags(
|
||||
workspaceId: string,
|
||||
featureFlag: FeatureFlagKey,
|
||||
userImpersonating: User,
|
||||
value: boolean,
|
||||
) {
|
||||
if (!userImpersonating.canImpersonate) {
|
||||
throw new AuthException(
|
||||
'User cannot update feature flags',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
relations: ['featureFlags'],
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const existingFlag = workspace.featureFlags?.find(
|
||||
(flag) => flag.key === featureFlag,
|
||||
);
|
||||
|
||||
if (existingFlag) {
|
||||
await this.featureFlagRepository.update(existingFlag.id, { value });
|
||||
} else {
|
||||
await this.featureFlagRepository.save({
|
||||
key: featureFlag,
|
||||
value,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
@ArgsType()
|
||||
export class UpdateWorkspaceFeatureFlagInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
workspaceId: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
featureFlag: FeatureFlagKey;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@IsBoolean()
|
||||
value: boolean;
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
|
||||
@ObjectType()
|
||||
class UserInfo {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
email: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
firstName?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class WorkspaceInfo {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo?: string;
|
||||
|
||||
@Field(() => Number)
|
||||
totalUsers: number;
|
||||
|
||||
@Field(() => [UserInfo])
|
||||
users: UserInfo[];
|
||||
|
||||
@Field(() => [FeatureFlagEntity])
|
||||
featureFlags: FeatureFlagEntity[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserLookup {
|
||||
@Field(() => UserInfo)
|
||||
user: UserInfo;
|
||||
|
||||
@Field(() => [WorkspaceInfo])
|
||||
workspaces: WorkspaceInfo[];
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class UserLookupInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
userIdentifier: string;
|
||||
}
|
||||
@ -22,6 +22,7 @@ import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/sw
|
||||
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
@ -96,6 +97,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
MicrosoftAPIsService,
|
||||
AppTokenService,
|
||||
AccessTokenService,
|
||||
RefreshTokenService,
|
||||
LoginTokenService,
|
||||
ResetPasswordService,
|
||||
SwitchWorkspaceService,
|
||||
@ -103,6 +105,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
ApiKeyService,
|
||||
OAuthService,
|
||||
],
|
||||
exports: [AccessTokenService, LoginTokenService],
|
||||
exports: [AccessTokenService, LoginTokenService, RefreshTokenService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -38,7 +38,6 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { ChallengeInput } from './dto/challenge.input';
|
||||
import { ImpersonateInput } from './dto/impersonate.input';
|
||||
import { LoginToken } from './dto/login-token.entity';
|
||||
import { SignUpInput } from './dto/sign-up.input';
|
||||
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
|
||||
@ -228,15 +227,6 @@ export class AuthResolver {
|
||||
return { tokens: tokens };
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
@Mutation(() => Verify)
|
||||
async impersonate(
|
||||
@Args() impersonateInput: ImpersonateInput,
|
||||
@AuthUser() user: User,
|
||||
): Promise<Verify> {
|
||||
return await this.authService.impersonate(impersonateInput.userId, user);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Mutation(() => ApiKeyToken)
|
||||
async generateApiKeyToken(
|
||||
|
||||
@ -188,53 +188,6 @@ export class AuthService {
|
||||
return { isValid: !!workspace };
|
||||
}
|
||||
|
||||
async impersonate(userIdToImpersonate: string, userImpersonating: User) {
|
||||
if (!userImpersonating.canImpersonate) {
|
||||
throw new AuthException(
|
||||
'User cannot impersonate',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userIdToImpersonate,
|
||||
},
|
||||
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.defaultWorkspace.allowImpersonation) {
|
||||
throw new AuthException(
|
||||
'Impersonation not allowed',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async generateAuthorizationCode(
|
||||
authorizeAppInput: AuthorizeAppInput,
|
||||
user: User,
|
||||
|
||||
@ -3,6 +3,7 @@ import { HttpAdapterHost } from '@nestjs/core';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
@ -70,6 +71,7 @@ import { FileModule } from './file/file.module';
|
||||
WorkspaceEventEmitterModule,
|
||||
ActorModule,
|
||||
TelemetryModule,
|
||||
AdminPanelModule,
|
||||
EnvironmentModule.forRoot({}),
|
||||
RedisClientModule,
|
||||
FileStorageModule.forRootAsync({
|
||||
|
||||
Reference in New Issue
Block a user