mirror of
https://github.com/docmost/docmost.git
synced 2025-11-19 08:41:11 +10:00
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:
@ -1,9 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
@ -24,6 +24,7 @@ import { PasswordResetDto } from './dto/password-reset.dto';
|
||||
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { addDays } from 'date-fns';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@ -35,14 +36,13 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
async login(
|
||||
@Req() req,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() loginInput: LoginDto,
|
||||
) {
|
||||
const authToken = await this.authService.login(
|
||||
loginInput,
|
||||
req.raw.workspaceId,
|
||||
);
|
||||
validateSsoEnforcement(workspace);
|
||||
|
||||
const authToken = await this.authService.login(loginInput, workspace.id);
|
||||
this.setAuthCookie(res, authToken);
|
||||
}
|
||||
|
||||
@ -53,10 +53,11 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() createAdminUserDto: CreateAdminUserDto,
|
||||
) {
|
||||
if (this.environmentService.isCloud()) throw new NotFoundException();
|
||||
const { workspace, authToken } =
|
||||
await this.authService.setup(createAdminUserDto);
|
||||
|
||||
const authToken = await this.authService.setup(createAdminUserDto);
|
||||
this.setAuthCookie(res, authToken);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -76,7 +77,8 @@ export class AuthController {
|
||||
@Body() forgotPasswordDto: ForgotPasswordDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.authService.forgotPassword(forgotPasswordDto, workspace.id);
|
||||
validateSsoEnforcement(workspace);
|
||||
return this.authService.forgotPassword(forgotPasswordDto, workspace);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -10,5 +10,6 @@ import { TokenModule } from './token.module';
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [SignupService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
8
apps/server/src/core/auth/auth.util.ts
Normal file
8
apps/server/src/core/auth/auth.util.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
export function validateSsoEnforcement(workspace: Workspace) {
|
||||
if (workspace.enforceSso) {
|
||||
throw new BadRequestException('This workspace has enforced SSO login.');
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@ -22,6 +22,7 @@ export class CreateUserDto {
|
||||
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export enum JwtType {
|
||||
ACCESS = 'access',
|
||||
COLLAB = 'collab',
|
||||
EXCHANGE = 'exchange',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@ -14,3 +15,9 @@ export type JwtCollabPayload = {
|
||||
workspaceId: string;
|
||||
type: 'collab';
|
||||
};
|
||||
|
||||
export type JwtExchangePayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
type: 'exchange';
|
||||
};
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
import { CanActivate, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class SetupGuard implements CanActivate {
|
||||
constructor(private workspaceRepo: WorkspaceRepo) {}
|
||||
constructor(
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async canActivate(): Promise<boolean> {
|
||||
if (this.environmentService.isCloud()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceCount = await this.workspaceRepo.count();
|
||||
if (workspaceCount > 0) {
|
||||
throw new ForbiddenException('Workspace setup already completed.');
|
||||
|
||||
@ -22,13 +22,13 @@ import { ForgotPasswordDto } from '../dto/forgot-password.dto';
|
||||
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
|
||||
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
|
||||
import { PasswordResetDto } from '../dto/password-reset.dto';
|
||||
import { UserToken } from '@docmost/db/types/entity.types';
|
||||
import { UserToken, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { UserTokenType } from '../auth.constants';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { DomainService } from '../../../integrations/environment/domain.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -38,7 +38,7 @@ export class AuthService {
|
||||
private userRepo: UserRepo,
|
||||
private userTokenRepo: UserTokenRepo,
|
||||
private mailService: MailService,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@ -46,7 +46,9 @@ export class AuthService {
|
||||
const user = await this.userRepo.findByEmail(
|
||||
loginDto.email,
|
||||
workspaceId,
|
||||
true,
|
||||
{
|
||||
includePassword: true
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
@ -68,8 +70,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async setup(createAdminUserDto: CreateAdminUserDto) {
|
||||
const user = await this.signupService.initialSetup(createAdminUserDto);
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
const { workspace, user } =
|
||||
await this.signupService.initialSetup(createAdminUserDto);
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
return { workspace, authToken };
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
@ -113,11 +118,11 @@ export class AuthService {
|
||||
|
||||
async forgotPassword(
|
||||
forgotPasswordDto: ForgotPasswordDto,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findByEmail(
|
||||
forgotPasswordDto.email,
|
||||
workspaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
@ -125,7 +130,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const token = nanoIdGen(16);
|
||||
const resetLink = `${this.environmentService.getAppUrl()}/password-reset?token=${token}`;
|
||||
|
||||
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
|
||||
|
||||
await this.userTokenRepo.insertUserToken({
|
||||
token: token,
|
||||
@ -199,7 +205,7 @@ export class AuthService {
|
||||
userTokenDto: VerifyUserTokenDto,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const userToken = await this.userTokenRepo.findById(
|
||||
const userToken: UserToken = await this.userTokenRepo.findById(
|
||||
userTokenDto.token,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
|
||||
@ -32,7 +32,7 @@ export class SignupService {
|
||||
|
||||
if (userCheck) {
|
||||
throw new BadRequestException(
|
||||
'You already have an account on this workspace',
|
||||
'An account with this email already exists in this workspace',
|
||||
);
|
||||
}
|
||||
|
||||
@ -72,11 +72,14 @@ export class SignupService {
|
||||
createAdminUserDto: CreateAdminUserDto,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
return await executeTx(
|
||||
let user: User,
|
||||
workspace: Workspace = null;
|
||||
|
||||
await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
// create user
|
||||
const user = await this.userRepo.insertUser(
|
||||
user = await this.userRepo.insertUser(
|
||||
{
|
||||
name: createAdminUserDto.name,
|
||||
email: createAdminUserDto.email,
|
||||
@ -92,7 +95,7 @@ export class SignupService {
|
||||
name: createAdminUserDto.workspaceName,
|
||||
};
|
||||
|
||||
const workspace = await this.workspaceService.create(
|
||||
workspace = await this.workspaceService.create(
|
||||
user,
|
||||
workspaceData,
|
||||
trx,
|
||||
@ -103,5 +106,7 @@ export class SignupService {
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { JwtCollabPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import {
|
||||
JwtCollabPayload,
|
||||
JwtExchangePayload,
|
||||
JwtPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
@ -34,9 +39,29 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, { expiresIn });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string) {
|
||||
return this.jwtService.verifyAsync(token, {
|
||||
async generateExchangeToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtExchangePayload = {
|
||||
sub: userId,
|
||||
workspaceId: workspaceId,
|
||||
type: JwtType.EXCHANGE,
|
||||
};
|
||||
return this.jwtService.sign(payload, { expiresIn: '10s' });
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
});
|
||||
|
||||
if (payload.type !== tokenType) {
|
||||
throw new UnauthorizedException(
|
||||
'Invalid JWT token. Token type does not match.',
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
// CLOUD ENV
|
||||
if (this.environmentService.isCloud()) {
|
||||
if (req.raw.workspaceId && req.raw.workspaceId !== payload.workspaceId) {
|
||||
throw new BadRequestException('Workspace does not match');
|
||||
throw new UnauthorizedException('Workspace does not match');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,19 +12,35 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('me')
|
||||
async getUserIno(
|
||||
async getUserInfo(
|
||||
@AuthUser() authUser: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { user: authUser, workspace };
|
||||
const memberCount = await this.workspaceRepo.getActiveUserCount(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
|
||||
const workspaceInfo = {
|
||||
...rest,
|
||||
memberCount,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
|
||||
return { user: authUser, workspace: workspaceInfo };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -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 };
|
||||
|
||||
8
apps/server/src/core/workspace/dto/check-hostname.dto.ts
Normal file
8
apps/server/src/core/workspace/dto/check-hostname.dto.ts
Normal file
@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
117
apps/server/src/core/workspace/workspace.constants.ts
Normal file
117
apps/server/src/core/workspace/workspace.constants.ts
Normal file
@ -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',
|
||||
];
|
||||
Reference in New Issue
Block a user