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

View File

@ -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)

View File

@ -10,5 +10,6 @@ import { TokenModule } from './token.module';
imports: [TokenModule, WorkspaceModule],
controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy],
exports: [SignupService],
})
export class AuthModule {}

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

View File

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

View File

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

View File

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

View File

@ -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,
);

View File

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

View File

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

View File

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