feat: cloud and ee (#805)

* stripe init
git submodules for enterprise modules

* * Cloud billing UI - WIP
* Proxy websockets in dev mode
* Separate workspace login and creation for cloud
* Other fixes

* feat: billing (cloud)

* * add domain service
* prepare links from workspace hostname

* WIP

* Add exchange token generation
* Validate JWT token type during verification

* domain service

* add SkipTransform decorator

* * updates (server)
* add new packages
* new sso migration file

* WIP

* Fix hostname generation

* WIP

* WIP

* Reduce input error font-size
* set max password length

* jwt package

* license page - WIP

* * License management UI
* Move license key store to db

* add reflector

* SSO enforcement

* * Add default plan
* Add usePlan hook

* * Fix auth container margin in mobile
* Redirect login and home to select page in cloud

* update .gitignore

* Default to yearly

* * Trial messaging
* Handle ended trials

* Don't set to readonly on collab disconnect (Cloud)

* Refine trial (UI)
* Fix bug caused by using jotai optics atom in AppHeader component

* configurable database maximum pool

* Close SSO form on save

* wip

* sync

* Only show sign-in in cloud

* exclude base api part from workspaceId check

* close db connection beforeApplicationShutdown

* Add health/live endpoint

* clear cookie on hostname change

* reset currentUser atom

* Change text

* return 401 if workspace does not match

* feat: show user workspace list in cloud login page

* sync

* Add home path

* Prefetch to speed up queries

* * Add robots.txt
* Disallow login and forgot password routes

* wildcard user-agent

* Fix space query cache

* fix

* fix

* use space uuid for recent pages

* prefetch billing plans

* enhance license page

* sync
This commit is contained in:
Philip Okugbe
2025-03-06 13:38:37 +00:00
committed by GitHub
parent 91596be70e
commit b81c9ee10c
148 changed files with 8947 additions and 3458 deletions
@@ -1,20 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceController } from './workspace.controller';
import { WorkspaceService } from '../services/workspace.service';
describe('WorkspaceController', () => {
let controller: WorkspaceController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WorkspaceController],
providers: [WorkspaceService],
}).compile();
controller = module.get<WorkspaceController>(WorkspaceController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -33,6 +33,7 @@ import {
import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
@UseGuards(JwtAuthGuard)
@Controller('workspace')
@@ -60,7 +61,8 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('update')
async updateWorkspace(
@Body() updateWorkspaceDto: UpdateWorkspaceDto,
@Res({ passthrough: true }) res: FastifyReply,
@Body() dto: UpdateWorkspaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
@@ -71,7 +73,21 @@ export class WorkspaceController {
throw new ForbiddenException();
}
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
const updatedWorkspace = await this.workspaceService.update(
workspace.id,
dto,
);
if (
dto.hostname &&
dto.hostname === updatedWorkspace.hostname &&
workspace.hostname !== updatedWorkspace.hostname
) {
// log user out of old hostname
res.clearCookie('authToken');
}
return updatedWorkspace;
}
@HttpCode(HttpStatus.OK)
@@ -102,8 +118,6 @@ export class WorkspaceController {
) {
throw new ForbiddenException();
}
return this.workspaceService.deactivateUser();
}
@HttpCode(HttpStatus.OK)
@@ -172,7 +186,7 @@ export class WorkspaceController {
return this.workspaceInvitationService.createInvitation(
inviteUserDto,
workspace.id,
workspace,
user,
);
}
@@ -193,7 +207,7 @@ export class WorkspaceController {
return this.workspaceInvitationService.resendInvitation(
revokeInviteDto.invitationId,
workspace.id,
workspace,
);
}
@@ -238,6 +252,13 @@ export class WorkspaceController {
});
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/check-hostname')
async checkHostname(@Body() checkHostnameDto: CheckHostnameDto) {
return this.workspaceService.checkHostname(checkHostnameDto.hostname);
}
@HttpCode(HttpStatus.OK)
@Post('invites/link')
async getInviteLink(
@@ -258,7 +279,7 @@ export class WorkspaceController {
const inviteLink =
await this.workspaceInvitationService.getInvitationLinkById(
inviteDto.invitationId,
workspace.id,
workspace,
);
return { inviteLink };
@@ -0,0 +1,8 @@
import { MinLength } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
export class CheckHostnameDto {
@MinLength(1)
@Transform(({ value }: TransformFnParams) => value?.trim())
hostname: string;
}
@@ -1,8 +1,14 @@
import {IsAlphanumeric, IsOptional, IsString, MaxLength, MinLength} from 'class-validator';
import {Transform, TransformFnParams} from "class-transformer";
import {
IsAlphanumeric,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
export class CreateWorkspaceDto {
@MinLength(4)
@MinLength(1)
@MaxLength(64)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
@@ -12,6 +18,7 @@ export class CreateWorkspaceDto {
@MinLength(4)
@MaxLength(30)
@IsAlphanumeric()
@Transform(({ value }: TransformFnParams) => value?.trim().toLowerCase())
hostname?: string;
@IsOptional()
@@ -1,9 +1,17 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { IsOptional, IsString } from 'class-validator';
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsString()
logo: string;
@IsOptional()
@IsArray()
emailDomains: string[];
@IsOptional()
@IsBoolean()
enforceSso: boolean;
}
@@ -12,17 +12,18 @@ import { executeTx } from '@docmost/db/utils';
import {
Group,
User,
Workspace,
WorkspaceInvitation,
} from '@docmost/db/types/entity.types';
import { MailService } from '../../../integrations/mail/mail.service';
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { TokenService } from '../../auth/services/token.service';
import { nanoIdGen } from '../../../common/helpers';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DomainService } from 'src/integrations/environment/domain.service';
@Injectable()
export class WorkspaceInvitationService {
@@ -31,7 +32,7 @@ export class WorkspaceInvitationService {
private userRepo: UserRepo,
private groupUserRepo: GroupUserRepo,
private mailService: MailService,
private environmentService: EnvironmentService,
private domainService: DomainService,
private tokenService: TokenService,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -88,7 +89,7 @@ export class WorkspaceInvitationService {
async createInvitation(
inviteUserDto: InviteUserDto,
workspaceId: string,
workspace: Workspace,
authUser: User,
): Promise<void> {
const { emails, role, groupIds } = inviteUserDto;
@@ -102,7 +103,7 @@ export class WorkspaceInvitationService {
.selectFrom('users')
.select(['email'])
.where('users.email', 'in', emails)
.where('users.workspaceId', '=', workspaceId)
.where('users.workspaceId', '=', workspace.id)
.execute();
let existingUserEmails = [];
@@ -121,7 +122,7 @@ export class WorkspaceInvitationService {
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', groupIds)
.where('groups.workspaceId', '=', workspaceId)
.where('groups.workspaceId', '=', workspace.id)
.execute();
}
@@ -129,7 +130,7 @@ export class WorkspaceInvitationService {
email: email,
role: role,
token: nanoIdGen(16),
workspaceId: workspaceId,
workspaceId: workspace.id,
invitedById: authUser.id,
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
@@ -156,6 +157,7 @@ export class WorkspaceInvitationService {
invitation.email,
invitation.token,
authUser.name,
workspace.hostname,
);
});
}
@@ -269,13 +271,13 @@ export class WorkspaceInvitationService {
async resendInvitation(
invitationId: string,
workspaceId: string,
workspace: Workspace,
): Promise<void> {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.where('workspaceId', '=', workspace.id)
.executeTakeFirst();
if (!invitation) {
@@ -284,7 +286,7 @@ export class WorkspaceInvitationService {
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspaceId,
workspace.id,
);
await this.sendInvitationMail(
@@ -292,6 +294,7 @@ export class WorkspaceInvitationService {
invitation.email,
invitation.token,
invitedByUser.name,
workspace.hostname,
);
}
@@ -308,17 +311,23 @@ export class WorkspaceInvitationService {
async getInvitationLinkById(
invitationId: string,
workspaceId: string,
workspace: Workspace,
): Promise<string> {
const token = await this.getInvitationTokenById(invitationId, workspaceId);
return this.buildInviteLink(invitationId, token.token);
const token = await this.getInvitationTokenById(invitationId, workspace.id);
return this.buildInviteLink({
invitationId,
inviteToken: token.token,
hostname: workspace.hostname,
});
}
async buildInviteLink(
invitationId: string,
inviteToken: string,
): Promise<string> {
return `${this.environmentService.getAppUrl()}/invites/${invitationId}?token=${inviteToken}`;
async buildInviteLink(opts: {
invitationId: string;
inviteToken: string;
hostname?: string;
}): Promise<string> {
const { invitationId, inviteToken, hostname } = opts;
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`;
}
async sendInvitationMail(
@@ -326,8 +335,13 @@ export class WorkspaceInvitationService {
inviteeEmail: string,
inviteToken: string,
invitedByName: string,
hostname?: string,
): Promise<void> {
const inviteLink = await this.buildInviteLink(invitationId, inviteToken);
const inviteLink = await this.buildInviteLink({
invitationId,
inviteToken,
hostname,
});
const emailTemplate = InvitationEmail({
inviteLink,
@@ -21,6 +21,11 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { DomainService } from '../../../integrations/environment/domain.service';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
@Injectable()
export class WorkspaceService {
@@ -31,6 +36,8 @@ export class WorkspaceService {
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo,
private environmentService: EnvironmentService,
private domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -50,14 +57,33 @@ export class WorkspaceService {
async getWorkspacePublicData(workspaceId: string) {
const workspace = await this.db
.selectFrom('workspaces')
.select(['id'])
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey'])
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('authProviders')
.select([
'authProviders.id',
'authProviders.name',
'authProviders.type',
])
.where('authProviders.isEnabled', '=', true)
.where('workspaceId', '=', workspaceId),
).as('authProviders'),
)
.where('id', '=', workspaceId)
.executeTakeFirst();
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
return workspace;
const { licenseKey, ...rest } = workspace;
return {
...rest,
hasLicenseKey: Boolean(licenseKey),
};
}
async create(
@@ -68,12 +94,30 @@ export class WorkspaceService {
return await executeTx(
this.db,
async (trx) => {
let hostname = undefined;
let trialEndAt = undefined;
let status = undefined;
let plan = undefined;
if (this.environmentService.isCloud()) {
// generate unique hostname
hostname = await this.generateHostname(
createWorkspaceDto.hostname ?? createWorkspaceDto.name,
);
trialEndAt = addDays(new Date(), 14);
status = WorkspaceStatus.Active;
plan = 'standard';
}
// create workspace
const workspace = await this.workspaceRepo.insertWorkspace(
{
name: createWorkspaceDto.name,
hostname: createWorkspaceDto.hostname,
description: createWorkspaceDto.description,
hostname,
status,
trialEndAt,
plan,
},
trx,
);
@@ -91,6 +135,7 @@ export class WorkspaceService {
workspaceId: workspace.id,
role: UserRole.OWNER,
})
.where('users.id', '=', user.id)
.execute();
// add user to default group created above
@@ -182,21 +227,54 @@ export class WorkspaceService {
}
async update(workspaceId: string, updateWorkspaceDto: UpdateWorkspaceDto) {
const workspace = await this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
if (updateWorkspaceDto.enforceSso) {
const sso = await this.db
.selectFrom('authProviders')
.selectAll()
.where('isEnabled', '=', true)
.where('workspaceId', '=', workspaceId)
.execute();
if (sso && sso?.length === 0) {
throw new BadRequestException(
'There must be at least one active SSO provider to enforce SSO.',
);
}
}
if (updateWorkspaceDto.name) {
workspace.name = updateWorkspaceDto.name;
if (updateWorkspaceDto.emailDomains) {
const regex =
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
const emailDomains = updateWorkspaceDto.emailDomains || [];
updateWorkspaceDto.emailDomains = emailDomains
.map((domain) => regex.exec(domain)?.[0])
.filter(Boolean);
}
if (updateWorkspaceDto.logo) {
workspace.logo = updateWorkspaceDto.logo;
if (updateWorkspaceDto.hostname) {
const hostname = updateWorkspaceDto.hostname;
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
throw new BadRequestException('Hostname already exists.');
}
if (await this.workspaceRepo.hostnameExists(hostname)) {
throw new BadRequestException('Hostname already exists.');
}
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
return workspace;
const workspace = await this.workspaceRepo.findById(workspaceId, {
withMemberCount: true,
withLicenseKey: true,
});
const { licenseKey, ...rest } = workspace;
return {
...rest,
hasLicenseKey: Boolean(licenseKey),
};
}
async getWorkspaceUsers(
@@ -256,7 +334,53 @@ export class WorkspaceService {
);
}
async deactivateUser(): Promise<any> {
return 'todo';
async generateHostname(
name: string,
trx?: KyselyTransaction,
): Promise<string> {
const generateRandomSuffix = (length: number) =>
Math.random()
.toFixed(length)
.substring(2, 2 + length);
let subdomain = name
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.substring(0, 20);
// Ensure we leave room for a random suffix.
const maxSuffixLength = 3;
if (subdomain.length < 4) {
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
}
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
subdomain = `myworkspace-${generateRandomSuffix(maxSuffixLength)}`;
}
let uniqueHostname = subdomain;
while (true) {
const exists = await this.workspaceRepo.hostnameExists(
uniqueHostname,
trx,
);
if (!exists) {
break;
}
// Append a random suffix and retry.
const randomSuffix = generateRandomSuffix(maxSuffixLength);
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
}
return uniqueHostname;
}
async checkHostname(hostname: string) {
const exists = await this.workspaceRepo.hostnameExists(hostname);
if (!exists) {
throw new NotFoundException('Hostname not found');
}
return { hostname: this.domainService.getUrl(hostname) };
}
}
@@ -0,0 +1,117 @@
export enum WorkspaceStatus {
Active = 'active',
Suspended = 'suspended',
}
export const DISALLOWED_HOSTNAMES = [
'app',
'help',
'account',
'billing',
'docs',
'blog',
'status',
'payment',
'updates',
'license',
'customer',
'customers',
'dashboard',
'docmost',
'support',
'admin',
'about',
'team',
'analytics',
'data',
'dev',
'development',
'staging',
'wiki',
'www',
'login',
'signup',
'signin',
'register',
'abuse',
'general',
'update',
'updates',
'upgrade',
'upgrades',
'api',
'server',
'servers',
'service',
'user',
'upload',
'uploads',
'version',
'translate',
'translation',
'translations',
'translator',
'setup',
'share',
'setting',
'settings',
'security',
'shop',
'store',
'prod',
'plan',
'plans',
'plugin',
'plugins',
'mail',
'email',
'checkout',
'checkouts',
'client',
'career',
'job',
'jobs',
'careers',
'account',
'accounts',
'host',
'connect',
'contact',
'core',
'embed',
'founder',
'guide',
'guides',
'smtp',
'imap',
'lab',
'collab',
'collaboration',
'ws',
'websocket',
'community',
'forum',
'forums',
'wikis',
'files',
'app',
'assets',
'news',
'jobs',
'careers',
'can',
'demo',
'logs',
'dash',
'auth',
'organization',
'org',
'db',
'database',
'notes',
'download',
'workspace',
'space',
'group',
'members',
];