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:
nitin
2024-11-28 18:13:11 +05:30
committed by GitHub
parent abe9185f48
commit e96ad9a1f2
38 changed files with 1197 additions and 232 deletions

View File

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

View File

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

View File

@ -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,
});
}
}
}

View File

@ -0,0 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class ImpersonateInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
userId: string;
}

View File

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

View File

@ -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[];
}

View File

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