feat(): enable custom domain usage (#9911)
# Content - Introduce the `workspaceUrls` property. It contains two sub-properties: `customUrl, subdomainUrl`. These endpoints are used to access the workspace. Even if the `workspaceUrls` is invalid for multiple reasons, the `subdomainUrl` remains valid. - Introduce `ResolveField` workspaceEndpoints to avoid unnecessary URL computation on the frontend part. - Add a `forceSubdomainUrl` to avoid custom URL using a query parameter
This commit is contained in:
@ -13,6 +13,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
const UserFindOneMock = jest.fn();
|
||||
const WorkspaceFindOneMock = jest.fn();
|
||||
@ -95,6 +96,15 @@ describe('AdminPanelService', () => {
|
||||
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
getworkspaceUrls: jest.fn().mockReturnValue({
|
||||
customUrl: undefined,
|
||||
subdomainUrl: 'https://twenty.twenty.com',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
@ -230,7 +240,10 @@ describe('AdminPanelService', () => {
|
||||
expect.objectContaining({
|
||||
workspace: {
|
||||
id: 'workspace-id',
|
||||
subdomain: 'example-subdomain',
|
||||
workspaceUrls: {
|
||||
customUrl: undefined,
|
||||
subdomainUrl: 'https://twenty.twenty.com',
|
||||
},
|
||||
},
|
||||
loginToken: expect.objectContaining({
|
||||
token: 'mock-login-token',
|
||||
|
||||
@ -7,11 +7,13 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, Workspace, FeatureFlag], 'core'),
|
||||
AuthModule,
|
||||
DomainManagerModule,
|
||||
],
|
||||
providers: [AdminPanelResolver, AdminPanelService],
|
||||
exports: [AdminPanelService],
|
||||
|
||||
@ -28,12 +28,14 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPanelService {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
@ -72,7 +74,9 @@ export class AdminPanelService {
|
||||
return {
|
||||
workspace: {
|
||||
id: user.workspaces[0].workspace.id,
|
||||
subdomain: user.workspaces[0].workspace.subdomain,
|
||||
workspaceUrls: this.domainManagerService.getworkspaceUrls(
|
||||
user.workspaces[0].workspace,
|
||||
),
|
||||
},
|
||||
loginToken,
|
||||
};
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
|
||||
import { workspaceUrlsAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
|
||||
|
||||
@ObjectType()
|
||||
export class ImpersonateOutput {
|
||||
@Field(() => AuthToken)
|
||||
loginToken: AuthToken;
|
||||
|
||||
@Field(() => WorkspaceSubdomainAndId)
|
||||
workspace: WorkspaceSubdomainAndId;
|
||||
@Field(() => workspaceUrlsAndId)
|
||||
workspace: workspaceUrlsAndId;
|
||||
}
|
||||
|
||||
@ -221,7 +221,7 @@ export class AuthResolver {
|
||||
await this.emailVerificationService.sendVerificationEmail(
|
||||
user.id,
|
||||
user.email,
|
||||
workspace.subdomain,
|
||||
workspace,
|
||||
);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
@ -233,7 +233,7 @@ export class AuthResolver {
|
||||
loginToken,
|
||||
workspace: {
|
||||
id: workspace.id,
|
||||
subdomain: workspace.subdomain,
|
||||
workspaceUrls: this.domainManagerService.getworkspaceUrls(workspace),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -113,7 +113,7 @@ export class GoogleAPIsAuthController {
|
||||
return res.redirect(
|
||||
this.domainManagerService
|
||||
.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
workspace,
|
||||
pathname: redirectLocation || '/settings/accounts',
|
||||
})
|
||||
.toString(),
|
||||
|
||||
@ -19,7 +19,6 @@ 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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
|
||||
@Controller('auth/google')
|
||||
@ -28,7 +27,6 @@ export class GoogleAuthController {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@ -110,7 +108,7 @@ export class GoogleAuthController {
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI({
|
||||
loginToken: loginToken.token,
|
||||
subdomain: workspace.subdomain,
|
||||
workspace,
|
||||
billingCheckoutSessionState,
|
||||
}),
|
||||
);
|
||||
@ -118,9 +116,9 @@ export class GoogleAuthController {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
|
||||
err,
|
||||
currentWorkspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
currentWorkspace,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ export class MicrosoftAPIsAuthController {
|
||||
return res.redirect(
|
||||
this.domainManagerService
|
||||
.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
workspace,
|
||||
pathname: redirectLocation || '/settings/accounts',
|
||||
})
|
||||
.toString(),
|
||||
|
||||
@ -18,7 +18,6 @@ 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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
|
||||
@Controller('auth/microsoft')
|
||||
@ -28,7 +27,6 @@ export class MicrosoftAuthController {
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
@ -111,8 +109,7 @@ export class MicrosoftAuthController {
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI({
|
||||
loginToken: loginToken.token,
|
||||
subdomain: workspace.subdomain,
|
||||
|
||||
workspace,
|
||||
billingCheckoutSessionState,
|
||||
}),
|
||||
);
|
||||
@ -120,9 +117,9 @@ export class MicrosoftAuthController {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
|
||||
err,
|
||||
currentWorkspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
currentWorkspace,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -32,7 +32,6 @@ import {
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { SAMLRequest } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
|
||||
import { OIDCRequest } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
|
||||
@ -45,7 +44,6 @@ export class SSOAuthController {
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly sSOService: SSOService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@ -152,16 +150,16 @@ export class SSOAuthController {
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI({
|
||||
loginToken: loginToken.token,
|
||||
subdomain: currentWorkspace.subdomain,
|
||||
workspace: currentWorkspace,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions(
|
||||
err,
|
||||
workspaceIdentityProvider?.workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
workspaceIdentityProvider?.workspace,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
IdentityProviderType,
|
||||
SSOIdentityProviderStatus,
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
|
||||
|
||||
@ObjectType()
|
||||
class SSOConnection {
|
||||
@ -34,8 +35,8 @@ export class AvailableWorkspaceOutput {
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName?: string;
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: string;
|
||||
@Field(() => workspaceUrls)
|
||||
workspaceUrls: workspaceUrls;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname?: string;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
|
||||
import { workspaceUrlsAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
|
||||
|
||||
import { AuthToken } from './token.entity';
|
||||
|
||||
@ -9,6 +9,6 @@ export class SignUpOutput {
|
||||
@Field(() => AuthToken)
|
||||
loginToken: AuthToken;
|
||||
|
||||
@Field(() => WorkspaceSubdomainAndId)
|
||||
workspace: WorkspaceSubdomainAndId;
|
||||
@Field(() => workspaceUrlsAndId)
|
||||
workspace: workspaceUrlsAndId;
|
||||
}
|
||||
|
||||
@ -27,9 +27,11 @@ export class EnterpriseFeaturesEnabledGuard implements CanActivate {
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
|
||||
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
|
||||
});
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -50,9 +50,11 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
} catch (err) {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
|
||||
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
|
||||
});
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -71,9 +71,9 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -11,13 +11,11 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
constructor(
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {
|
||||
@ -53,9 +51,9 @@ export class GoogleOauthGuard extends AuthGuard('google') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -28,9 +28,11 @@ export class GoogleProviderEnabledGuard implements CanActivate {
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
|
||||
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
|
||||
});
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -57,9 +57,7 @@ export class MicrosoftAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||
AuthExceptionCode.INSUFFICIENT_SCOPES,
|
||||
)
|
||||
: error,
|
||||
{
|
||||
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -72,9 +72,9 @@ export class MicrosoftAPIsOauthRequestCodeGuard extends AuthGuard(
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -5,14 +5,12 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
constructor(
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {
|
||||
@ -41,9 +39,9 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
workspace,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -28,9 +28,11 @@ export class MicrosoftProviderEnabledGuard implements CanActivate {
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(context, err, {
|
||||
subdomain: this.guardRedirectService.getSubdomainFromContext(context),
|
||||
});
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromContext(context),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
import { OIDCAuthStrategy } from 'src/engine/core-modules/auth/strategies/oidc.auth.strategy';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
|
||||
@ -21,12 +20,13 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
|
||||
constructor(
|
||||
private readonly sSOService: SSOService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private getIdentityProviderId(request: any): string {
|
||||
private getStateByRequest(request: any): {
|
||||
identityProviderId: string;
|
||||
} {
|
||||
if (request.params.identityProviderId) {
|
||||
return request.params.identityProviderId;
|
||||
}
|
||||
@ -39,24 +39,27 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
|
||||
) {
|
||||
const state = JSON.parse(request.query.state);
|
||||
|
||||
return state.identityProviderId;
|
||||
return {
|
||||
identityProviderId: state.identityProviderId,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid OIDC identity provider params');
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
let identityProvider:
|
||||
| (SSOConfiguration & WorkspaceSSOIdentityProvider)
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
const identityProviderId = this.getIdentityProviderId(request);
|
||||
const state = this.getStateByRequest(request);
|
||||
|
||||
identityProvider =
|
||||
await this.sSOService.findSSOIdentityProviderById(identityProviderId);
|
||||
identityProvider = await this.sSOService.findSSOIdentityProviderById(
|
||||
state.identityProviderId,
|
||||
);
|
||||
|
||||
if (!identityProvider) {
|
||||
throw new AuthException(
|
||||
@ -77,9 +80,9 @@ export class OIDCAuthGuard extends AuthGuard('openidconnect') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
identityProvider?.workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
identityProvider?.workspace,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@ -10,22 +12,34 @@ import {
|
||||
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
|
||||
@Injectable()
|
||||
export class SAMLAuthGuard extends AuthGuard('saml') {
|
||||
constructor(
|
||||
private readonly sSOService: SSOService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private getRelayStateByRequest(request: Request) {
|
||||
try {
|
||||
const relayStateRaw = request.body.RelayState || request.query.RelayState;
|
||||
|
||||
if (relayStateRaw) {
|
||||
return JSON.parse(relayStateRaw);
|
||||
}
|
||||
} catch (error) {
|
||||
this.exceptionHandlerService.captureExceptions(error);
|
||||
}
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
let identityProvider:
|
||||
| (SSOConfiguration & WorkspaceSSOIdentityProvider)
|
||||
@ -49,9 +63,9 @@ export class SAMLAuthGuard extends AuthGuard('saml') {
|
||||
this.guardRedirectService.dispatchErrorFromGuard(
|
||||
context,
|
||||
err,
|
||||
identityProvider?.workspace ?? {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
},
|
||||
this.guardRedirectService.getSubdomainAndHostnameFromWorkspace(
|
||||
identityProvider?.workspace,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@ -455,15 +455,15 @@ export class AuthService {
|
||||
|
||||
computeRedirectURI({
|
||||
loginToken,
|
||||
subdomain,
|
||||
workspace,
|
||||
billingCheckoutSessionState,
|
||||
}: {
|
||||
loginToken: string;
|
||||
subdomain: string;
|
||||
workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
|
||||
billingCheckoutSessionState?: string;
|
||||
}) {
|
||||
const url = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain,
|
||||
workspace,
|
||||
pathname: '/verify',
|
||||
searchParams: {
|
||||
loginToken,
|
||||
|
||||
@ -50,7 +50,6 @@ export class OIDCAuthStrategy extends PassportStrategy(
|
||||
...options,
|
||||
state: JSON.stringify({
|
||||
identityProviderId: req.params.identityProviderId,
|
||||
...(req.query.forceSubdomainUrl ? { forceSubdomainUrl: true } : {}),
|
||||
...(req.query.workspaceInviteHash
|
||||
? { workspaceInviteHash: req.query.workspaceInviteHash }
|
||||
: {}),
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
|
||||
export enum BillingEntitlementKey {
|
||||
SSO = 'SSO',
|
||||
CUSTOM_DOMAIN = 'CUSTOM_DOMAIN',
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export class BillingPortalWorkspaceService {
|
||||
requirePaymentMethod,
|
||||
}: BillingPortalCheckoutSessionParameters): Promise<string> {
|
||||
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
workspace,
|
||||
});
|
||||
const cancelUrl = frontBaseUrl.toString();
|
||||
|
||||
@ -118,7 +118,7 @@ export class BillingPortalWorkspaceService {
|
||||
}
|
||||
|
||||
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
workspace,
|
||||
});
|
||||
|
||||
if (returnUrlPath) {
|
||||
|
||||
@ -41,6 +41,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
|
||||
value: true,
|
||||
stripeCustomerId: 'cus_123',
|
||||
},
|
||||
{
|
||||
key: BillingEntitlementKey.CUSTOM_DOMAIN,
|
||||
stripeCustomerId: 'cus_123',
|
||||
value: false,
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@ -79,6 +85,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
|
||||
value: false,
|
||||
stripeCustomerId: 'cus_123',
|
||||
},
|
||||
{
|
||||
key: 'CUSTOM_DOMAIN',
|
||||
stripeCustomerId: 'cus_123',
|
||||
value: false,
|
||||
workspaceId: 'workspaceId',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,53 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DomainManagerService } from './domain-manager.service';
|
||||
|
||||
describe('DomainManagerService', () => {
|
||||
describe('getworkspaceUrls', () => {
|
||||
it('should return a URL containing the correct hostname if hostname is provided', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.getworkspaceUrls({
|
||||
subdomain: 'subdomain',
|
||||
hostname: 'custom-host.com',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
customUrl: 'https://custom-host.com/',
|
||||
subdomainUrl: 'https://subdomain.example.com/',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a URL containing the correct subdomain if hostname is not provided but subdomain is', () => {
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONT_PROTOCOL: 'https',
|
||||
FRONT_DOMAIN: 'example.com',
|
||||
};
|
||||
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result = domainManagerService.getworkspaceUrls({
|
||||
subdomain: 'subdomain',
|
||||
hostname: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
customUrl: undefined,
|
||||
subdomainUrl: 'https://subdomain.example.com/',
|
||||
});
|
||||
});
|
||||
});
|
||||
let domainManagerService: DomainManagerService;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
@ -106,7 +153,10 @@ describe('DomainManagerService', () => {
|
||||
});
|
||||
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
subdomain: 'test',
|
||||
workspace: {
|
||||
subdomain: 'test',
|
||||
hostname: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.toString()).toBe('https://test.example.com/');
|
||||
@ -125,7 +175,10 @@ describe('DomainManagerService', () => {
|
||||
});
|
||||
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
subdomain: 'subdomain',
|
||||
workspace: {
|
||||
subdomain: 'test',
|
||||
hostname: undefined,
|
||||
},
|
||||
pathname: '/path/to/resource',
|
||||
});
|
||||
|
||||
@ -145,8 +198,10 @@ describe('DomainManagerService', () => {
|
||||
});
|
||||
|
||||
const result = domainManagerService.buildWorkspaceURL({
|
||||
subdomain: 'subdomain',
|
||||
|
||||
workspace: {
|
||||
subdomain: 'test',
|
||||
hostname: undefined,
|
||||
},
|
||||
searchParams: {
|
||||
foo: 'bar',
|
||||
baz: 123,
|
||||
|
||||
@ -74,33 +74,31 @@ export class DomainManagerService {
|
||||
buildEmailVerificationURL({
|
||||
emailVerificationToken,
|
||||
email,
|
||||
subdomain,
|
||||
workspace,
|
||||
}: {
|
||||
emailVerificationToken: string;
|
||||
email: string;
|
||||
subdomain: string;
|
||||
workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
|
||||
}) {
|
||||
return this.buildWorkspaceURL({
|
||||
subdomain,
|
||||
workspace,
|
||||
pathname: 'verify-email',
|
||||
searchParams: { emailVerificationToken, email },
|
||||
});
|
||||
}
|
||||
|
||||
buildWorkspaceURL({
|
||||
subdomain,
|
||||
workspace,
|
||||
pathname,
|
||||
searchParams,
|
||||
}: {
|
||||
subdomain: string;
|
||||
workspace: Pick<Workspace, 'subdomain' | 'hostname'>;
|
||||
pathname?: string;
|
||||
searchParams?: Record<string, string | number>;
|
||||
}) {
|
||||
const url = this.getFrontUrl();
|
||||
const workspaceUrls = this.getworkspaceUrls(workspace);
|
||||
|
||||
if (this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
url.hostname = `${subdomain}.${url.hostname}`;
|
||||
}
|
||||
const url = new URL(workspaceUrls.customUrl ?? workspaceUrls.subdomainUrl);
|
||||
|
||||
if (pathname) {
|
||||
url.pathname = pathname;
|
||||
@ -117,21 +115,6 @@ export class DomainManagerService {
|
||||
return url;
|
||||
}
|
||||
|
||||
// @Deprecated
|
||||
getWorkspaceSubdomainFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
|
||||
if (!originHostname.endsWith(this.getFrontUrl().hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontDomain = this.getFrontUrl().hostname;
|
||||
|
||||
const subdomain = originHostname.replace(`.${frontDomain}`, '');
|
||||
|
||||
return this.isDefaultSubdomain(subdomain) ? null : subdomain;
|
||||
};
|
||||
|
||||
getSubdomainAndHostnameFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
|
||||
@ -162,9 +145,12 @@ export class DomainManagerService {
|
||||
return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN');
|
||||
}
|
||||
|
||||
computeRedirectErrorUrl(errorMessage: string, subdomain: string) {
|
||||
computeRedirectErrorUrl(
|
||||
errorMessage: string,
|
||||
workspace: Pick<Workspace, 'subdomain' | 'hostname'>,
|
||||
) {
|
||||
const url = this.buildWorkspaceURL({
|
||||
subdomain: subdomain,
|
||||
workspace,
|
||||
pathname: '/verify',
|
||||
searchParams: { errorMessage },
|
||||
});
|
||||
@ -352,7 +338,7 @@ export class DomainManagerService {
|
||||
await this.deleteCustomHostname(fromCustomHostname.id);
|
||||
}
|
||||
|
||||
return await this.registerCustomHostname(toHostname);
|
||||
return this.registerCustomHostname(toHostname);
|
||||
}
|
||||
|
||||
async deleteCustomHostnameByHostnameSilently(hostname: string) {
|
||||
@ -378,4 +364,32 @@ export class DomainManagerService {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
|
||||
private getCustomWorkspaceEndpoint(hostname: string) {
|
||||
const url = this.getFrontUrl();
|
||||
|
||||
url.hostname = hostname;
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private getTwentyWorkspaceEndpoint(subdomain: string) {
|
||||
const url = this.getFrontUrl();
|
||||
|
||||
url.hostname = `${subdomain}.${url.hostname}`;
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getworkspaceUrls({
|
||||
subdomain,
|
||||
hostname,
|
||||
}: Pick<Workspace, 'subdomain' | 'hostname'>) {
|
||||
return {
|
||||
customUrl: hostname
|
||||
? this.getCustomWorkspaceEndpoint(hostname)
|
||||
: undefined,
|
||||
subdomainUrl: this.getTwentyWorkspaceEndpoint(subdomain),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export class EmailVerificationResolver {
|
||||
|
||||
return await this.emailVerificationService.resendEmailVerificationToken(
|
||||
resendEmailVerificationTokenInput.email,
|
||||
workspace.subdomain,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -38,7 +39,7 @@ export class EmailVerificationService {
|
||||
async sendVerificationEmail(
|
||||
userId: string,
|
||||
email: string,
|
||||
subdomain: string,
|
||||
workspace: Pick<Workspace, 'subdomain' | 'hostname'>,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
return { success: false };
|
||||
@ -51,7 +52,7 @@ export class EmailVerificationService {
|
||||
this.domainManagerService.buildEmailVerificationURL({
|
||||
emailVerificationToken,
|
||||
email,
|
||||
subdomain,
|
||||
workspace,
|
||||
});
|
||||
|
||||
const emailData = {
|
||||
@ -80,7 +81,10 @@ export class EmailVerificationService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async resendEmailVerificationToken(email: string, subdomain: string) {
|
||||
async resendEmailVerificationToken(
|
||||
email: string,
|
||||
workspace: Pick<Workspace, 'subdomain' | 'hostname'>,
|
||||
) {
|
||||
if (!this.environmentService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
throw new EmailVerificationException(
|
||||
'Email verification token cannot be sent because email verification is not required',
|
||||
@ -121,7 +125,7 @@ export class EmailVerificationService {
|
||||
await this.appTokenRepository.delete(existingToken.id);
|
||||
}
|
||||
|
||||
await this.sendVerificationEmail(user.id, email, subdomain);
|
||||
await this.sendVerificationEmail(user.id, email, workspace);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Request } from 'express';
|
||||
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GuardRedirectService {
|
||||
@ -17,7 +20,7 @@ export class GuardRedirectService {
|
||||
dispatchErrorFromGuard(
|
||||
context: ExecutionContext,
|
||||
error: Error | CustomException,
|
||||
workspace: { id?: string; subdomain: string },
|
||||
workspace: { id?: string; subdomain: string; hostname?: string },
|
||||
) {
|
||||
if ('contextType' in context && context.contextType === 'graphql') {
|
||||
throw error;
|
||||
@ -29,15 +32,36 @@ export class GuardRedirectService {
|
||||
.redirect(this.getRedirectErrorUrlAndCaptureExceptions(error, workspace));
|
||||
}
|
||||
|
||||
getSubdomainFromContext(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
getSubdomainAndHostnameFromWorkspace(
|
||||
workspace?: Pick<Workspace, 'subdomain' | 'hostname'> | null,
|
||||
) {
|
||||
if (!workspace) {
|
||||
return {
|
||||
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
};
|
||||
}
|
||||
|
||||
const subdomainFromUrl =
|
||||
this.domainManagerService.getWorkspaceSubdomainFromUrl(
|
||||
request.headers.referer,
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
return subdomainFromUrl ?? this.environmentService.get('DEFAULT_SUBDOMAIN');
|
||||
getSubdomainAndHostnameFromContext(context: ExecutionContext): {
|
||||
subdomain: string;
|
||||
hostname?: string;
|
||||
} {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const subdomainAndHostnameFromReferer = request.headers.referer
|
||||
? this.domainManagerService.getSubdomainAndHostnameFromUrl(
|
||||
request.headers.referer,
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
subdomain:
|
||||
subdomainAndHostnameFromReferer?.subdomain ??
|
||||
this.environmentService.get('DEFAULT_SUBDOMAIN'),
|
||||
hostname: subdomainAndHostnameFromReferer?.hostname,
|
||||
};
|
||||
}
|
||||
|
||||
private captureException(err: Error | CustomException, workspaceId?: string) {
|
||||
@ -52,13 +76,13 @@ export class GuardRedirectService {
|
||||
|
||||
getRedirectErrorUrlAndCaptureExceptions(
|
||||
err: Error | CustomException,
|
||||
workspace: { id?: string; subdomain: string },
|
||||
workspace: { id?: string; subdomain: string; hostname?: string },
|
||||
) {
|
||||
this.captureException(err, workspace.id);
|
||||
|
||||
return this.domainManagerService.computeRedirectErrorUrl(
|
||||
err instanceof AuthException ? err.message : 'Unknown error',
|
||||
workspace.subdomain,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsOptional, IsBoolean, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class GetAuthorizationUrlInput {
|
||||
|
||||
@ -15,6 +15,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -29,6 +30,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
|
||||
DataSourceModule,
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceInvitationModule,
|
||||
DomainManagerModule,
|
||||
TwentyORMModule,
|
||||
],
|
||||
services: [UserWorkspaceService],
|
||||
|
||||
@ -24,6 +24,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
constructor(
|
||||
@ -37,6 +38,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {
|
||||
super(userWorkspaceRepository);
|
||||
@ -179,7 +181,9 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
|
||||
id: userWorkspace.workspaceId,
|
||||
displayName: userWorkspace.workspace.displayName,
|
||||
subdomain: userWorkspace.workspace.subdomain,
|
||||
workspaceUrls: this.domainManagerService.getworkspaceUrls(
|
||||
userWorkspace.workspace,
|
||||
),
|
||||
logo: userWorkspace.workspace.logo,
|
||||
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
|
||||
(acc, identityProvider) =>
|
||||
|
||||
@ -19,7 +19,6 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
|
||||
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
registerEnumType(OnboardingStatus, {
|
||||
name: 'OnboardingStatus',
|
||||
@ -100,7 +99,4 @@ export class User {
|
||||
|
||||
@Field(() => OnboardingStatus, { nullable: true })
|
||||
onboardingStatus: OnboardingStatus;
|
||||
|
||||
@Field(() => Workspace, { nullable: true })
|
||||
currentWorkspace: Relation<Workspace>;
|
||||
}
|
||||
|
||||
@ -81,22 +81,7 @@ export class UserResolver {
|
||||
) {}
|
||||
|
||||
@Query(() => User)
|
||||
async currentUser(
|
||||
@AuthUser() { id: userId }: User,
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<User> {
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
);
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
await this.userService.hasUserAccessToWorkspaceOrThrow(
|
||||
userId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
async currentUser(@AuthUser() { id: userId }: User): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
@ -109,7 +94,7 @@ export class UserResolver {
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
return { ...user, currentWorkspace: workspace };
|
||||
return user;
|
||||
}
|
||||
|
||||
@ResolveField(() => GraphQLJSONObject)
|
||||
@ -314,4 +299,9 @@ export class UserResolver {
|
||||
|
||||
return this.onboardingService.getOnboardingStatus(user, workspace);
|
||||
}
|
||||
|
||||
@ResolveField(() => Workspace)
|
||||
async currentWorkspace(@AuthWorkspace() workspace: Workspace) {
|
||||
return workspace;
|
||||
}
|
||||
}
|
||||
|
||||
@ -279,7 +279,7 @@ export class WorkspaceInvitationService {
|
||||
for (const invitation of invitationsPr) {
|
||||
if (invitation.status === 'fulfilled') {
|
||||
const link = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
workspace,
|
||||
pathname: `invite/${workspace?.inviteHash}`,
|
||||
searchParams: invitation.value.isPersonalInvitation
|
||||
? {
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
IdentityProviderType,
|
||||
SSOIdentityProviderStatus,
|
||||
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
|
||||
|
||||
@ObjectType()
|
||||
export class SSOIdentityProvider {
|
||||
@ -56,9 +57,6 @@ export class PublicWorkspaceDataOutput {
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName: Workspace['displayName'];
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: Workspace['subdomain'];
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname: Workspace['hostname'];
|
||||
@Field(() => workspaceUrls)
|
||||
workspaceUrls: workspaceUrls;
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { ObjectType, Field } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class workspaceUrls {
|
||||
@Field(() => String, { nullable: true })
|
||||
customUrl?: string;
|
||||
|
||||
@Field(() => String)
|
||||
subdomainUrl: string;
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceSubdomainAndId {
|
||||
@Field()
|
||||
subdomain: string;
|
||||
export class workspaceUrlsAndId {
|
||||
@Field(() => workspaceUrls)
|
||||
workspaceUrls: workspaceUrls;
|
||||
|
||||
@Field()
|
||||
id: string;
|
||||
|
||||
@ -15,6 +15,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 { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
@ -81,6 +82,10 @@ describe('WorkspaceService', () => {
|
||||
provide: FeatureFlagService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ExceptionHandlerService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import assert from 'assert';
|
||||
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { WorkspaceActivationStatus, isDefined } from 'twenty-shared';
|
||||
import { isDefined, WorkspaceActivationStatus } from 'twenty-shared';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
@ -24,10 +24,14 @@ import {
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
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';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly featureLookUpKey = BillingEntitlementKey.CUSTOM_DOMAIN;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@ -42,10 +46,26 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
}
|
||||
|
||||
private async isCustomDomainEnabled(workspaceId: string) {
|
||||
const isCustomDomainBillingEnabled =
|
||||
await this.billingService.hasEntitlement(
|
||||
workspaceId,
|
||||
this.featureLookUpKey,
|
||||
);
|
||||
|
||||
if (!isCustomDomainBillingEnabled) {
|
||||
throw new WorkspaceException(
|
||||
`No entitlement found for this workspace`,
|
||||
WorkspaceExceptionCode.WORKSPACE_CUSTOM_DOMAIN_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSubdomainUpdate(newSubdomain: string) {
|
||||
const subdomainAvailable = await this.isSubdomainAvailable(newSubdomain);
|
||||
|
||||
@ -61,6 +81,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
}
|
||||
|
||||
private async setCustomDomain(workspace: Workspace, hostname: string) {
|
||||
await this.isCustomDomainEnabled(workspace.id);
|
||||
|
||||
const existingWorkspace = await this.workspaceRepository.findOne({
|
||||
where: { hostname },
|
||||
});
|
||||
@ -126,10 +148,11 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
if (payload.hostname && customDomainRegistered) {
|
||||
this.domainManagerService
|
||||
.deleteCustomHostnameByHostnameSilently(payload.hostname)
|
||||
.catch(() => {
|
||||
// send to sentry
|
||||
.catch((err) => {
|
||||
this.exceptionHandlerService.captureExceptions([err]);
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,4 +11,5 @@ export enum WorkspaceExceptionCode {
|
||||
SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN',
|
||||
DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN',
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
WORKSPACE_CUSTOM_DOMAIN_DISABLED = 'WORKSPACE_CUSTOM_DOMAIN_DISABLED',
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
|
||||
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
@ -44,6 +43,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
|
||||
import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-endpoints.dto';
|
||||
|
||||
import { Workspace } from './workspace.entity';
|
||||
|
||||
@ -214,6 +215,11 @@ export class WorkspaceResolver {
|
||||
return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
|
||||
}
|
||||
|
||||
@ResolveField(() => workspaceUrls)
|
||||
workspaceUrls(@Parent() workspace: Workspace) {
|
||||
return this.domainManagerService.getworkspaceUrls(workspace);
|
||||
}
|
||||
|
||||
@Query(() => CustomHostnameDetails, { nullable: true })
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async getHostnameDetails(
|
||||
@ -225,7 +231,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@Query(() => PublicWorkspaceDataOutput)
|
||||
async getPublicWorkspaceDataBySubdomain(
|
||||
async getPublicWorkspaceDataByDomain(
|
||||
@OriginHeader() origin: string,
|
||||
): Promise<PublicWorkspaceDataOutput | undefined> {
|
||||
try {
|
||||
@ -262,8 +268,7 @@ export class WorkspaceResolver {
|
||||
id: workspace.id,
|
||||
logo: workspaceLogoWithToken,
|
||||
displayName: workspace.displayName,
|
||||
subdomain: workspace.subdomain,
|
||||
hostname: workspace.hostname,
|
||||
workspaceUrls: this.domainManagerService.getworkspaceUrls(workspace),
|
||||
authProviders: getAuthProvidersByWorkspace({
|
||||
workspace,
|
||||
systemEnabledProviders,
|
||||
|
||||
Reference in New Issue
Block a user