feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656)
## Summary Add support for multi-workspace feature and adjust configurations and states accordingly. - Introduced new state isMultiWorkspaceEnabledState. - Updated ClientConfigProviderEffect component to handle multi-workspace. - Modified GraphQL schema and queries to include multi-workspace related configurations. - Adjusted server environment variables and their respective documentation to support multi-workspace toggle. - Updated server-side logic to handle new multi-workspace configurations and conditions.
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
@ -11,7 +11,6 @@ import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/g
|
||||
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
|
||||
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
|
||||
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
|
||||
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
|
||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
|
||||
@ -24,7 +23,6 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
|
||||
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';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
@ -36,13 +34,15 @@ import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/worksp
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@ -54,7 +54,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
JwtModule,
|
||||
FileUploadModule,
|
||||
DataSourceModule,
|
||||
forwardRef(() => UserModule),
|
||||
DomainManagerModule,
|
||||
TokenModule,
|
||||
UserModule,
|
||||
WorkspaceManagerModule,
|
||||
TypeORMModule,
|
||||
TypeOrmModule.forFeature(
|
||||
@ -69,22 +71,20 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
'core',
|
||||
),
|
||||
HttpModule,
|
||||
TokenModule,
|
||||
UserWorkspaceModule,
|
||||
WorkspaceModule,
|
||||
OnboardingModule,
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceInvitationModule,
|
||||
ConnectedAccountModule,
|
||||
WorkspaceSSOModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceInvitationModule,
|
||||
],
|
||||
controllers: [
|
||||
GoogleAuthController,
|
||||
MicrosoftAuthController,
|
||||
GoogleAPIsAuthController,
|
||||
MicrosoftAPIsAuthController,
|
||||
VerifyAuthController,
|
||||
SSOAuthController,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@ -7,6 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@ -43,6 +44,14 @@ describe('AuthResolver', () => {
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
buildWorkspaceURL: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new URL('http://localhost:3001')),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {},
|
||||
|
||||
@ -9,12 +9,6 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p
|
||||
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
|
||||
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
|
||||
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
|
||||
import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input';
|
||||
import {
|
||||
GenerateJWTOutput,
|
||||
GenerateJWTOutputWithAuthTokens,
|
||||
GenerateJWTOutputWithSSOAUTH,
|
||||
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
|
||||
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
|
||||
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
|
||||
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
|
||||
@ -36,12 +30,22 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
|
||||
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
import { ChallengeInput } from './dto/challenge.input';
|
||||
import { LoginToken } from './dto/login-token.entity';
|
||||
import { SignUpInput } from './dto/sign-up.input';
|
||||
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
|
||||
import { UserExists } from './dto/user-exists.entity';
|
||||
import { UserExistsOutput } from './dto/user-exists.entity';
|
||||
import { CheckUserExistsInput } from './dto/user-exists.input';
|
||||
import { Verify } from './dto/verify.entity';
|
||||
import { VerifyInput } from './dto/verify.input';
|
||||
@ -62,18 +66,15 @@ export class AuthResolver {
|
||||
private switchWorkspaceService: SwitchWorkspaceService,
|
||||
private transientTokenService: TransientTokenService,
|
||||
private oauthService: OAuthService,
|
||||
private domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@Query(() => UserExists)
|
||||
@Query(() => UserExistsOutput)
|
||||
async checkUserExists(
|
||||
@Args() checkUserExistsInput: CheckUserExistsInput,
|
||||
): Promise<UserExists> {
|
||||
const { exists } = await this.authService.checkUserExists(
|
||||
checkUserExistsInput.email,
|
||||
);
|
||||
|
||||
return { exists };
|
||||
): Promise<typeof UserExistsOutput> {
|
||||
return await this.authService.checkUserExists(checkUserExistsInput.email);
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceInviteHashValid)
|
||||
@ -96,8 +97,20 @@ export class AuthResolver {
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@Mutation(() => LoginToken)
|
||||
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
|
||||
const user = await this.authService.challenge(challengeInput);
|
||||
async challenge(
|
||||
@Args() challengeInput: ChallengeInput,
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<LoginToken> {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOrigin(origin);
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const user = await this.authService.challenge(challengeInput, workspace);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
@ -107,10 +120,22 @@ export class AuthResolver {
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@Mutation(() => LoginToken)
|
||||
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
|
||||
async signUp(
|
||||
@Args() signUpInput: SignUpInput,
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<LoginToken> {
|
||||
const user = await this.authService.signInUp({
|
||||
...signUpInput,
|
||||
targetWorkspaceSubdomain:
|
||||
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
|
||||
fromSSO: false,
|
||||
isAuthEnabled: workspaceValidator.isAuthEnabled(
|
||||
'password',
|
||||
new AuthException(
|
||||
'Password auth is not enabled for this workspace',
|
||||
AuthExceptionCode.OAUTH_ACCESS_DENIED,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
@ -124,11 +149,9 @@ export class AuthResolver {
|
||||
async exchangeAuthorizationCode(
|
||||
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
) {
|
||||
const tokens = await this.oauthService.verifyAuthorizationCode(
|
||||
return await this.oauthService.verifyAuthorizationCode(
|
||||
exchangeAuthCodeInput,
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@Mutation(() => TransientToken)
|
||||
@ -156,14 +179,18 @@ export class AuthResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Verify)
|
||||
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
|
||||
const email = await this.loginTokenService.verifyLoginToken(
|
||||
async verify(
|
||||
@Args() verifyInput: VerifyInput,
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<Verify> {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOrigin(origin);
|
||||
|
||||
const { sub: email } = await this.loginTokenService.verifyLoginToken(
|
||||
verifyInput.loginToken,
|
||||
);
|
||||
|
||||
const result = await this.authService.verify(email);
|
||||
|
||||
return result;
|
||||
return await this.authService.verify(email, workspace?.id);
|
||||
}
|
||||
|
||||
@Mutation(() => AuthorizeApp)
|
||||
@ -172,50 +199,22 @@ export class AuthResolver {
|
||||
@Args() authorizeAppInput: AuthorizeAppInput,
|
||||
@AuthUser() user: User,
|
||||
): Promise<AuthorizeApp> {
|
||||
const authorizedApp = await this.authService.generateAuthorizationCode(
|
||||
return await this.authService.generateAuthorizationCode(
|
||||
authorizeAppInput,
|
||||
user,
|
||||
);
|
||||
|
||||
return authorizedApp;
|
||||
}
|
||||
|
||||
@Mutation(() => GenerateJWTOutput)
|
||||
@Mutation(() => PublicWorkspaceDataOutput)
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||
async generateJWT(
|
||||
async switchWorkspace(
|
||||
@AuthUser() user: User,
|
||||
@Args() args: GenerateJwtInput,
|
||||
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
|
||||
const result = await this.switchWorkspaceService.switchWorkspace(
|
||||
@Args() args: SwitchWorkspaceInput,
|
||||
): Promise<PublicWorkspaceDataOutput> {
|
||||
return await this.switchWorkspaceService.switchWorkspace(
|
||||
user,
|
||||
args.workspaceId,
|
||||
);
|
||||
|
||||
if (result.useSSOAuth) {
|
||||
return {
|
||||
success: true,
|
||||
reason: 'WORKSPACE_USE_SSO_AUTH',
|
||||
availableSSOIDPs: result.availableSSOIdentityProviders.map(
|
||||
(identityProvider) => ({
|
||||
...identityProvider,
|
||||
workspace: {
|
||||
id: result.workspace.id,
|
||||
displayName: result.workspace.displayName,
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
|
||||
authTokens:
|
||||
await this.switchWorkspaceService.generateSwitchWorkspaceToken(
|
||||
user,
|
||||
result.workspace,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => AuthTokens)
|
||||
@ -278,4 +277,11 @@ export class AuthResolver {
|
||||
args.passwordResetToken,
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => [AvailableWorkspaceOutput])
|
||||
async findAvailableWorkspacesByEmail(
|
||||
@Args('email') email: string,
|
||||
): Promise<AvailableWorkspaceOutput[]> {
|
||||
return this.authService.findAvailableWorkspacesByEmail(email);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
@ -21,6 +23,8 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
|
||||
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Controller('auth/google-apis')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -30,6 +34,9 @@ export class GoogleAPIsAuthController {
|
||||
private readonly transientTokenService: TransientTokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -96,10 +103,24 @@ export class GoogleAPIsAuthController {
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: workspaceId,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
`${this.environmentService.get('FRONT_BASE_URL')}${
|
||||
redirectLocation || '/settings/accounts'
|
||||
}`,
|
||||
this.domainManagerService
|
||||
.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
pathname: redirectLocation || '/settings/accounts',
|
||||
})
|
||||
.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,9 @@ import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
|
||||
@ -16,6 +18,14 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Controller('auth/google')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -23,6 +33,10 @@ export class GoogleAuthController {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -36,29 +50,81 @@ export class GoogleAuthController {
|
||||
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
|
||||
@UseFilters(AuthOAuthExceptionFilter)
|
||||
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
} = req.user;
|
||||
try {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain,
|
||||
} = req.user;
|
||||
|
||||
const user = await this.authService.signInUp({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
fromSSO: true,
|
||||
});
|
||||
const signInUpParams = {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain,
|
||||
fromSSO: true,
|
||||
isAuthEnabled: workspaceValidator.isAuthEnabled(
|
||||
'google',
|
||||
new AuthException(
|
||||
'Google auth is not enabled for this workspace',
|
||||
AuthExceptionCode.OAUTH_ACCESS_DENIED,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
if (
|
||||
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
|
||||
targetWorkspaceSubdomain ===
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN')
|
||||
) {
|
||||
const workspaceWithGoogleAuthActive =
|
||||
await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
isGoogleAuthEnabled: true,
|
||||
workspaceUsers: {
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
},
|
||||
relations: ['userWorkspaces', 'userWorkspaces.user'],
|
||||
});
|
||||
|
||||
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
|
||||
if (workspaceWithGoogleAuthActive) {
|
||||
signInUpParams.targetWorkspaceSubdomain =
|
||||
workspaceWithGoogleAuthActive.subdomain;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.authService.signInUp(signInUpParams);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return res.redirect(
|
||||
await this.authService.computeRedirectURI(
|
||||
loginToken.token,
|
||||
user.defaultWorkspace.subdomain,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthException) {
|
||||
return res.redirect(
|
||||
this.domainManagerService.computeRedirectErrorUrl({
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
errorMessage: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
@ -21,6 +23,9 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
|
||||
import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Controller('auth/microsoft-apis')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -29,7 +34,11 @@ export class MicrosoftAPIsAuthController {
|
||||
private readonly microsoftAPIsService: MicrosoftAPIsService,
|
||||
private readonly transientTokenService: TransientTokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -96,10 +105,24 @@ export class MicrosoftAPIsAuthController {
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: workspaceId,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
`${this.environmentService.get('FRONT_BASE_URL')}${
|
||||
redirectLocation || '/settings/accounts'
|
||||
}`,
|
||||
this.domainManagerService
|
||||
.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
pathname: redirectLocation || '/settings/accounts',
|
||||
})
|
||||
.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,13 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Controller('auth/microsoft')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -22,6 +29,8 @@ export class MicrosoftAuthController {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -37,29 +46,55 @@ export class MicrosoftAuthController {
|
||||
@Req() req: MicrosoftRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
} = req.user;
|
||||
try {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain,
|
||||
} = req.user;
|
||||
|
||||
const user = await this.authService.signInUp({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
fromSSO: true,
|
||||
});
|
||||
const user = await this.authService.signInUp({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain,
|
||||
fromSSO: true,
|
||||
isAuthEnabled: workspaceValidator.isAuthEnabled(
|
||||
'microsoft',
|
||||
new AuthException(
|
||||
'Microsoft auth is not enabled for this workspace',
|
||||
AuthExceptionCode.OAUTH_ACCESS_DENIED,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
|
||||
return res.redirect(
|
||||
await this.authService.computeRedirectURI(
|
||||
loginToken.token,
|
||||
user.defaultWorkspace.subdomain,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthException) {
|
||||
return res.redirect(
|
||||
this.domainManagerService.computeRedirectErrorUrl({
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
errorMessage: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,14 +25,14 @@ import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.gua
|
||||
import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import {
|
||||
IdentityProviderType,
|
||||
WorkspaceSSOIdentityProvider,
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
@Controller('auth')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
@ -40,9 +40,9 @@ export class SSOAuthController {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly ssoService: SSOService,
|
||||
@InjectRepository(WorkspaceSSOIdentityProvider, 'core')
|
||||
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
|
||||
@ -50,7 +50,7 @@ export class SSOAuthController {
|
||||
|
||||
@Get('saml/metadata/:identityProviderId')
|
||||
@UseGuards(SSOProviderEnabledGuard)
|
||||
async generateMetadata(@Req() req: any): Promise<string> {
|
||||
async generateMetadata(@Req() req: any): Promise<string | void> {
|
||||
return generateServiceProviderMetadata({
|
||||
wantAssertionsSigned: false,
|
||||
issuer: this.ssoService.buildIssuerURL({
|
||||
@ -81,14 +81,26 @@ export class SSOAuthController {
|
||||
@UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard)
|
||||
async oidcAuthCallback(@Req() req: any, @Res() res: Response) {
|
||||
try {
|
||||
const loginToken = await this.generateLoginToken(req.user);
|
||||
const { loginToken, identityProvider } = await this.generateLoginToken(
|
||||
req.user,
|
||||
);
|
||||
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI(loginToken.token),
|
||||
await this.authService.computeRedirectURI(
|
||||
loginToken.token,
|
||||
identityProvider.workspace.subdomain,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// TODO: improve error management
|
||||
res.status(403).send(err.message);
|
||||
if (err instanceof AuthException) {
|
||||
return res.redirect(
|
||||
this.domainManagerService.computeRedirectErrorUrl({
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
errorMessage: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,16 +108,26 @@ export class SSOAuthController {
|
||||
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
|
||||
async samlAuthCallback(@Req() req: any, @Res() res: Response) {
|
||||
try {
|
||||
const loginToken = await this.generateLoginToken(req.user);
|
||||
const { loginToken, identityProvider } = await this.generateLoginToken(
|
||||
req.user,
|
||||
);
|
||||
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI(loginToken.token),
|
||||
await this.authService.computeRedirectURI(
|
||||
loginToken.token,
|
||||
identityProvider.workspace.subdomain,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// TODO: improve error management
|
||||
res
|
||||
.status(403)
|
||||
.redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`);
|
||||
if (err instanceof AuthException) {
|
||||
return res.redirect(
|
||||
this.domainManagerService.computeRedirectErrorUrl({
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
errorMessage: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,6 +138,13 @@ export class SSOAuthController {
|
||||
identityProviderId?: string;
|
||||
user: { email: string } & Record<string, string>;
|
||||
}) {
|
||||
if (!identityProviderId) {
|
||||
throw new AuthException(
|
||||
'Identity provider ID is required',
|
||||
AuthExceptionCode.INVALID_DATA,
|
||||
);
|
||||
}
|
||||
|
||||
const identityProvider =
|
||||
await this.workspaceSSOIdentityProviderRepository.findOne({
|
||||
where: { id: identityProviderId },
|
||||
@ -129,20 +158,15 @@ export class SSOAuthController {
|
||||
);
|
||||
}
|
||||
|
||||
const invitation =
|
||||
await this.workspaceInvitationService.getOneWorkspaceInvitation(
|
||||
identityProvider.workspaceId,
|
||||
user.email,
|
||||
);
|
||||
|
||||
if (invitation) {
|
||||
await this.authService.signInUp({
|
||||
...user,
|
||||
workspacePersonalInviteToken: invitation.value,
|
||||
workspaceInviteHash: identityProvider.workspace.inviteHash,
|
||||
fromSSO: true,
|
||||
});
|
||||
}
|
||||
await this.authService.signInUp({
|
||||
...user,
|
||||
...(this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
|
||||
? {
|
||||
targetWorkspaceSubdomain: identityProvider.workspace.subdomain,
|
||||
}
|
||||
: {}),
|
||||
fromSSO: true,
|
||||
});
|
||||
|
||||
const isUserExistInWorkspace =
|
||||
await this.userWorkspaceService.checkUserWorkspaceExistsByEmail(
|
||||
@ -157,6 +181,9 @@ export class SSOAuthController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.loginTokenService.generateLoginToken(user.email);
|
||||
return {
|
||||
identityProvider,
|
||||
loginToken: await this.loginTokenService.generateLoginToken(user.email),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
import { VerifyAuthController } from './verify-auth.controller';
|
||||
|
||||
describe('VerifyAuthController', () => {
|
||||
let controller: VerifyAuthController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [VerifyAuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<VerifyAuthController>(VerifyAuthController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -1,26 +0,0 @@
|
||||
import { Body, Controller, Post, UseFilters } from '@nestjs/common';
|
||||
|
||||
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
|
||||
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
@Controller('auth/verify')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class VerifyAuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
|
||||
const email = await this.loginTokenService.verifyLoginToken(
|
||||
verifyInput.loginToken,
|
||||
);
|
||||
const result = await this.authService.verify(email);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
|
||||
import {
|
||||
IdentityProviderType,
|
||||
SSOIdentityProviderStatus,
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
|
||||
@ObjectType()
|
||||
class SSOConnection {
|
||||
@Field(() => IdentityProviderType)
|
||||
type: SSOConfiguration['type'];
|
||||
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
issuer: string;
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
|
||||
@Field(() => SSOIdentityProviderStatus)
|
||||
status: SSOConfiguration['status'];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AvailableWorkspaceOutput {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName?: string;
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo?: string;
|
||||
|
||||
@Field(() => [SSOConnection])
|
||||
sso: SSOConnection[];
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
|
||||
|
||||
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
|
||||
|
||||
@ObjectType()
|
||||
export class GenerateJWTOutputWithAuthTokens {
|
||||
@Field(() => Boolean)
|
||||
success: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH';
|
||||
|
||||
@Field(() => AuthTokens)
|
||||
authTokens: AuthTokens;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class GenerateJWTOutputWithSSOAUTH {
|
||||
@Field(() => Boolean)
|
||||
success: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
reason: 'WORKSPACE_USE_SSO_AUTH';
|
||||
|
||||
@Field(() => [FindAvailableSSOIDPOutput])
|
||||
availableSSOIDPs: Array<FindAvailableSSOIDPOutput>;
|
||||
}
|
||||
|
||||
export const GenerateJWTOutput = createUnionType({
|
||||
name: 'GenerateJWT',
|
||||
types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH],
|
||||
resolveType(value) {
|
||||
if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') {
|
||||
return GenerateJWTOutputWithAuthTokens;
|
||||
}
|
||||
if (value.reason === 'WORKSPACE_USE_SSO_AUTH') {
|
||||
return GenerateJWTOutputWithSSOAUTH;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class GenerateJwtInput {
|
||||
export class SwitchWorkspaceInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ -1,7 +1,30 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
|
||||
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
|
||||
@ObjectType()
|
||||
export class UserExists {
|
||||
@Field(() => Boolean)
|
||||
exists: boolean;
|
||||
exists: true;
|
||||
|
||||
@Field(() => [AvailableWorkspaceOutput])
|
||||
availableWorkspaces: Array<AvailableWorkspaceOutput>;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserNotExists {
|
||||
@Field(() => Boolean)
|
||||
exists: false;
|
||||
}
|
||||
|
||||
export const UserExistsOutput = createUnionType({
|
||||
name: 'UserExistsOutput',
|
||||
types: () => [UserExists, UserNotExists] as const,
|
||||
resolveType(value) {
|
||||
if (value.exists === true) {
|
||||
return UserExists;
|
||||
}
|
||||
|
||||
return UserNotExists;
|
||||
},
|
||||
});
|
||||
|
||||
@ -11,11 +11,11 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Catch(AuthException)
|
||||
export class AuthOAuthExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
constructor(private readonly domainManagerService: DomainManagerService) {}
|
||||
|
||||
catch(exception: AuthException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
@ -25,7 +25,7 @@ export class AuthOAuthExceptionFilter implements ExceptionFilter {
|
||||
case AuthExceptionCode.OAUTH_ACCESS_DENIED:
|
||||
response
|
||||
.status(403)
|
||||
.redirect(this.environmentService.get('FRONT_BASE_URL'));
|
||||
.redirect(this.domainManagerService.getBaseUrl().toString());
|
||||
break;
|
||||
default:
|
||||
throw new InternalServerErrorException(exception.message);
|
||||
|
||||
@ -38,6 +38,13 @@ export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
workspacePersonalInviteToken;
|
||||
}
|
||||
|
||||
if (
|
||||
request.query.workspaceSubdomain &&
|
||||
typeof request.query.workspaceSubdomain === 'string'
|
||||
) {
|
||||
request.params.workspaceSubdomain = request.query.workspaceSubdomain;
|
||||
}
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,13 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
workspacePersonalInviteToken;
|
||||
}
|
||||
|
||||
if (
|
||||
request.query.workspaceSubdomain &&
|
||||
typeof request.query.workspaceSubdomain === 'string'
|
||||
) {
|
||||
request.params.workspaceSubdomain = request.query.workspaceSubdomain;
|
||||
}
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,34 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import { expect, jest } from '@jest/globals';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
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 { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
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 { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
jest.mock('bcrypt');
|
||||
|
||||
const UserFindOneMock = jest.fn();
|
||||
const UserWorkspaceFindOneByMock = jest.fn();
|
||||
|
||||
const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn();
|
||||
const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
|
||||
const workspaceInvitationValidateInvitationMock = jest.fn();
|
||||
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
@ -25,7 +42,9 @@ describe('AuthService', () => {
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useValue: {},
|
||||
useValue: {
|
||||
findOne: UserFindOneMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
@ -39,6 +58,10 @@ describe('AuthService', () => {
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
@ -51,13 +74,114 @@ describe('AuthService', () => {
|
||||
provide: RefreshTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {
|
||||
checkUserWorkspaceExists:
|
||||
userWorkspaceServiceCheckUserWorkspaceExistsMock,
|
||||
addUserToWorkspace: userWorkspaceAddUserToWorkspaceMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceInvitationService,
|
||||
useValue: {
|
||||
getOneWorkspaceInvitation:
|
||||
workspaceInvitationGetOneWorkspaceInvitationMock,
|
||||
validateInvitation: workspaceInvitationValidateInvitationMock,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
it('should be defined', async () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('challenge - user already member of workspace', async () => {
|
||||
const workspace = { isPasswordAuthEnabled: true } as Workspace;
|
||||
const user = {
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
captchaToken: 'captchaToken',
|
||||
};
|
||||
|
||||
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
|
||||
UserWorkspaceFindOneByMock.mockReturnValueOnce({});
|
||||
|
||||
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({});
|
||||
|
||||
const response = await service.challenge(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
captchaToken: 'captchaToken',
|
||||
},
|
||||
workspace,
|
||||
);
|
||||
|
||||
expect(response).toStrictEqual({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('challenge - user who have an invitation', async () => {
|
||||
const user = {
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
captchaToken: 'captchaToken',
|
||||
};
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
|
||||
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
|
||||
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false);
|
||||
|
||||
workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({});
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({});
|
||||
userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({});
|
||||
|
||||
const response = await service.challenge(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
captchaToken: 'captchaToken',
|
||||
},
|
||||
{
|
||||
isPasswordAuthEnabled: true,
|
||||
} as Workspace,
|
||||
);
|
||||
|
||||
expect(response).toStrictEqual({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
|
||||
expect(
|
||||
workspaceInvitationGetOneWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(workspaceInvitationValidateInvitationMock).toHaveBeenCalledTimes(1);
|
||||
expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(UserFindOneMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -28,7 +28,10 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent
|
||||
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
|
||||
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
|
||||
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
|
||||
import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity';
|
||||
import {
|
||||
UserExists,
|
||||
UserNotExists,
|
||||
} from 'src/engine/core-modules/auth/dto/user-exists.entity';
|
||||
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
|
||||
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
@ -38,12 +41,24 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly userService: UserService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly signInUpService: SignInUpService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@ -55,9 +70,54 @@ export class AuthService {
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
) {}
|
||||
|
||||
async challenge(challengeInput: ChallengeInput) {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email: challengeInput.email,
|
||||
private async checkAccessAndUseInvitationOrThrow(
|
||||
workspace: Workspace,
|
||||
user: User,
|
||||
) {
|
||||
if (
|
||||
await this.userWorkspaceService.checkUserWorkspaceExists(
|
||||
user.id,
|
||||
workspace.id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invitation =
|
||||
await this.workspaceInvitationService.getOneWorkspaceInvitation(
|
||||
workspace.id,
|
||||
user.email,
|
||||
);
|
||||
|
||||
if (invitation) {
|
||||
await this.workspaceInvitationService.validateInvitation({
|
||||
workspacePersonalInviteToken: invitation.value,
|
||||
email: user.email,
|
||||
});
|
||||
await this.userWorkspaceService.addUserToWorkspace(user, workspace);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AuthException(
|
||||
"You're not member of this workspace.",
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
async challenge(challengeInput: ChallengeInput, targetWorkspace: Workspace) {
|
||||
if (!targetWorkspace.isPasswordAuthEnabled) {
|
||||
throw new AuthException(
|
||||
'Email/Password auth is not enabled for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: challengeInput.email,
|
||||
},
|
||||
relations: ['workspaces'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@ -67,6 +127,8 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new AuthException(
|
||||
'Incorrect login method',
|
||||
@ -94,19 +156,23 @@ export class AuthService {
|
||||
password,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
fromSSO,
|
||||
isAuthEnabled,
|
||||
}: {
|
||||
email: string;
|
||||
password?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
workspaceInviteHash?: string | null;
|
||||
workspacePersonalInviteToken?: string | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
picture?: string | null;
|
||||
fromSSO: boolean;
|
||||
targetWorkspaceSubdomain?: string;
|
||||
isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>;
|
||||
}) {
|
||||
return await this.signInUpService.signInUp({
|
||||
email,
|
||||
@ -115,12 +181,14 @@ export class AuthService {
|
||||
lastName,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain,
|
||||
picture,
|
||||
fromSSO,
|
||||
isAuthEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async verify(email: string): Promise<Verify> {
|
||||
async verify(email: string, workspaceId?: string): Promise<Verify> {
|
||||
if (!email) {
|
||||
throw new AuthException(
|
||||
'Email is required',
|
||||
@ -128,6 +196,26 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
const userWithIdAndDefaultWorkspaceId = await this.userRepository.findOne({
|
||||
select: ['defaultWorkspaceId', 'id'],
|
||||
where: { email },
|
||||
});
|
||||
|
||||
userValidator.assertIsExist(
|
||||
userWithIdAndDefaultWorkspaceId,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
if (
|
||||
workspaceId &&
|
||||
userWithIdAndDefaultWorkspaceId.defaultWorkspaceId !== workspaceId
|
||||
) {
|
||||
await this.userService.saveDefaultWorkspace(
|
||||
userWithIdAndDefaultWorkspaceId.id,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
email,
|
||||
@ -135,19 +223,10 @@ export class AuthService {
|
||||
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.defaultWorkspace) {
|
||||
throw new AuthException(
|
||||
'User has no default workspace',
|
||||
AuthExceptionCode.INVALID_DATA,
|
||||
);
|
||||
}
|
||||
userValidator.assertIsExist(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
// passwordHash is hidden for security reasons
|
||||
user.passwordHash = '';
|
||||
@ -170,12 +249,19 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async checkUserExists(email: string): Promise<UserExists> {
|
||||
async checkUserExists(email: string): Promise<UserExists | UserNotExists> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
|
||||
return { exists: !!user };
|
||||
if (userValidator.isExist(user)) {
|
||||
return {
|
||||
exists: true,
|
||||
availableWorkspaces: await this.findAvailableWorkspacesByEmail(email),
|
||||
};
|
||||
}
|
||||
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
async checkWorkspaceInviteHashIsValid(
|
||||
@ -312,7 +398,7 @@ export class AuthService {
|
||||
const emailTemplate = PasswordUpdateNotifyEmail({
|
||||
userName: `${user.firstName} ${user.lastName}`,
|
||||
email: user.email,
|
||||
link: this.environmentService.get('FRONT_BASE_URL'),
|
||||
link: this.domainManagerService.getBaseUrl().toString(),
|
||||
});
|
||||
|
||||
const html = render(emailTemplate, {
|
||||
@ -352,9 +438,55 @@ export class AuthService {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
computeRedirectURI(loginToken: string): string {
|
||||
return `${this.environmentService.get(
|
||||
'FRONT_BASE_URL',
|
||||
)}/verify?loginToken=${loginToken}`;
|
||||
async computeRedirectURI(loginToken: string, subdomain?: string) {
|
||||
const url = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain,
|
||||
pathname: '/verify',
|
||||
searchParams: { loginToken },
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async findAvailableWorkspacesByEmail(email: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
relations: [
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceSSOIdentityProviders',
|
||||
],
|
||||
});
|
||||
|
||||
userValidator.assertIsExist(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
|
||||
id: userWorkspace.workspaceId,
|
||||
displayName: userWorkspace.workspace.displayName,
|
||||
subdomain: userWorkspace.workspace.subdomain,
|
||||
logo: userWorkspace.workspace.logo,
|
||||
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
|
||||
(acc, identityProvider) =>
|
||||
acc.concat(
|
||||
identityProvider.status === 'Inactive'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: identityProvider.id,
|
||||
name: identityProvider.name,
|
||||
issuer: identityProvider.issuer,
|
||||
type: identityProvider.type,
|
||||
status: identityProvider.status,
|
||||
},
|
||||
],
|
||||
),
|
||||
[] as AvailableWorkspaceOutput['sso'],
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
import { ResetPasswordService } from './reset-password.service';
|
||||
|
||||
@ -45,6 +46,14 @@ describe('ResetPasswordService', () => {
|
||||
send: jest.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
getBaseUrl: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new URL('http://localhost:3001')),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
|
||||
@ -24,11 +24,13 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class ResetPasswordService {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
@ -116,11 +118,12 @@ export class ResetPasswordService {
|
||||
);
|
||||
}
|
||||
|
||||
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
|
||||
const frontBaseURL = this.domainManagerService.getBaseUrl();
|
||||
|
||||
frontBaseURL.pathname = `/reset-password/${resetToken.passwordResetToken}`;
|
||||
|
||||
const emailData = {
|
||||
link: resetLink,
|
||||
link: frontBaseURL.toString(),
|
||||
duration: ms(
|
||||
differenceInMilliseconds(
|
||||
resetToken.passwordResetTokenExpiresAt,
|
||||
|
||||
@ -2,18 +2,45 @@ import { HttpService } from '@nestjs/axios';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import { expect, jest } from '@jest/globals';
|
||||
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
Workspace,
|
||||
WorkspaceActivationStatus,
|
||||
} from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
|
||||
jest.mock('bcrypt');
|
||||
|
||||
const UserFindOneMock = jest.fn();
|
||||
const workspaceInvitationValidateInvitationMock = jest.fn();
|
||||
const workspaceInvitationInvalidateWorkspaceInvitationMock = jest.fn();
|
||||
const workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock =
|
||||
jest.fn();
|
||||
const userWorkspaceServiceAddUserToWorkspaceMock = jest.fn();
|
||||
const UserCreateMock = jest.fn();
|
||||
const UserSaveMock = jest.fn();
|
||||
const EnvironmentServiceGetMock = jest.fn();
|
||||
const WorkspaceCountMock = jest.fn();
|
||||
const WorkspaceCreateMock = jest.fn();
|
||||
const WorkspaceSaveMock = jest.fn();
|
||||
|
||||
describe('SignInUpService', () => {
|
||||
let service: SignInUpService;
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@ -24,23 +51,48 @@ describe('SignInUpService', () => {
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useValue: {},
|
||||
useValue: {
|
||||
count: WorkspaceCountMock,
|
||||
create: WorkspaceCreateMock,
|
||||
save: WorkspaceSaveMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useValue: {},
|
||||
useValue: {
|
||||
findOne: UserFindOneMock,
|
||||
create: UserCreateMock,
|
||||
save: UserSaveMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
provide: WorkspaceInvitationService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceService,
|
||||
useValue: {
|
||||
generateSubdomain: jest.fn().mockReturnValue('tartanpion'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {
|
||||
addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock,
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OnboardingService,
|
||||
useValue: {},
|
||||
useValue: {
|
||||
setOnboardingConnectAccountPending: jest.fn(),
|
||||
setOnboardingInviteTeamPending: jest.fn(),
|
||||
setOnboardingCreateProfilePending: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: HttpService,
|
||||
@ -48,7 +100,19 @@ describe('SignInUpService', () => {
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
useValue: {
|
||||
get: EnvironmentServiceGetMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceInvitationService,
|
||||
useValue: {
|
||||
validateInvitation: workspaceInvitationValidateInvitationMock,
|
||||
invalidateWorkspaceInvitation:
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
findInvitationByWorkspaceSubdomainAndUserEmail:
|
||||
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
@ -59,4 +123,363 @@ describe('SignInUpService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('signInUp - sso - new user', async () => {
|
||||
const email = 'test@test.com';
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(false);
|
||||
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const spy = jest
|
||||
.spyOn(service, 'signUpOnNewWorkspace')
|
||||
.mockResolvedValueOnce({} as User);
|
||||
|
||||
await service.signInUp({
|
||||
email: 'test@test.com',
|
||||
fromSSO: true,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email,
|
||||
passwordHash: undefined,
|
||||
firstName: expect.any(String),
|
||||
lastName: expect.any(String),
|
||||
picture: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('signInUp - sso - existing user', async () => {
|
||||
const email = 'existing@test.com';
|
||||
const existingUser = {
|
||||
id: 'user-id',
|
||||
email,
|
||||
passwordHash: undefined,
|
||||
defaultWorkspace: { id: 'workspace-id' },
|
||||
};
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(existingUser);
|
||||
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const result = await service.signInUp({
|
||||
email,
|
||||
fromSSO: true,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(result).toEqual(existingUser);
|
||||
});
|
||||
it('signInUp - sso - new user - existing invitation', async () => {
|
||||
const email = 'newuser@test.com';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(null);
|
||||
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
|
||||
{
|
||||
value: 'personal-token-value',
|
||||
},
|
||||
);
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
|
||||
isValid: true,
|
||||
workspace: { id: workspaceId },
|
||||
});
|
||||
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
const spySignInUpOnExistingWorkspace = jest
|
||||
.spyOn(service, 'signInUpOnExistingWorkspace')
|
||||
.mockResolvedValueOnce(
|
||||
{} as Awaited<
|
||||
ReturnType<(typeof service)['signInUpOnExistingWorkspace']>
|
||||
>,
|
||||
);
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
fromSSO: true,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email,
|
||||
passwordHash: undefined,
|
||||
workspace: expect.objectContaining({
|
||||
id: workspaceId,
|
||||
}),
|
||||
firstName: expect.any(String),
|
||||
lastName: expect.any(String),
|
||||
picture: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledWith(workspaceId, email);
|
||||
});
|
||||
it('signInUp - sso - existing user - existing invitation', async () => {
|
||||
const email = 'existinguser@test.com';
|
||||
const workspaceId = 'workspace-id';
|
||||
const existingUser = {
|
||||
id: 'user-id',
|
||||
email,
|
||||
passwordHash: undefined,
|
||||
defaultWorkspace: { id: 'workspace-id' },
|
||||
};
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(existingUser);
|
||||
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
|
||||
{
|
||||
value: 'personal-token-value',
|
||||
},
|
||||
);
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
|
||||
isValid: true,
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({});
|
||||
|
||||
const result = await service.signInUp({
|
||||
email,
|
||||
fromSSO: true,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(result).toEqual(existingUser);
|
||||
expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledWith(workspaceId, email);
|
||||
});
|
||||
it('signInUp - sso - new user - personal invitation token', async () => {
|
||||
const email = 'newuser@test.com';
|
||||
const workspaceId = 'workspace-id';
|
||||
const workspacePersonalInviteToken = 'personal-token-value';
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(null);
|
||||
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
|
||||
isValid: true,
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
const spySignInUpOnExistingWorkspace = jest
|
||||
.spyOn(service, 'signInUpOnExistingWorkspace')
|
||||
.mockResolvedValueOnce(
|
||||
{} as Awaited<
|
||||
ReturnType<(typeof service)['signInUpOnExistingWorkspace']>
|
||||
>,
|
||||
);
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
fromSSO: true,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email,
|
||||
passwordHash: undefined,
|
||||
workspace: expect.objectContaining({
|
||||
id: workspaceId,
|
||||
}),
|
||||
firstName: expect.any(String),
|
||||
lastName: expect.any(String),
|
||||
picture: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledWith(workspaceId, email);
|
||||
});
|
||||
it('signInUp - sso - existing user - personal invitation token', async () => {
|
||||
const email = 'existinguser@test.com';
|
||||
const workspaceId = 'workspace-id';
|
||||
const workspacePersonalInviteToken = 'personal-token-value';
|
||||
const existingUser = {
|
||||
id: 'user-id',
|
||||
email,
|
||||
passwordHash: undefined,
|
||||
defaultWorkspace: { id: 'workspace-id' },
|
||||
};
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(existingUser);
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
|
||||
isValid: true,
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
fromSSO: true,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledWith(workspaceId, email);
|
||||
});
|
||||
it('signInUp - credentials - existing user', async () => {
|
||||
const email = 'existinguser@test.com';
|
||||
const password = 'validPassword123';
|
||||
const existingUser = {
|
||||
id: 'user-id',
|
||||
email,
|
||||
passwordHash: 'hash-of-validPassword123',
|
||||
defaultWorkspace: { id: 'workspace-id' },
|
||||
};
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(existingUser);
|
||||
|
||||
EnvironmentServiceGetMock.mockReturnValueOnce(false);
|
||||
|
||||
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
password,
|
||||
fromSSO: false,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
it('signInUp - credentials - new user', async () => {
|
||||
const email = 'newuser@test.com';
|
||||
const password = 'validPassword123';
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(null);
|
||||
|
||||
UserCreateMock.mockReturnValueOnce({} as User);
|
||||
UserSaveMock.mockReturnValueOnce({} as User);
|
||||
|
||||
EnvironmentServiceGetMock.mockReturnValueOnce(true);
|
||||
|
||||
WorkspaceCreateMock.mockReturnValueOnce({});
|
||||
WorkspaceSaveMock.mockReturnValueOnce({});
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
password,
|
||||
fromSSO: false,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(UserCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(UserSaveMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(WorkspaceSaveMock).toHaveBeenCalledTimes(1);
|
||||
expect(WorkspaceCreateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('signInUp - credentials - new user - personal invitation token', async () => {
|
||||
const email = 'newuser@test.com';
|
||||
const password = 'validPassword123';
|
||||
const workspaceId = 'workspace-id';
|
||||
const workspacePersonalInviteToken = 'personal-token-value';
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(null);
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
|
||||
isValid: true,
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
UserCreateMock.mockReturnValueOnce({} as User);
|
||||
UserSaveMock.mockReturnValueOnce({} as User);
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
password,
|
||||
fromSSO: false,
|
||||
workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain: 'tartanpion',
|
||||
});
|
||||
|
||||
expect(UserCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(UserSaveMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledWith(workspaceId, email);
|
||||
});
|
||||
it('signInUp - credentials - new user - public invitation token', async () => {
|
||||
const email = 'newuser@test.com';
|
||||
const password = 'validPassword123';
|
||||
const workspaceId = 'workspace-id';
|
||||
const workspaceInviteHash = 'public-token-value';
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce(null);
|
||||
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
|
||||
isValid: true,
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
|
||||
true,
|
||||
);
|
||||
|
||||
UserCreateMock.mockReturnValueOnce({} as User);
|
||||
UserSaveMock.mockReturnValueOnce({} as User);
|
||||
|
||||
await service.signInUp({
|
||||
email,
|
||||
password,
|
||||
fromSSO: false,
|
||||
workspaceInviteHash,
|
||||
});
|
||||
|
||||
expect(UserCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(UserSaveMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
workspaceInvitationInvalidateWorkspaceInvitationMock,
|
||||
).toHaveBeenCalledWith(workspaceId, email);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,20 +9,15 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import {
|
||||
PASSWORD_REGEX,
|
||||
compareHash,
|
||||
hashPassword,
|
||||
PASSWORD_REGEX,
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import {
|
||||
EnvironmentException,
|
||||
EnvironmentExceptionCode,
|
||||
} from 'src/engine/core-modules/environment/environment.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
@ -33,44 +28,56 @@ import {
|
||||
WorkspaceActivationStatus,
|
||||
} from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import {
|
||||
EnvironmentException,
|
||||
EnvironmentExceptionCode,
|
||||
} from 'src/engine/core-modules/environment/environment.exception';
|
||||
|
||||
export type SignInUpServiceInput = {
|
||||
email: string;
|
||||
password?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
workspaceInviteHash?: string | null;
|
||||
workspacePersonalInviteToken?: string | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
picture?: string | null;
|
||||
fromSSO: boolean;
|
||||
targetWorkspaceSubdomain?: string;
|
||||
isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class SignInUpService {
|
||||
constructor(
|
||||
private readonly fileUploadService: FileUploadService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly fileUploadService: FileUploadService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
|
||||
async signInUp({
|
||||
email,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
workspaceInviteHash,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
fromSSO,
|
||||
targetWorkspaceSubdomain,
|
||||
isAuthEnabled,
|
||||
}: SignInUpServiceInput) {
|
||||
if (!firstName) firstName = '';
|
||||
if (!lastName) lastName = '';
|
||||
@ -96,9 +103,7 @@ export class SignInUpService {
|
||||
const passwordHash = password ? await hashPassword(password) : undefined;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
where: { email },
|
||||
relations: ['defaultWorkspace'],
|
||||
});
|
||||
|
||||
@ -116,18 +121,55 @@ export class SignInUpService {
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceInviteHash) {
|
||||
return await this.signInUpOnExistingWorkspace({
|
||||
email,
|
||||
passwordHash,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
existingUser,
|
||||
});
|
||||
const maybeInvitation =
|
||||
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash
|
||||
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail(
|
||||
{
|
||||
subdomain: targetWorkspaceSubdomain,
|
||||
email,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
workspacePersonalInviteToken ||
|
||||
workspaceInviteHash ||
|
||||
maybeInvitation
|
||||
) {
|
||||
const invitationValidation =
|
||||
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation
|
||||
? await this.workspaceInvitationService.validateInvitation({
|
||||
workspacePersonalInviteToken:
|
||||
workspacePersonalInviteToken ?? maybeInvitation?.value,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (
|
||||
invitationValidation?.isValid === true &&
|
||||
invitationValidation.workspace
|
||||
) {
|
||||
const updatedUser = await this.signInUpOnExistingWorkspace({
|
||||
email,
|
||||
passwordHash,
|
||||
workspace: invitationValidation.workspace,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
existingUser,
|
||||
isAuthEnabled,
|
||||
});
|
||||
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
invitationValidation.workspace.id,
|
||||
email,
|
||||
);
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingUser) {
|
||||
return await this.signUpOnNewWorkspace({
|
||||
email,
|
||||
@ -141,47 +183,46 @@ export class SignInUpService {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
private async signInUpOnExistingWorkspace({
|
||||
async signInUpOnExistingWorkspace({
|
||||
email,
|
||||
passwordHash,
|
||||
workspaceInviteHash,
|
||||
workspacePersonalInviteToken,
|
||||
workspace,
|
||||
firstName,
|
||||
lastName,
|
||||
picture,
|
||||
existingUser,
|
||||
isAuthEnabled,
|
||||
}: {
|
||||
email: string;
|
||||
passwordHash: string | undefined;
|
||||
workspaceInviteHash: string | null;
|
||||
workspacePersonalInviteToken: string | null | undefined;
|
||||
workspace: Workspace;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
picture: SignInUpServiceInput['picture'];
|
||||
existingUser: User | null;
|
||||
isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>;
|
||||
}) {
|
||||
const isNewUser = !isDefined(existingUser);
|
||||
let user = existingUser;
|
||||
|
||||
const workspace = await this.findWorkspaceAndValidateInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
workspaceValidator.assertIsExist(
|
||||
workspace,
|
||||
new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
if (!(workspace.activationStatus === WorkspaceActivationStatus.ACTIVE)) {
|
||||
throw new AuthException(
|
||||
workspaceValidator.assertIsActive(
|
||||
workspace,
|
||||
new AuthException(
|
||||
'Workspace is not ready to welcome new members',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
if (isAuthEnabled)
|
||||
workspaceValidator.validateAuth(isAuthEnabled, workspace);
|
||||
|
||||
if (isNewUser) {
|
||||
const imagePath = await this.uploadPicture(picture, workspace.id);
|
||||
@ -199,19 +240,18 @@ export class SignInUpService {
|
||||
user = await this.userRepository.save(userToCreate);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
userValidator.assertIsExist(
|
||||
user,
|
||||
new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
const updatedUser = workspacePersonalInviteToken
|
||||
? await this.userWorkspaceService.addUserToWorkspaceByInviteToken(
|
||||
workspacePersonalInviteToken,
|
||||
user,
|
||||
)
|
||||
: await this.userWorkspaceService.addUserToWorkspace(user, workspace);
|
||||
const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
|
||||
await this.activateOnboardingForUser(user, workspace, {
|
||||
firstName,
|
||||
@ -221,53 +261,6 @@ export class SignInUpService {
|
||||
return Object.assign(user, updatedUser);
|
||||
}
|
||||
|
||||
private async findWorkspaceAndValidateInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
}) {
|
||||
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
|
||||
throw new AuthException(
|
||||
'No invite token or hash provided',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
inviteHash: workspaceInviteHash,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) {
|
||||
throw new AuthException(
|
||||
'Workspace does not allow public invites',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) {
|
||||
try {
|
||||
await this.userWorkspaceService.validateInvitation(
|
||||
workspacePersonalInviteToken,
|
||||
email,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new AuthException(
|
||||
err.message,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private async activateOnboardingForUser(
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
@ -288,7 +281,7 @@ export class SignInUpService {
|
||||
}
|
||||
}
|
||||
|
||||
private async signUpOnNewWorkspace({
|
||||
async signUpOnNewWorkspace({
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
@ -301,14 +294,20 @@ export class SignInUpService {
|
||||
lastName: string;
|
||||
picture: SignInUpServiceInput['picture'];
|
||||
}) {
|
||||
if (this.environmentService.get('IS_SIGN_UP_DISABLED')) {
|
||||
throw new EnvironmentException(
|
||||
'Sign up is disabled',
|
||||
EnvironmentExceptionCode.ENVIRONMENT_VARIABLES_NOT_FOUND,
|
||||
);
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
const workspacesCount = await this.workspaceRepository.count();
|
||||
|
||||
// let the creation of the first workspace
|
||||
if (workspacesCount > 0) {
|
||||
throw new EnvironmentException(
|
||||
'New workspace setup is disabled',
|
||||
EnvironmentExceptionCode.ENVIRONMENT_VARIABLES_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceToCreate = this.workspaceRepository.create({
|
||||
subdomain: await this.domainManagerService.generateSubdomain(),
|
||||
displayName: '',
|
||||
domainName: '',
|
||||
inviteHash: v4(),
|
||||
|
||||
@ -6,9 +6,9 @@ import { Repository } from 'typeorm';
|
||||
import { AuthException } 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 { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
import { SwitchWorkspaceService } from './switch-workspace.service';
|
||||
|
||||
@ -16,7 +16,7 @@ describe('SwitchWorkspaceService', () => {
|
||||
let service: SwitchWorkspaceService;
|
||||
let userRepository: Repository<User>;
|
||||
let workspaceRepository: Repository<Workspace>;
|
||||
let ssoService: SSOService;
|
||||
let userService: UserService;
|
||||
let accessTokenService: AccessTokenService;
|
||||
let refreshTokenService: RefreshTokenService;
|
||||
|
||||
@ -32,12 +32,6 @@ describe('SwitchWorkspaceService', () => {
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: SSOService,
|
||||
useValue: {
|
||||
listSSOIdentityProvidersByWorkspaceId: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: {
|
||||
@ -50,6 +44,12 @@ describe('SwitchWorkspaceService', () => {
|
||||
generateRefreshToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {
|
||||
saveDefaultWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -60,9 +60,9 @@ describe('SwitchWorkspaceService', () => {
|
||||
workspaceRepository = module.get<Repository<Workspace>>(
|
||||
getRepositoryToken(Workspace, 'core'),
|
||||
);
|
||||
ssoService = module.get<SSOService>(SSOService);
|
||||
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
|
||||
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
|
||||
userService = module.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@ -101,7 +101,6 @@ describe('SwitchWorkspaceService', () => {
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
workspaceUsers: [{ userId: 'other-user-id' }],
|
||||
workspaceSSOIdentityProviders: [],
|
||||
};
|
||||
|
||||
jest
|
||||
@ -121,19 +120,25 @@ describe('SwitchWorkspaceService', () => {
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
workspaceUsers: [{ userId: 'user-id' }],
|
||||
workspaceSSOIdentityProviders: [{}],
|
||||
logo: 'logo',
|
||||
displayName: 'displayName',
|
||||
isGoogleAuthEnabled: true,
|
||||
isPasswordAuthEnabled: true,
|
||||
isMicrosoftAuthEnabled: false,
|
||||
workspaceSSOIdentityProviders: [
|
||||
{
|
||||
id: 'sso-id',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockSSOProviders = [{ id: 'sso-provider-id' }];
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findBy')
|
||||
.mockResolvedValue([mockUser as User]);
|
||||
jest.spyOn(userRepository, 'save').mockResolvedValue(mockUser as User);
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as any);
|
||||
jest
|
||||
.spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId')
|
||||
.mockResolvedValue(mockSSOProviders as any);
|
||||
|
||||
const result = await service.switchWorkspace(
|
||||
mockUser as User,
|
||||
@ -141,9 +146,10 @@ describe('SwitchWorkspaceService', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
useSSOAuth: true,
|
||||
workspace: mockWorkspace,
|
||||
availableSSOIdentityProviders: mockSSOProviders,
|
||||
id: mockWorkspace.id,
|
||||
logo: expect.any(String),
|
||||
displayName: expect.any(String),
|
||||
authProviders: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
@ -153,6 +159,8 @@ describe('SwitchWorkspaceService', () => {
|
||||
id: 'workspace-id',
|
||||
workspaceUsers: [{ userId: 'user-id' }],
|
||||
workspaceSSOIdentityProviders: [],
|
||||
logo: 'logo',
|
||||
displayName: 'displayName',
|
||||
};
|
||||
|
||||
jest
|
||||
@ -161,6 +169,7 @@ describe('SwitchWorkspaceService', () => {
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as any);
|
||||
jest.spyOn(userRepository, 'save').mockResolvedValue({} as User);
|
||||
|
||||
const result = await service.switchWorkspace(
|
||||
mockUser as User,
|
||||
@ -168,8 +177,10 @@ describe('SwitchWorkspaceService', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
useSSOAuth: false,
|
||||
workspace: mockWorkspace,
|
||||
id: mockWorkspace.id,
|
||||
logo: expect.any(String),
|
||||
displayName: expect.any(String),
|
||||
authProviders: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -200,10 +211,10 @@ describe('SwitchWorkspaceService', () => {
|
||||
refreshToken: mockRefreshToken,
|
||||
},
|
||||
});
|
||||
expect(userRepository.save).toHaveBeenCalledWith({
|
||||
id: mockUser.id,
|
||||
defaultWorkspace: mockWorkspace,
|
||||
});
|
||||
expect(userService.saveDefaultWorkspace).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
);
|
||||
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
|
||||
@ -7,12 +7,13 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
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 { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace';
|
||||
|
||||
@Injectable()
|
||||
export class SwitchWorkspaceService {
|
||||
@ -21,7 +22,7 @@ export class SwitchWorkspaceService {
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly ssoService: SSOService,
|
||||
private readonly userService: UserService,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
) {}
|
||||
@ -59,31 +60,17 @@ export class SwitchWorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (workspace.workspaceSSOIdentityProviders.length > 0) {
|
||||
return {
|
||||
useSSOAuth: true,
|
||||
workspace,
|
||||
availableSSOIdentityProviders:
|
||||
await this.ssoService.listSSOIdentityProvidersByWorkspaceId(
|
||||
workspaceId,
|
||||
),
|
||||
} as {
|
||||
useSSOAuth: true;
|
||||
workspace: Workspace;
|
||||
availableSSOIdentityProviders: Awaited<
|
||||
ReturnType<
|
||||
typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId
|
||||
>
|
||||
>;
|
||||
};
|
||||
}
|
||||
await this.userRepository.save({
|
||||
id: user.id,
|
||||
defaultWorkspace: workspace,
|
||||
});
|
||||
|
||||
return {
|
||||
useSSOAuth: false,
|
||||
workspace,
|
||||
} as {
|
||||
useSSOAuth: false;
|
||||
workspace: Workspace;
|
||||
id: workspace.id,
|
||||
subdomain: workspace.subdomain,
|
||||
logo: workspace.logo,
|
||||
displayName: workspace.displayName,
|
||||
authProviders: getAuthProvidersByWorkspace(workspace),
|
||||
};
|
||||
}
|
||||
|
||||
@ -91,10 +78,7 @@ export class SwitchWorkspaceService {
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
): Promise<AuthTokens> {
|
||||
await this.userRepository.save({
|
||||
id: user.id,
|
||||
defaultWorkspace: workspace,
|
||||
});
|
||||
await this.userService.saveDefaultWorkspace(user.id, workspace.id);
|
||||
|
||||
const token = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
|
||||
@ -17,6 +17,7 @@ export type GoogleRequest = Omit<
|
||||
picture: string | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
targetWorkspaceSubdomain?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -37,6 +38,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
...options,
|
||||
state: JSON.stringify({
|
||||
workspaceInviteHash: req.params.workspaceInviteHash,
|
||||
workspaceSubdomain: req.params.workspaceSubdomain,
|
||||
...(req.params.workspacePersonalInviteToken
|
||||
? {
|
||||
workspacePersonalInviteToken:
|
||||
@ -69,6 +71,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
picture: photos?.[0]?.value,
|
||||
workspaceInviteHash: state.workspaceInviteHash,
|
||||
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain: state.workspaceSubdomain,
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
|
||||
@ -21,6 +21,7 @@ export type MicrosoftRequest = Omit<
|
||||
picture: string | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
targetWorkspaceSubdomain?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -41,6 +42,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
...options,
|
||||
state: JSON.stringify({
|
||||
workspaceInviteHash: req.params.workspaceInviteHash,
|
||||
workspaceSubdomain: req.params.workspaceSubdomain,
|
||||
...(req.params.workspacePersonalInviteToken
|
||||
? {
|
||||
workspacePersonalInviteToken:
|
||||
@ -83,6 +85,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
picture: photos?.[0]?.value,
|
||||
workspaceInviteHash: state.workspaceInviteHash,
|
||||
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
|
||||
targetWorkspaceSubdomain: state.workspaceSubdomain,
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
|
||||
@ -69,30 +69,34 @@ export class SamlAuthStrategy extends PassportStrategy(
|
||||
}
|
||||
|
||||
validate: VerifyWithRequest = async (request, profile, done) => {
|
||||
if (!profile) {
|
||||
return done(new Error('Profile is must be provided'));
|
||||
try {
|
||||
if (!profile) {
|
||||
return done(new Error('Profile is must be provided'));
|
||||
}
|
||||
|
||||
const email = profile.email ?? profile.mail ?? profile.nameID;
|
||||
|
||||
if (!isEmail(email)) {
|
||||
return done(new Error('Invalid email'));
|
||||
}
|
||||
|
||||
const result: {
|
||||
user: Record<string, string>;
|
||||
identityProviderId?: string;
|
||||
} = { user: { email } };
|
||||
|
||||
if (
|
||||
'RelayState' in request.body &&
|
||||
typeof request.body.RelayState === 'string'
|
||||
) {
|
||||
const RelayState = JSON.parse(request.body.RelayState);
|
||||
|
||||
result.identityProviderId = RelayState.identityProviderId;
|
||||
}
|
||||
|
||||
done(null, result);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
|
||||
const email = profile.email ?? profile.mail ?? profile.nameID;
|
||||
|
||||
if (!isEmail(email)) {
|
||||
return done(new Error('Invalid email'));
|
||||
}
|
||||
|
||||
const result: {
|
||||
user: Record<string, string>;
|
||||
identityProviderId?: string;
|
||||
} = { user: { email } };
|
||||
|
||||
if (
|
||||
'RelayState' in request.body &&
|
||||
typeof request.body.RelayState === 'string'
|
||||
) {
|
||||
const RelayState = JSON.parse(request.body.RelayState);
|
||||
|
||||
result.identityProviderId = RelayState.identityProviderId;
|
||||
}
|
||||
|
||||
done(null, result);
|
||||
};
|
||||
}
|
||||
|
||||
@ -166,7 +166,7 @@ describe('AccessTokenService', () => {
|
||||
.spyOn(service['jwtStrategy'], 'validate')
|
||||
.mockReturnValue(mockAuthContext as any);
|
||||
|
||||
const result = await service.validateToken(mockRequest);
|
||||
const result = await service.validateTokenByRequest(mockRequest);
|
||||
|
||||
expect(result).toEqual(mockAuthContext);
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
@ -184,7 +184,7 @@ describe('AccessTokenService', () => {
|
||||
headers: {},
|
||||
} as Request;
|
||||
|
||||
await expect(service.validateToken(mockRequest)).rejects.toThrow(
|
||||
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
|
||||
@ -116,16 +116,7 @@ export class AccessTokenService {
|
||||
};
|
||||
}
|
||||
|
||||
async validateToken(request: Request): Promise<AuthContext> {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
'missing authentication token',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<AuthContext> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS');
|
||||
|
||||
const decoded = await this.jwtWrapperService.decode(token);
|
||||
@ -135,4 +126,17 @@ export class AccessTokenService {
|
||||
|
||||
return { user, apiKey, workspace, workspaceMemberId };
|
||||
}
|
||||
|
||||
async validateTokenByRequest(request: Request): Promise<AuthContext> {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
'missing authentication token',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return this.validateToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,7 +94,7 @@ describe('LoginTokenService', () => {
|
||||
|
||||
const result = await service.verifyLoginToken(mockToken);
|
||||
|
||||
expect(result).toEqual(mockEmail);
|
||||
expect(result).toEqual({ sub: mockEmail });
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'LOGIN',
|
||||
|
||||
@ -20,6 +20,7 @@ export class LoginTokenService {
|
||||
|
||||
async generateLoginToken(email: string): Promise<AuthToken> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret('LOGIN');
|
||||
|
||||
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
@ -43,11 +44,11 @@ export class LoginTokenService {
|
||||
};
|
||||
}
|
||||
|
||||
async verifyLoginToken(loginToken: string): Promise<string> {
|
||||
async verifyLoginToken(loginToken: string): Promise<{ sub: string }> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
|
||||
|
||||
return this.jwtWrapperService.decode(loginToken, {
|
||||
json: true,
|
||||
}).sub;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,11 +16,13 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
FeatureFlagModule,
|
||||
StripeModule,
|
||||
DomainManagerModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[
|
||||
BillingSubscription,
|
||||
|
||||
@ -11,12 +11,14 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingPortalWorkspaceService {
|
||||
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
@ -31,7 +33,7 @@ export class BillingPortalWorkspaceService {
|
||||
priceId: string,
|
||||
successUrlPath?: string,
|
||||
): Promise<string> {
|
||||
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
|
||||
const frontBaseUrl = this.domainManagerService.getBaseUrl().toString();
|
||||
const successUrl = successUrlPath
|
||||
? frontBaseUrl + successUrlPath
|
||||
: frontBaseUrl;
|
||||
@ -81,7 +83,7 @@ export class BillingPortalWorkspaceService {
|
||||
throw new Error('Error: missing stripeCustomerId');
|
||||
}
|
||||
|
||||
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
|
||||
const frontBaseUrl = this.domainManagerService.getBaseUrl().toString();
|
||||
const returnUrl = returnUrlPath
|
||||
? frontBaseUrl + returnUrlPath
|
||||
: frontBaseUrl;
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [DomainManagerModule],
|
||||
providers: [StripeService],
|
||||
exports: [StripeService],
|
||||
})
|
||||
|
||||
@ -7,17 +7,20 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
|
||||
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
protected readonly logger = new Logger(StripeService.name);
|
||||
private readonly stripe: Stripe;
|
||||
|
||||
constructor(private readonly environmentService: EnvironmentService) {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stripe = new Stripe(
|
||||
this.environmentService.get('BILLING_STRIPE_API_KEY'),
|
||||
{},
|
||||
@ -74,7 +77,8 @@ export class StripeService {
|
||||
): Promise<Stripe.BillingPortal.Session> {
|
||||
return await this.stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl ?? this.environmentService.get('FRONT_BASE_URL'),
|
||||
return_url:
|
||||
returnUrl ?? this.domainManagerService.getBaseUrl().toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -2,30 +2,6 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
|
||||
@ObjectType()
|
||||
class AuthProviders {
|
||||
@Field(() => Boolean)
|
||||
google: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
magicLink: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
password: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
microsoft: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
sso: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class Telemetry {
|
||||
@Field(() => Boolean)
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class Billing {
|
||||
@Field(() => Boolean)
|
||||
@ -76,9 +52,6 @@ class ApiConfig {
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => AuthProviders, { nullable: false })
|
||||
authProviders: AuthProviders;
|
||||
|
||||
@Field(() => Billing, { nullable: false })
|
||||
billing: Billing;
|
||||
|
||||
@ -86,7 +59,16 @@ export class ClientConfig {
|
||||
signInPrefilled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
signUpDisabled: boolean;
|
||||
isMultiWorkspaceEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isSSOEnabled: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
defaultSubdomain: string;
|
||||
|
||||
@Field(() => String)
|
||||
frontDomain: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
debugMode: boolean;
|
||||
|
||||
@ -11,13 +11,6 @@ export class ClientConfigResolver {
|
||||
@Query(() => ClientConfig)
|
||||
async clientConfig(): Promise<ClientConfig> {
|
||||
const clientConfig: ClientConfig = {
|
||||
authProviders: {
|
||||
google: this.environmentService.get('AUTH_GOOGLE_ENABLED'),
|
||||
magicLink: false,
|
||||
password: this.environmentService.get('AUTH_PASSWORD_ENABLED'),
|
||||
microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'),
|
||||
sso: this.environmentService.get('AUTH_SSO_ENABLED'),
|
||||
},
|
||||
billing: {
|
||||
isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'),
|
||||
billingUrl: this.environmentService.get('BILLING_PLAN_REQUIRED_LINK'),
|
||||
@ -25,8 +18,13 @@ export class ClientConfigResolver {
|
||||
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
|
||||
),
|
||||
},
|
||||
isSSOEnabled: this.environmentService.get('AUTH_SSO_ENABLED'),
|
||||
signInPrefilled: this.environmentService.get('SIGN_IN_PREFILLED'),
|
||||
signUpDisabled: this.environmentService.get('IS_SIGN_UP_DISABLED'),
|
||||
isMultiWorkspaceEnabled: this.environmentService.get(
|
||||
'IS_MULTIWORKSPACE_ENABLED',
|
||||
),
|
||||
defaultSubdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
frontDomain: this.environmentService.get('FRONT_DOMAIN'),
|
||||
debugMode: this.environmentService.get('DEBUG_MODE'),
|
||||
support: {
|
||||
supportDriver: this.environmentService.get('SUPPORT_DRIVER'),
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Module({
|
||||
imports: [NestjsQueryTypeOrmModule.forFeature([Workspace], 'core')],
|
||||
providers: [DomainManagerService],
|
||||
exports: [DomainManagerService],
|
||||
})
|
||||
export class DomainManagerModule {}
|
||||
@ -0,0 +1,157 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { DomainManagerService } from './domain-manager.service';
|
||||
|
||||
describe('DomainManagerService', () => {
|
||||
let domainManagerService: DomainManagerService;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DomainManagerService,
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
});
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it('should build the base URL with protocol and domain from environment variables', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.getBaseUrl();
|
||||
|
||||
expect(result.toString()).toBe('https://example.com/');
|
||||
});
|
||||
|
||||
it('should append default subdomain if multiworkspace is enabled', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
IS_MULTIWORKSPACE_ENABLED: true,
|
||||
DEFAULT_SUBDOMAIN: 'test',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.getBaseUrl();
|
||||
|
||||
expect(result.toString()).toBe('https://test.example.com/');
|
||||
});
|
||||
|
||||
it('should append port if FRONT_PORT is set', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
FRONT_PORT: '8080',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.getBaseUrl();
|
||||
|
||||
expect(result.toString()).toBe('https://example.com:8080/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkspaceURL', () => {
|
||||
it('should build workspace URL with given subdomain', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
IS_MULTIWORKSPACE_ENABLED: true,
|
||||
DEFAULT_SUBDOMAIN: 'default',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
subdomain: 'test',
|
||||
});
|
||||
|
||||
expect(result.toString()).toBe('https://test.example.com/');
|
||||
});
|
||||
|
||||
it('should set the pathname if provided', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
pathname: '/path/to/resource',
|
||||
});
|
||||
|
||||
expect(result.pathname).toBe('/path/to/resource');
|
||||
});
|
||||
|
||||
it('should set the search parameters if provided', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
searchParams: {
|
||||
foo: 'bar',
|
||||
baz: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.searchParams.get('foo')).toBe('bar');
|
||||
expect(result.searchParams.get('baz')).toBe('123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,264 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import {
|
||||
WorkspaceException,
|
||||
WorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class DomainManagerService {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
getBaseUrl() {
|
||||
const baseUrl = new URL(
|
||||
`${this.environmentService.get('FRONT_PROTOCOL')}://${this.environmentService.get('FRONT_DOMAIN')}`,
|
||||
);
|
||||
|
||||
if (
|
||||
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN')
|
||||
) {
|
||||
baseUrl.hostname = `${this.environmentService.get('DEFAULT_SUBDOMAIN')}.${baseUrl.hostname}`;
|
||||
}
|
||||
|
||||
if (this.environmentService.get('FRONT_PORT')) {
|
||||
baseUrl.port = this.environmentService.get('FRONT_PORT').toString();
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
buildWorkspaceURL({
|
||||
subdomain,
|
||||
pathname,
|
||||
searchParams,
|
||||
}: {
|
||||
subdomain?: string;
|
||||
pathname?: string;
|
||||
searchParams?: Record<string, string | number>;
|
||||
}) {
|
||||
const url = this.getBaseUrl();
|
||||
|
||||
if (
|
||||
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
|
||||
!subdomain
|
||||
) {
|
||||
throw new Error('subdomain is required when multiworkspace is enable');
|
||||
}
|
||||
|
||||
if (
|
||||
subdomain &&
|
||||
subdomain.length > 0 &&
|
||||
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
|
||||
) {
|
||||
url.hostname = url.hostname.replace(
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
subdomain,
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname) {
|
||||
url.pathname = pathname;
|
||||
}
|
||||
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (isDefined(value)) {
|
||||
url.searchParams.set(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
getWorkspaceSubdomainByOrigin = (origin: string) => {
|
||||
const { hostname: originHostname } = new URL(origin);
|
||||
|
||||
const subdomain = originHostname.replace(
|
||||
`.${this.environmentService.get('FRONT_DOMAIN')}`,
|
||||
'',
|
||||
);
|
||||
|
||||
if (this.isDefaultSubdomain(subdomain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subdomain;
|
||||
};
|
||||
|
||||
isDefaultSubdomain(subdomain: string) {
|
||||
return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN');
|
||||
}
|
||||
|
||||
computeRedirectErrorUrl({
|
||||
errorMessage,
|
||||
subdomain,
|
||||
}: {
|
||||
errorMessage: string;
|
||||
subdomain?: string;
|
||||
}) {
|
||||
const url = this.buildWorkspaceURL({
|
||||
subdomain,
|
||||
pathname: '/verify',
|
||||
searchParams: { errorMessage },
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async getDefaultWorkspace() {
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
const workspaces = await this.workspaceRepository.find({
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
if (workspaces.length > 1) {
|
||||
// TODO AMOREAUX: this logger is trigger twice and the second time the message is undefined for an unknown reason
|
||||
Logger.warn(
|
||||
`In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`,
|
||||
);
|
||||
}
|
||||
|
||||
return workspaces[0];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Default workspace not exist when multi-workspace is enabled',
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceByOrigin(origin: string) {
|
||||
try {
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
return this.getDefaultWorkspace();
|
||||
}
|
||||
|
||||
const subdomain = this.getWorkspaceSubdomainByOrigin(origin);
|
||||
|
||||
if (!isDefined(subdomain)) return;
|
||||
|
||||
return this.workspaceRepository.findOneBy({ subdomain });
|
||||
} catch (e) {
|
||||
throw new WorkspaceException(
|
||||
'Workspace not found',
|
||||
WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private generateRandomSubdomain(): string {
|
||||
const prefixes = [
|
||||
'cool',
|
||||
'smart',
|
||||
'fast',
|
||||
'bright',
|
||||
'shiny',
|
||||
'happy',
|
||||
'funny',
|
||||
'clever',
|
||||
'brave',
|
||||
'kind',
|
||||
'gentle',
|
||||
'quick',
|
||||
'sharp',
|
||||
'calm',
|
||||
'silent',
|
||||
'lucky',
|
||||
'fierce',
|
||||
'swift',
|
||||
'mighty',
|
||||
'noble',
|
||||
'bold',
|
||||
'wise',
|
||||
'eager',
|
||||
'joyful',
|
||||
'glad',
|
||||
'zany',
|
||||
'witty',
|
||||
'bouncy',
|
||||
'graceful',
|
||||
'colorful',
|
||||
];
|
||||
const suffixes = [
|
||||
'raccoon',
|
||||
'panda',
|
||||
'whale',
|
||||
'tiger',
|
||||
'dolphin',
|
||||
'eagle',
|
||||
'penguin',
|
||||
'owl',
|
||||
'fox',
|
||||
'wolf',
|
||||
'lion',
|
||||
'bear',
|
||||
'hawk',
|
||||
'shark',
|
||||
'sparrow',
|
||||
'moose',
|
||||
'lynx',
|
||||
'falcon',
|
||||
'rabbit',
|
||||
'hedgehog',
|
||||
'monkey',
|
||||
'horse',
|
||||
'koala',
|
||||
'kangaroo',
|
||||
'elephant',
|
||||
'giraffe',
|
||||
'panther',
|
||||
'crocodile',
|
||||
'seal',
|
||||
'octopus',
|
||||
];
|
||||
|
||||
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
||||
|
||||
return `${randomPrefix}-${randomSuffix}`;
|
||||
}
|
||||
|
||||
private getSubdomainNameByEmail(email?: string) {
|
||||
if (!isDefined(email) || !isWorkEmail(email)) return;
|
||||
|
||||
return getDomainNameByEmail(email);
|
||||
}
|
||||
|
||||
private getSubdomainNameByDisplayName(displayName?: string) {
|
||||
if (!isDefined(displayName)) return;
|
||||
const displayNameWords = displayName.match(/(\w| |\d)+/g);
|
||||
|
||||
if (displayNameWords) {
|
||||
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
async generateSubdomain(params?: { email?: string; displayName?: string }) {
|
||||
const subdomain =
|
||||
this.getSubdomainNameByEmail(params?.email) ??
|
||||
this.getSubdomainNameByDisplayName(params?.displayName) ??
|
||||
this.generateRandomSubdomain();
|
||||
|
||||
const existingWorkspaceCount = await this.workspaceRepository.countBy({
|
||||
subdomain,
|
||||
});
|
||||
|
||||
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
|
||||
}
|
||||
}
|
||||
@ -127,8 +127,22 @@ export class EnvironmentVariables {
|
||||
PG_SSL_ALLOW_SELF_SIGNED = false;
|
||||
|
||||
// Frontend URL
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
FRONT_BASE_URL: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
FRONT_DOMAIN = 'localhost';
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED)
|
||||
DEFAULT_SUBDOMAIN = 'app';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
FRONT_PROTOCOL: 'http' | 'https' = 'http';
|
||||
|
||||
@CastToPositiveNumber()
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
FRONT_PORT = 3001;
|
||||
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
@IsOptional()
|
||||
@ -227,6 +241,11 @@ export class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
ENTERPRISE_KEY: string;
|
||||
|
||||
@CastToBoolean()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
IS_MULTIWORKSPACE_ENABLED = false;
|
||||
|
||||
// Custom Code Engine
|
||||
@IsEnum(ServerlessDriverType)
|
||||
@IsOptional()
|
||||
@ -363,11 +382,6 @@ export class EnvironmentVariables {
|
||||
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0)
|
||||
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 60;
|
||||
|
||||
@CastToBoolean()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
IS_SIGN_UP_DISABLED = false;
|
||||
|
||||
@IsEnum(CaptchaDriverType)
|
||||
@IsOptional()
|
||||
CAPTCHA_DRIVER?: CaptchaDriverType;
|
||||
|
||||
@ -59,7 +59,7 @@ export class OpenApiService {
|
||||
|
||||
try {
|
||||
const { workspace } =
|
||||
await this.accessTokenService.validateToken(request);
|
||||
await this.accessTokenService.validateTokenByRequest(request);
|
||||
|
||||
objectMetadataItems =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
|
||||
|
||||
@ -11,7 +11,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
|
||||
import {
|
||||
SSOException,
|
||||
SSOExceptionCode,
|
||||
@ -149,44 +148,6 @@ export class SSOService {
|
||||
};
|
||||
}
|
||||
|
||||
async findAvailableSSOIdentityProviders(email: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { email },
|
||||
relations: [
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceSSOIdentityProviders',
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new SSOException('User not found', SSOExceptionCode.USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return user.workspaces.flatMap((userWorkspace) =>
|
||||
(
|
||||
userWorkspace.workspace
|
||||
.workspaceSSOIdentityProviders as Array<SSOConfiguration>
|
||||
).reduce((acc, identityProvider) => {
|
||||
if (identityProvider.status === 'Inactive') return acc;
|
||||
|
||||
acc.push({
|
||||
id: identityProvider.id,
|
||||
name: identityProvider.name ?? 'Unknown',
|
||||
issuer: identityProvider.issuer,
|
||||
type: identityProvider.type,
|
||||
status: identityProvider.status,
|
||||
workspace: {
|
||||
id: userWorkspace.workspaceId,
|
||||
displayName: userWorkspace.workspace.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, [] as Array<FindAvailableSSOIDPOutput>),
|
||||
);
|
||||
}
|
||||
|
||||
async findSSOIdentityProviderById(identityProviderId?: string) {
|
||||
// if identityProviderId is not provide, typeorm return a random idp instead of undefined
|
||||
if (!identityProviderId) return undefined;
|
||||
|
||||
@ -8,7 +8,6 @@ import { DeleteSsoInput } from 'src/engine/core-modules/sso/dtos/delete-sso.inpu
|
||||
import { DeleteSsoOutput } from 'src/engine/core-modules/sso/dtos/delete-sso.output';
|
||||
import { EditSsoInput } from 'src/engine/core-modules/sso/dtos/edit-sso.input';
|
||||
import { EditSsoOutput } from 'src/engine/core-modules/sso/dtos/edit-sso.output';
|
||||
import { FindAvailableSSOIDPInput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input';
|
||||
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
|
||||
import { GetAuthorizationUrlInput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.input';
|
||||
import { GetAuthorizationUrlOutput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.output';
|
||||
@ -39,14 +38,6 @@ export class SSOResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SSOProviderEnabledGuard)
|
||||
@Mutation(() => [FindAvailableSSOIDPOutput])
|
||||
async findAvailableSSOIdentityProviders(
|
||||
@Args('input') input: FindAvailableSSOIDPInput,
|
||||
): Promise<Array<FindAvailableSSOIDPOutput>> {
|
||||
return this.sSOService.findAvailableSSOIdentityProviders(input.email);
|
||||
}
|
||||
|
||||
@UseGuards(SSOProviderEnabledGuard)
|
||||
@Query(() => [FindAvailableSSOIDPOutput])
|
||||
async listSSOIdentityProvidersByWorkspaceId(
|
||||
|
||||
@ -31,7 +31,7 @@ export enum OIDCResponseType {
|
||||
}
|
||||
|
||||
registerEnumType(IdentityProviderType, {
|
||||
name: 'IdpType',
|
||||
name: 'IdentityProviderType',
|
||||
});
|
||||
|
||||
export enum SSOIdentityProviderStatus {
|
||||
|
||||
@ -9,16 +9,17 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature(
|
||||
[User, UserWorkspace, AppToken],
|
||||
[User, UserWorkspace, Workspace],
|
||||
'core',
|
||||
),
|
||||
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
|
||||
@ -31,6 +32,6 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
|
||||
}),
|
||||
],
|
||||
exports: [UserWorkspaceService],
|
||||
providers: [UserWorkspaceService],
|
||||
providers: [UserWorkspaceService, UserWorkspaceResolver],
|
||||
})
|
||||
export class UserWorkspaceModule {}
|
||||
|
||||
@ -5,10 +5,6 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
@ -26,14 +22,12 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
@InjectRepository(ObjectMetadataEntity, 'metadata')
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
) {
|
||||
super(userWorkspaceRepository);
|
||||
}
|
||||
@ -116,39 +110,25 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
await this.createWorkspaceMember(workspace.id, user);
|
||||
}
|
||||
|
||||
return await this.userRepository.save({
|
||||
const savedUser = await this.userRepository.save({
|
||||
id: user.id,
|
||||
defaultWorkspace: workspace,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async validateInvitation(inviteToken: string, email: string) {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: inviteToken,
|
||||
type: AppTokenType.InvitationToken,
|
||||
},
|
||||
relations: ['workspace'],
|
||||
});
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
workspace.id,
|
||||
user.email,
|
||||
);
|
||||
|
||||
if (!appToken) {
|
||||
throw new Error('Invalid invitation token');
|
||||
}
|
||||
|
||||
if (!appToken.context?.email && appToken.context?.email !== email) {
|
||||
throw new Error('Email does not match the invitation');
|
||||
}
|
||||
|
||||
if (new Date(appToken.expiresAt) < new Date()) {
|
||||
throw new Error('Invitation expired');
|
||||
}
|
||||
|
||||
return appToken;
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) {
|
||||
const appToken = await this.validateInvitation(inviteToken, user.email);
|
||||
const appToken = await this.workspaceInvitationService.validateInvitation({
|
||||
workspacePersonalInviteToken: inviteToken,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
|
||||
appToken.workspace.id,
|
||||
@ -158,7 +138,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
return await this.addUserToWorkspace(user, appToken.workspace);
|
||||
}
|
||||
|
||||
public async getUserCount(workspaceId): Promise<number | undefined> {
|
||||
public async getUserCount(workspaceId: string): Promise<number | undefined> {
|
||||
return await this.userWorkspaceRepository.countBy({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
@ -18,6 +18,11 @@ import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/worksp
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class UserService extends TypeOrmQueryService<User> {
|
||||
@ -64,9 +69,7 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
'workspaceMember',
|
||||
);
|
||||
|
||||
const workspaceMembers = workspaceMemberRepository.find();
|
||||
|
||||
return workspaceMembers;
|
||||
return workspaceMemberRepository.find();
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<User> {
|
||||
@ -131,4 +134,29 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async saveDefaultWorkspace(userId: string, workspaceId: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
workspaces: {
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
relations: ['workspaces'],
|
||||
});
|
||||
|
||||
userValidator.assertIsExist(
|
||||
user,
|
||||
new AuthException(
|
||||
'User does not have access to this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
|
||||
return await this.userRepository.save({
|
||||
id: userId,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import assert from 'assert';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { GraphQLJSONObject } from 'graphql-type-json';
|
||||
@ -40,6 +39,11 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
|
||||
const getHMACKey = (email?: string, key?: string | null) => {
|
||||
if (!email || !key) return null;
|
||||
@ -65,7 +69,17 @@ export class UserResolver {
|
||||
) {}
|
||||
|
||||
@Query(() => User)
|
||||
async currentUser(@AuthUser() { id: userId }: User): Promise<User> {
|
||||
async currentUser(
|
||||
@AuthUser() { id: userId }: User,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<User> {
|
||||
if (
|
||||
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
|
||||
workspaceId
|
||||
) {
|
||||
await this.userService.saveDefaultWorkspace(userId, workspaceId);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
@ -73,7 +87,10 @@ export class UserResolver {
|
||||
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
|
||||
});
|
||||
|
||||
assert(user, 'User not found');
|
||||
userValidator.assertIsExist(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
const assertIsExist = (
|
||||
user: User | undefined | null,
|
||||
exceptionToThrow: CustomException,
|
||||
): asserts user is User => {
|
||||
if (!user) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
const isExist = (user: User | undefined | null): user is User => {
|
||||
return !!user;
|
||||
};
|
||||
|
||||
const assertHasDefaultWorkspace = (
|
||||
user: User,
|
||||
exceptionToThrow?: CustomException,
|
||||
): asserts user is User & { defaultWorkspaceId: string } => {
|
||||
if (!user.defaultWorkspaceId) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
export const userValidator: {
|
||||
assertIsExist: typeof assertIsExist;
|
||||
assertHasDefaultWorkspace: typeof assertHasDefaultWorkspace;
|
||||
isExist: typeof isExist;
|
||||
} = {
|
||||
assertIsExist,
|
||||
assertHasDefaultWorkspace,
|
||||
isExist,
|
||||
};
|
||||
@ -14,9 +14,14 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
|
||||
import { WorkspaceInvitationService } from './workspace-invitation.service';
|
||||
|
||||
// To fix a circular dependency issue
|
||||
jest.mock('src/engine/core-modules/workspace/services/workspace.service');
|
||||
|
||||
describe('WorkspaceInvitationService', () => {
|
||||
let service: WorkspaceInvitationService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
@ -37,6 +42,18 @@ describe('WorkspaceInvitationService', () => {
|
||||
provide: getRepositoryToken(UserWorkspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
buildWorkspaceURL: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new URL('http://localhost:3001')),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
@ -55,6 +72,16 @@ describe('WorkspaceInvitationService', () => {
|
||||
setOnboardingInviteTeamPending: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceService,
|
||||
useValue: {
|
||||
// Mock methods you expect WorkspaceInvitationService to call
|
||||
getDefaultWorkspace: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'default-workspace-id' }),
|
||||
// Add other methods as needed
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -28,6 +28,8 @@ import {
|
||||
WorkspaceInvitationExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { castAppTokenToWorkspaceInvitationUtil } from 'src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -35,13 +37,122 @@ export class WorkspaceInvitationService {
|
||||
constructor(
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly emailService: EmailService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
|
||||
// VALIDATIONS METHODS
|
||||
private async validatePublicInvitation(workspaceInviteHash: string) {
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
inviteHash: workspaceInviteHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspace.isPublicInviteLinkEnabled) {
|
||||
throw new AuthException(
|
||||
'Workspace does not allow public invites',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return { isValid: true, workspace };
|
||||
}
|
||||
|
||||
private async validatePersonalInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
email,
|
||||
}: {
|
||||
workspacePersonalInviteToken?: string;
|
||||
email: string;
|
||||
}) {
|
||||
try {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: workspacePersonalInviteToken,
|
||||
type: AppTokenType.InvitationToken,
|
||||
},
|
||||
relations: ['workspace'],
|
||||
});
|
||||
|
||||
if (!appToken) {
|
||||
throw new Error('Invalid invitation token');
|
||||
}
|
||||
|
||||
if (!appToken.context?.email || appToken.context?.email !== email) {
|
||||
throw new Error('Email does not match the invitation');
|
||||
}
|
||||
|
||||
if (new Date(appToken.expiresAt) < new Date()) {
|
||||
throw new Error('Invitation expired');
|
||||
}
|
||||
|
||||
return { isValid: true, workspace: appToken.workspace };
|
||||
} catch (err) {
|
||||
throw new AuthException(
|
||||
err.message,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
}: {
|
||||
workspacePersonalInviteToken?: string;
|
||||
workspaceInviteHash?: string;
|
||||
email: string;
|
||||
}) {
|
||||
if (workspacePersonalInviteToken) {
|
||||
return await this.validatePersonalInvitation({
|
||||
workspacePersonalInviteToken,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
if (workspaceInviteHash) {
|
||||
return await this.validatePublicInvitation(workspaceInviteHash);
|
||||
}
|
||||
|
||||
throw new AuthException(
|
||||
'Invitation invalid',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
async findInvitationByWorkspaceSubdomainAndUserEmail({
|
||||
subdomain,
|
||||
email,
|
||||
}: {
|
||||
subdomain?: string;
|
||||
email: string;
|
||||
}) {
|
||||
const workspace = this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
|
||||
? await this.workspaceRepository.findOneBy({
|
||||
subdomain,
|
||||
})
|
||||
: await this.domainManagerService.getDefaultWorkspace();
|
||||
|
||||
if (!workspace) return;
|
||||
|
||||
return await this.getOneWorkspaceInvitation(workspace.id, email);
|
||||
}
|
||||
|
||||
async getOneWorkspaceInvitation(workspaceId: string, email: string) {
|
||||
return await this.appTokenRepository
|
||||
.createQueryBuilder('appToken')
|
||||
@ -55,26 +166,38 @@ export class WorkspaceInvitationService {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
castAppTokenToWorkspaceInvitation(appToken: AppToken) {
|
||||
if (appToken.type !== AppTokenType.InvitationToken) {
|
||||
async getAppTokenByInvitationToken(invitationToken: string) {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: invitationToken,
|
||||
type: AppTokenType.InvitationToken,
|
||||
},
|
||||
relations: ['workspace'],
|
||||
});
|
||||
|
||||
if (!appToken) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`Token type must be "${AppTokenType.InvitationToken}"`,
|
||||
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
|
||||
'Invalid invitation token',
|
||||
WorkspaceInvitationExceptionCode.INVALID_INVITATION,
|
||||
);
|
||||
}
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`Invitation corrupted: Missing email in context`,
|
||||
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
|
||||
);
|
||||
}
|
||||
return appToken;
|
||||
}
|
||||
|
||||
return {
|
||||
id: appToken.id,
|
||||
email: appToken.context.email,
|
||||
expiresAt: appToken.expiresAt,
|
||||
};
|
||||
async loadWorkspaceInvitations(workspace: Workspace) {
|
||||
const appTokens = await this.appTokenRepository.find({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
type: AppTokenType.InvitationToken,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
select: {
|
||||
value: false,
|
||||
},
|
||||
});
|
||||
|
||||
return appTokens.map(castAppTokenToWorkspaceInvitationUtil);
|
||||
}
|
||||
|
||||
async createWorkspaceInvitation(email: string, workspace: Workspace) {
|
||||
@ -112,21 +235,6 @@ export class WorkspaceInvitationService {
|
||||
return this.generateInvitationToken(workspace.id, email);
|
||||
}
|
||||
|
||||
async loadWorkspaceInvitations(workspace: Workspace) {
|
||||
const appTokens = await this.appTokenRepository.find({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
type: AppTokenType.InvitationToken,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
select: {
|
||||
value: false,
|
||||
},
|
||||
});
|
||||
|
||||
return appTokens.map(this.castAppTokenToWorkspaceInvitation);
|
||||
}
|
||||
|
||||
async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) {
|
||||
const appToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
@ -221,16 +329,18 @@ export class WorkspaceInvitationService {
|
||||
}),
|
||||
);
|
||||
|
||||
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||
|
||||
for (const invitation of invitationsPr) {
|
||||
if (invitation.status === 'fulfilled') {
|
||||
const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`);
|
||||
|
||||
if (invitation.value.isPersonalInvitation) {
|
||||
link.searchParams.set('inviteToken', invitation.value.appToken.value);
|
||||
link.searchParams.set('email', invitation.value.email);
|
||||
}
|
||||
const link = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
pathname: `invite/${workspace?.inviteHash}`,
|
||||
searchParams: invitation.value.isPersonalInvitation
|
||||
? {
|
||||
inviteToken: invitation.value.appToken.value,
|
||||
email: invitation.value.email,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
const emailData = {
|
||||
link: link.toString(),
|
||||
workspace: { name: workspace.displayName, logo: workspace.logo },
|
||||
@ -280,9 +390,7 @@ export class WorkspaceInvitationService {
|
||||
} else {
|
||||
acc.result.push(
|
||||
invitation.value.isPersonalInvitation
|
||||
? this.castAppTokenToWorkspaceInvitation(
|
||||
invitation.value.appToken,
|
||||
)
|
||||
? castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken)
|
||||
: { email: invitation.value.email },
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
WorkspaceInvitationException,
|
||||
WorkspaceInvitationExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
|
||||
import { castAppTokenToWorkspaceInvitationUtil } from './cast-app-token-to-workspace-invitation.util';
|
||||
|
||||
describe('castAppTokenToWorkspaceInvitation', () => {
|
||||
it('should throw an error if token type is not InvitationToken', () => {
|
||||
const appToken = {
|
||||
id: '1',
|
||||
type: AppTokenType.RefreshToken,
|
||||
context: { email: 'test@example.com' },
|
||||
expiresAt: new Date(),
|
||||
} as AppToken;
|
||||
|
||||
expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError(
|
||||
new WorkspaceInvitationException(
|
||||
`Token type must be "${AppTokenType.InvitationToken}"`,
|
||||
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if context email is missing', () => {
|
||||
const appToken = {
|
||||
id: '1',
|
||||
type: AppTokenType.InvitationToken,
|
||||
context: null,
|
||||
expiresAt: new Date(),
|
||||
} as AppToken;
|
||||
|
||||
expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError(
|
||||
new WorkspaceInvitationException(
|
||||
`Invitation corrupted: Missing email in context`,
|
||||
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct invitation object for valid inputs', () => {
|
||||
const appToken = {
|
||||
id: '1',
|
||||
type: AppTokenType.InvitationToken,
|
||||
context: { email: 'test@example.com' },
|
||||
expiresAt: new Date(),
|
||||
} as AppToken;
|
||||
|
||||
const invitation = castAppTokenToWorkspaceInvitationUtil(appToken);
|
||||
|
||||
expect(invitation).toEqual({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
expiresAt: appToken.expiresAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,30 @@
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
WorkspaceInvitationException,
|
||||
WorkspaceInvitationExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
|
||||
export const castAppTokenToWorkspaceInvitationUtil = (appToken: AppToken) => {
|
||||
if (appToken.type !== AppTokenType.InvitationToken) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`Token type must be "${AppTokenType.InvitationToken}"`,
|
||||
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
if (!appToken.context?.email) {
|
||||
throw new WorkspaceInvitationException(
|
||||
`Invitation corrupted: Missing email in context`,
|
||||
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: appToken.id,
|
||||
email: appToken.context.email,
|
||||
expiresAt: appToken.expiresAt,
|
||||
};
|
||||
};
|
||||
@ -1,7 +1,6 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceInvitationException extends CustomException {
|
||||
code: WorkspaceInvitationExceptionCode;
|
||||
constructor(message: string, code: WorkspaceInvitationExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
|
||||
@ -3,16 +3,20 @@ import { Module } from '@nestjs/common';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'),
|
||||
TokenModule,
|
||||
DomainManagerModule,
|
||||
NestjsQueryTypeOrmModule.forFeature(
|
||||
[AppToken, UserWorkspace, Workspace],
|
||||
'core',
|
||||
),
|
||||
OnboardingModule,
|
||||
],
|
||||
exports: [WorkspaceInvitationService],
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
|
||||
@ObjectType()
|
||||
export class ActivateWorkspaceOutput {
|
||||
@Field(() => Workspace)
|
||||
workspace: Workspace;
|
||||
|
||||
@Field(() => AuthToken)
|
||||
loginToken: AuthToken;
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { ObjectType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
IdentityProviderType,
|
||||
SSOIdentityProviderStatus,
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
|
||||
@ObjectType()
|
||||
export class SSOIdentityProvider {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
|
||||
@Field(() => IdentityProviderType)
|
||||
type: IdentityProviderType;
|
||||
|
||||
@Field(() => SSOIdentityProviderStatus)
|
||||
status: SSOIdentityProviderStatus;
|
||||
|
||||
@Field(() => String)
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AuthProviders {
|
||||
@Field(() => [SSOIdentityProvider])
|
||||
sso: Array<SSOIdentityProvider>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
google: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
magicLink: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
password: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
microsoft: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PublicWorkspaceDataOutput {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => AuthProviders)
|
||||
authProviders: AuthProviders;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo: Workspace['logo'];
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName: Workspace['displayName'];
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: Workspace['subdomain'];
|
||||
}
|
||||
@ -9,6 +9,11 @@ export class UpdateWorkspaceInput {
|
||||
@IsOptional()
|
||||
domainName?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
subdomain?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ -33,4 +38,19 @@ export class UpdateWorkspaceInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allowImpersonation?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isGoogleAuthEnabled?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isMicrosoftAuthEnabled?: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPasswordAuthEnabled?: boolean;
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
@ -47,6 +48,10 @@ describe('WorkspaceService', () => {
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: BillingSubscriptionService,
|
||||
useValue: {},
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import assert from 'assert';
|
||||
@ -20,9 +19,9 @@ import {
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private userWorkspaceService: UserWorkspaceService;
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@ -33,12 +32,9 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly workspaceManagerService: WorkspaceManagerService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private moduleRef: ModuleRef,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
async activateWorkspace(user: User, data: ActivateWorkspaceInput) {
|
||||
@ -82,12 +78,15 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
user.defaultWorkspaceId,
|
||||
user,
|
||||
);
|
||||
|
||||
await this.workspaceRepository.update(user.defaultWorkspaceId, {
|
||||
displayName: data.displayName,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
});
|
||||
|
||||
return existingWorkspace;
|
||||
return await this.workspaceRepository.findOneBy({
|
||||
id: user.defaultWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async softDeleteWorkspace(id: string) {
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { getAuthProvidersByWorkspace } from './getAuthProvidersByWorkspace';
|
||||
|
||||
describe('getAuthProvidersByWorkspace', () => {
|
||||
const mockWorkspace = {
|
||||
isGoogleAuthEnabled: true,
|
||||
isPasswordAuthEnabled: true,
|
||||
isMicrosoftAuthEnabled: false,
|
||||
workspaceSSOIdentityProviders: [
|
||||
{
|
||||
id: 'sso1',
|
||||
name: 'SSO Provider 1',
|
||||
type: 'SAML',
|
||||
status: 'active',
|
||||
issuer: 'sso1.example.com',
|
||||
},
|
||||
],
|
||||
} as unknown as Workspace;
|
||||
|
||||
it('should return correct auth providers for given workspace', () => {
|
||||
const result = getAuthProvidersByWorkspace({
|
||||
...mockWorkspace,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
google: true,
|
||||
magicLink: false,
|
||||
password: true,
|
||||
microsoft: false,
|
||||
sso: [
|
||||
{
|
||||
id: 'sso1',
|
||||
name: 'SSO Provider 1',
|
||||
type: 'SAML',
|
||||
status: 'active',
|
||||
issuer: 'sso1.example.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle workspace with no SSO providers', () => {
|
||||
const result = getAuthProvidersByWorkspace({
|
||||
...mockWorkspace,
|
||||
workspaceSSOIdentityProviders: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
google: true,
|
||||
magicLink: false,
|
||||
password: true,
|
||||
microsoft: false,
|
||||
sso: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable Microsoft auth if isMicrosoftAuthEnabled is false', () => {
|
||||
const result = getAuthProvidersByWorkspace({
|
||||
...mockWorkspace,
|
||||
isMicrosoftAuthEnabled: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
google: true,
|
||||
magicLink: false,
|
||||
password: true,
|
||||
microsoft: false,
|
||||
sso: [
|
||||
{
|
||||
id: 'sso1',
|
||||
name: 'SSO Provider 1',
|
||||
type: 'SAML',
|
||||
status: 'active',
|
||||
issuer: 'sso1.example.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,17 @@
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export const getAuthProvidersByWorkspace = (workspace: Workspace) => {
|
||||
return {
|
||||
google: workspace.isGoogleAuthEnabled,
|
||||
magicLink: false,
|
||||
password: workspace.isPasswordAuthEnabled,
|
||||
microsoft: workspace.isMicrosoftAuthEnabled,
|
||||
sso: workspace.workspaceSSOIdentityProviders.map((identityProvider) => ({
|
||||
id: identityProvider.id,
|
||||
name: identityProvider.name,
|
||||
type: identityProvider.type,
|
||||
status: identityProvider.status,
|
||||
issuer: identityProvider.issuer,
|
||||
})),
|
||||
};
|
||||
};
|
||||
@ -150,4 +150,20 @@ export class Workspace {
|
||||
@Field()
|
||||
@Column({ default: '' })
|
||||
databaseSchema: string;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
subdomain: string;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
isGoogleAuthEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: true })
|
||||
isPasswordAuthEnabled: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ default: false })
|
||||
isMicrosoftAuthEnabled: boolean;
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WorkspaceException extends CustomException {
|
||||
constructor(message: string, code: WorkspaceExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum WorkspaceExceptionCode {
|
||||
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
}
|
||||
@ -11,14 +11,14 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener';
|
||||
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
|
||||
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
|
||||
import { Workspace } from './workspace.entity';
|
||||
@ -30,8 +30,10 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
TypeORMModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
DomainManagerModule,
|
||||
BillingModule,
|
||||
FileModule,
|
||||
TokenModule,
|
||||
FileUploadModule,
|
||||
WorkspaceMetadataCacheModule,
|
||||
NestjsQueryTypeOrmModule.forFeature(
|
||||
@ -44,7 +46,6 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
DataSourceModule,
|
||||
OnboardingModule,
|
||||
TypeORMModule,
|
||||
WorkspaceInvitationModule,
|
||||
],
|
||||
services: [WorkspaceService],
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
@ -54,7 +55,6 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
WorkspaceService,
|
||||
UserWorkspaceResolver,
|
||||
WorkspaceWorkspaceMemberListener,
|
||||
],
|
||||
})
|
||||
|
||||
@ -29,16 +29,28 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import {
|
||||
WorkspaceException,
|
||||
WorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
|
||||
import { ActivateWorkspaceOutput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-output';
|
||||
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace';
|
||||
|
||||
import { Workspace } from './workspace.entity';
|
||||
|
||||
import { WorkspaceService } from './services/workspace.service';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver(() => Workspace)
|
||||
export class WorkspaceResolver {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly fileUploadService: FileUploadService,
|
||||
@ -47,6 +59,7 @@ export class WorkspaceResolver {
|
||||
) {}
|
||||
|
||||
@Query(() => Workspace)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async currentWorkspace(@AuthWorkspace() { id }: Workspace) {
|
||||
const workspace = await this.workspaceService.findById(id);
|
||||
|
||||
@ -55,16 +68,25 @@ export class WorkspaceResolver {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@Mutation(() => Workspace)
|
||||
@Mutation(() => ActivateWorkspaceOutput)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async activateWorkspace(
|
||||
@Args('data') data: ActivateWorkspaceInput,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return await this.workspaceService.activateWorkspace(user, data);
|
||||
const workspace = await this.workspaceService.activateWorkspace(user, data);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return {
|
||||
workspace,
|
||||
loginToken,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Workspace)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async updateWorkspace(
|
||||
@Args('data') data: UpdateWorkspaceInput,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@ -73,6 +95,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async uploadWorkspaceLogo(
|
||||
@AuthWorkspace() { id }: Workspace,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
@ -101,8 +124,8 @@ export class WorkspaceResolver {
|
||||
return `${paths[0]}?token=${workspaceLogoToken}`;
|
||||
}
|
||||
|
||||
@UseGuards(DemoEnvGuard)
|
||||
@Mutation(() => Workspace)
|
||||
@UseGuards(DemoEnvGuard, WorkspaceAuthGuard)
|
||||
async deleteCurrentWorkspace(@AuthWorkspace() { id }: Workspace) {
|
||||
return this.workspaceService.deleteWorkspace(id);
|
||||
}
|
||||
@ -144,4 +167,26 @@ export class WorkspaceResolver {
|
||||
hasValidEntrepriseKey(): boolean {
|
||||
return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
|
||||
}
|
||||
|
||||
@Query(() => PublicWorkspaceDataOutput)
|
||||
async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOrigin(origin);
|
||||
|
||||
workspaceValidator.assertIsExist(
|
||||
workspace,
|
||||
new WorkspaceException(
|
||||
'Workspace not found',
|
||||
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
logo: workspace.logo,
|
||||
displayName: workspace.displayName,
|
||||
subdomain: workspace.subdomain,
|
||||
authProviders: getAuthProvidersByWorkspace(workspace),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import {
|
||||
Workspace,
|
||||
WorkspaceActivationStatus,
|
||||
} from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password';
|
||||
|
||||
const assertIsExist = (
|
||||
workspace: Workspace | undefined | null,
|
||||
exceptionToThrow?: CustomException,
|
||||
): asserts workspace is Workspace => {
|
||||
if (!workspace) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
const assertIsActive = (
|
||||
workspace: Workspace,
|
||||
exceptionToThrow: CustomException,
|
||||
): asserts workspace is Workspace & {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE;
|
||||
} => {
|
||||
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) return;
|
||||
throw exceptionToThrow;
|
||||
};
|
||||
|
||||
type IsAuthEnabled = <P extends WorkspaceAuthProvider>(
|
||||
provider: P,
|
||||
exceptionToThrow: CustomException,
|
||||
) => (
|
||||
workspace: Workspace,
|
||||
exceptionToThrowCustom?: CustomException,
|
||||
) => boolean;
|
||||
|
||||
const isAuthEnabled: IsAuthEnabled = (provider, exceptionToThrow) => {
|
||||
return (workspace, exceptionToThrowCustom = exceptionToThrow) => {
|
||||
if (provider === 'google' && workspace.isGoogleAuthEnabled) return true;
|
||||
if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled)
|
||||
return true;
|
||||
if (provider === 'password' && workspace.isPasswordAuthEnabled) return true;
|
||||
|
||||
if (exceptionToThrowCustom) {
|
||||
throw exceptionToThrowCustom;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
const validateAuth = (fn: ReturnType<IsAuthEnabled>, workspace: Workspace) =>
|
||||
fn(workspace);
|
||||
|
||||
export const workspaceValidator: {
|
||||
assertIsExist: typeof assertIsExist;
|
||||
assertIsActive: typeof assertIsActive;
|
||||
isAuthEnabled: IsAuthEnabled;
|
||||
validateAuth: typeof validateAuth;
|
||||
} = {
|
||||
assertIsExist: assertIsExist,
|
||||
assertIsActive: assertIsActive,
|
||||
isAuthEnabled,
|
||||
validateAuth,
|
||||
};
|
||||
Reference in New Issue
Block a user