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:
Antoine Moreaux
2024-12-03 19:06:28 +01:00
committed by GitHub
parent 9a65e80566
commit 7943141d03
167 changed files with 5180 additions and 1901 deletions

View File

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

View File

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

View File

@ -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)}` : ''}`;
}
}