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:
@ -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)}` : ''}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user