mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-12 15:52:32 +10:00
implement new invitation system
* fix comments on the frontend * move jwt token service to its own module * other fixes and updates
This commit is contained in:
@ -18,6 +18,7 @@
|
||||
"migration:down": "tsx ./src/kysely/migrate.ts down",
|
||||
"migration:latest": "tsx ./src/kysely/migrate.ts latest",
|
||||
"migration:redo": "tsx ./src/kysely/migrate.ts redo",
|
||||
"migration:reset": "tsx ./src/kysely/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
@ -30,7 +31,6 @@
|
||||
"@aws-sdk/client-s3": "^3.565.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.565.0",
|
||||
"@casl/ability": "^6.7.1",
|
||||
"@docmost/transactional": "workspace:^",
|
||||
"@fastify/multipart": "^8.2.0",
|
||||
"@fastify/static": "^7.0.3",
|
||||
"@nestjs/bullmq": "^10.1.1",
|
||||
@ -53,10 +53,12 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"fastify": "^4.26.2",
|
||||
"fix-esm": "^1.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely-migration-cli": "^0.4.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-kysely": "^0.1.7",
|
||||
"nodemailer": "^6.9.13",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { AuthModule } from '../core/auth/auth.module';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
@ -8,6 +7,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { WebSocket } from 'ws';
|
||||
import { HistoryExtension } from './extensions/history.extension';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@ -16,7 +16,7 @@ import { HistoryExtension } from './extensions/history.extension';
|
||||
PersistenceExtension,
|
||||
HistoryExtension,
|
||||
],
|
||||
imports: [AuthModule],
|
||||
imports: [TokenModule],
|
||||
})
|
||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
private collabWsAdapter: CollabWsAdapter;
|
||||
|
||||
@ -1,32 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { SignupService } from './services/signup.service';
|
||||
import { GroupModule } from '../group/group.module';
|
||||
import { TokenModule } from './token.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
return {
|
||||
secret: environmentService.getJwtSecret(),
|
||||
signOptions: {
|
||||
expiresIn: environmentService.getJwtTokenExpiresIn(),
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
WorkspaceModule,
|
||||
GroupModule,
|
||||
],
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, TokenService, JwtStrategy],
|
||||
exports: [TokenService],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@MinLength(3)
|
||||
@MaxLength(35)
|
||||
@MinLength(2)
|
||||
@MaxLength(60)
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
||||
@ -3,19 +3,19 @@ import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { WorkspaceService } from '../../workspace/services/workspace.service';
|
||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
||||
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
|
||||
import { GroupUserService } from '../../group/services/group-user.service';
|
||||
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 { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
private workspaceService: WorkspaceService,
|
||||
private groupUserService: GroupUserService,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@ -56,7 +56,7 @@ export class SignupService {
|
||||
);
|
||||
|
||||
// add user to default group
|
||||
await this.groupUserService.addUserToDefaultGroup(
|
||||
await this.groupUserRepo.addUserToDefaultGroup(
|
||||
user.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
|
||||
24
apps/server/src/core/auth/token.module.ts
Normal file
24
apps/server/src/core/auth/token.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
return {
|
||||
secret: environmentService.getAppSecret(),
|
||||
signOptions: {
|
||||
expiresIn: environmentService.getJwtTokenExpiresIn(),
|
||||
issuer: 'Docmost',
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
],
|
||||
providers: [TokenService],
|
||||
exports: [TokenService],
|
||||
})
|
||||
export class TokenModule {}
|
||||
@ -11,7 +11,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
export type Subjects =
|
||||
| 'Workspace'
|
||||
| 'WorkspaceInvitation'
|
||||
| 'Space'
|
||||
| 'SpaceMember'
|
||||
| 'Group'
|
||||
@ -36,8 +35,6 @@ export default class CaslAbilityFactory {
|
||||
can([Action.Manage], 'Workspace');
|
||||
can([Action.Manage], 'WorkspaceUser');
|
||||
|
||||
can([Action.Manage], 'WorkspaceInvitation');
|
||||
|
||||
// Groups
|
||||
can([Action.Manage], 'Group');
|
||||
can([Action.Manage], 'GroupUser');
|
||||
|
||||
@ -66,8 +66,7 @@ export class CommentService {
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
// return created comment and creator relation
|
||||
return this.findById(createdComment.id);
|
||||
return createdComment;
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
@ -114,7 +113,12 @@ export class CommentService {
|
||||
return comment;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.commentRepo.deleteComment(id);
|
||||
async remove(commentId: string): Promise<void> {
|
||||
const comment = await this.commentRepo.findById(commentId);
|
||||
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
await this.commentRepo.deleteComment(commentId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,9 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
import { GroupIdDto } from './group-id.dto';
|
||||
|
||||
export class AddGroupUserDto extends GroupIdDto {
|
||||
// @IsOptional()
|
||||
// @IsUUID()
|
||||
// userId: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50, {
|
||||
message: 'userIds must an array with no more than 50 elements',
|
||||
message: 'you cannot add more than 50 users at a time',
|
||||
})
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID(4, { each: true })
|
||||
|
||||
@ -7,17 +7,14 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { GroupService } from './group.service';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserService {
|
||||
constructor(
|
||||
private groupRepo: GroupRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private userRepo: UserRepo,
|
||||
@Inject(forwardRef(() => GroupService))
|
||||
@ -40,24 +37,6 @@ export class GroupUserService {
|
||||
return groupUsers;
|
||||
}
|
||||
|
||||
async addUserToDefaultGroup(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
const defaultGroup = await this.groupRepo.getDefaultGroup(
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
await this.addUserToGroup(userId, defaultGroup.id, workspaceId, trx);
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
async addUsersToGroupBatch(
|
||||
userIds: string[],
|
||||
groupId: string,
|
||||
@ -90,48 +69,6 @@ export class GroupUserService {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async addUserToGroup(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
await this.groupService.findAndValidateGroup(groupId, workspaceId);
|
||||
const user = await this.userRepo.findById(userId, workspaceId, {
|
||||
trx: trx,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const groupUserExists = await this.groupUserRepo.getGroupUserById(
|
||||
userId,
|
||||
groupId,
|
||||
trx,
|
||||
);
|
||||
|
||||
if (groupUserExists) {
|
||||
throw new BadRequestException(
|
||||
'User is already a member of this group',
|
||||
);
|
||||
}
|
||||
|
||||
await this.groupUserRepo.insertGroupUser(
|
||||
{
|
||||
userId,
|
||||
groupId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
async removeUserFromGroup(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
|
||||
@ -4,42 +4,22 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { AuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private userRepo: UserRepo,
|
||||
) {}
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('me')
|
||||
async getUser(
|
||||
@AuthUser() authUser: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const user = await this.userRepo.findById(authUser.id, workspace.id);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid user');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async getUserIno(
|
||||
@AuthUser() authUser: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
|
||||
@ -4,19 +4,20 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { WorkspaceService } from '../services/workspace.service';
|
||||
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||
import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto';
|
||||
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||
import { AuthUser } from '../../../decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator';
|
||||
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { WorkspaceInvitationService } from '../services/workspace-invitation.service';
|
||||
import { Public } from '../../../decorators/public.decorator';
|
||||
import {
|
||||
AcceptInviteDto,
|
||||
InvitationIdDto,
|
||||
InviteUserDto,
|
||||
RevokeInviteDto,
|
||||
} from '../dto/invitation.dto';
|
||||
@ -24,7 +25,6 @@ import { Action } from '../../casl/ability.action';
|
||||
import { CheckPolicies } from '../../casl/decorators/policies.decorator';
|
||||
import { AppAbility } from '../../casl/abilities/casl-ability.factory';
|
||||
import { PoliciesGuard } from '../../casl/guards/policies.guard';
|
||||
import { WorkspaceUserService } from '../services/workspace-user.service';
|
||||
import { JwtAuthGuard } from '../../../guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
@ -33,7 +33,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
export class WorkspaceController {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceUserService: WorkspaceUserService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
) {}
|
||||
|
||||
@ -59,16 +58,6 @@ export class WorkspaceController {
|
||||
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
|
||||
}
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies((ability: AppAbility) =>
|
||||
ability.can(Action.Manage, 'Workspace'),
|
||||
)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async deleteWorkspace(@Body() deleteWorkspaceDto: DeleteWorkspaceDto) {
|
||||
// return this.workspaceService.delete(deleteWorkspaceDto);
|
||||
}
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies((ability: AppAbility) =>
|
||||
ability.can(Action.Read, 'WorkspaceUser'),
|
||||
@ -80,10 +69,7 @@ export class WorkspaceController {
|
||||
pagination: PaginationOptions,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.workspaceUserService.getWorkspaceUsers(
|
||||
workspace.id,
|
||||
pagination,
|
||||
);
|
||||
return this.workspaceService.getWorkspaceUsers(workspace.id, pagination);
|
||||
}
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@ -93,7 +79,7 @@ export class WorkspaceController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('members/deactivate')
|
||||
async deactivateWorkspaceMember() {
|
||||
return this.workspaceUserService.deactivateUser();
|
||||
return this.workspaceService.deactivateUser();
|
||||
}
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@ -107,7 +93,7 @@ export class WorkspaceController {
|
||||
@AuthUser() authUser: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.workspaceUserService.updateWorkspaceUserRole(
|
||||
return this.workspaceService.updateWorkspaceUserRole(
|
||||
authUser,
|
||||
workspaceUserRoleDto,
|
||||
workspace.id,
|
||||
@ -116,37 +102,91 @@ export class WorkspaceController {
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies((ability: AppAbility) =>
|
||||
ability.can(Action.Manage, 'WorkspaceInvitation'),
|
||||
ability.can(Action.Read, 'WorkspaceUser'),
|
||||
)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invite')
|
||||
async inviteUser(
|
||||
@Body() inviteUserDto: InviteUserDto,
|
||||
@AuthUser() authUser: User,
|
||||
@Post('invites')
|
||||
async getInvitations(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Body()
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
/* return this.workspaceInvitationService.createInvitation(
|
||||
authUser,
|
||||
return this.workspaceInvitationService.getInvitations(
|
||||
workspace.id,
|
||||
inviteUserDto,
|
||||
);*/
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invite/accept')
|
||||
async acceptInvite(@Body() acceptInviteDto: AcceptInviteDto) {
|
||||
// return this.workspaceInvitationService.acceptInvitation(
|
||||
// acceptInviteDto.invitationId,
|
||||
//);
|
||||
@Post('invites/info')
|
||||
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
|
||||
return this.workspaceInvitationService.getInvitationById(
|
||||
dto.invitationId,
|
||||
req.raw.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: authorize permission with guards
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies((ability: AppAbility) =>
|
||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
||||
)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invite/revoke')
|
||||
async revokeInvite(@Body() revokeInviteDto: RevokeInviteDto) {
|
||||
// return this.workspaceInvitationService.revokeInvitation(
|
||||
// revokeInviteDto.invitationId,
|
||||
// );
|
||||
@Post('invites/create')
|
||||
async inviteUser(
|
||||
@Body() inviteUserDto: InviteUserDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthUser() authUser: User,
|
||||
) {
|
||||
return this.workspaceInvitationService.createInvitation(
|
||||
inviteUserDto,
|
||||
workspace.id,
|
||||
authUser,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies((ability: AppAbility) =>
|
||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
||||
)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invites/resend')
|
||||
async resendInvite(
|
||||
@Body() revokeInviteDto: RevokeInviteDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.workspaceInvitationService.resendInvitation(
|
||||
revokeInviteDto.invitationId,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies((ability: AppAbility) =>
|
||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
||||
)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invites/revoke')
|
||||
async revokeInvite(
|
||||
@Body() revokeInviteDto: RevokeInviteDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.workspaceInvitationService.revokeInvitation(
|
||||
revokeInviteDto.invitationId,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invites/accept')
|
||||
async acceptInvite(
|
||||
@Body() acceptInviteDto: AcceptInviteDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.workspaceInvitationService.acceptInvitation(
|
||||
acceptInviteDto,
|
||||
req.raw.workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class AddWorkspaceUserDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
role: string;
|
||||
}
|
||||
@ -1,13 +1,35 @@
|
||||
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../../../helpers/types/permission';
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name: string;
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50, {
|
||||
message: 'you cannot invite more than 50 users at a time',
|
||||
})
|
||||
@ArrayMinSize(1)
|
||||
@IsEmail({}, { each: true })
|
||||
emails: string[];
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(25, {
|
||||
message: 'you cannot add invited users to more than 25 groups at a time',
|
||||
})
|
||||
@ArrayMinSize(0)
|
||||
@IsUUID(4, { each: true })
|
||||
groupIds: string[];
|
||||
|
||||
@IsEnum(UserRole)
|
||||
role: string;
|
||||
@ -18,6 +40,19 @@ export class InvitationIdDto {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
export class AcceptInviteDto extends InvitationIdDto {}
|
||||
export class AcceptInviteDto extends InvitationIdDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(60)
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@MinLength(8)
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class RevokeInviteDto extends InvitationIdDto {}
|
||||
|
||||
@ -1,106 +1,318 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { WorkspaceUserService } from './workspace-user.service';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AcceptInviteDto, InviteUserDto } from '../dto/invitation.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import {
|
||||
Group,
|
||||
User,
|
||||
WorkspaceInvitation,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { MailService } from '../../../integrations/mail/mail.service';
|
||||
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||
import { hashPassword } from '../../../helpers';
|
||||
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 '../../../helpers/nanoid.utils';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { TokensDto } from '../../auth/dto/tokens.dto';
|
||||
|
||||
// need reworking
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
private readonly logger = new Logger(WorkspaceInvitationService.name);
|
||||
constructor(
|
||||
private workspaceService: WorkspaceService,
|
||||
private workspaceUserService: WorkspaceUserService,
|
||||
private userService: UserService,
|
||||
private userRepo: UserRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private mailService: MailService,
|
||||
private environmentService: EnvironmentService,
|
||||
private tokenService: TokenService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
/*
|
||||
async findInvitedUserByEmail(
|
||||
email,
|
||||
workspaceId,
|
||||
): Promise<WorkspaceInvitation> {
|
||||
return this.workspaceInvitationRepository.findOneBy({
|
||||
email: email,
|
||||
workspaceId: workspaceId,
|
||||
|
||||
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
|
||||
let query = this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select(['id', 'email', 'role', 'workspaceId', 'createdAt'])
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb('email', 'ilike', `%${pagination.query}%`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = executeWithPagination(query, {
|
||||
page: pagination.page,
|
||||
perPage: pagination.limit,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getInvitationById(invitationId: string, workspaceId: string) {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select(['id', 'email', 'createdAt'])
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
}
|
||||
|
||||
return invitation;
|
||||
}
|
||||
|
||||
async createInvitation(
|
||||
authUser: User,
|
||||
workspaceId: string,
|
||||
inviteUserDto: InviteUserDto,
|
||||
): Promise<WorkspaceInvitation> {
|
||||
// check if invited user is already a workspace member
|
||||
const invitedUser =
|
||||
await this.workspaceUserService.findWorkspaceUserByEmail(
|
||||
inviteUserDto.email,
|
||||
workspaceId,
|
||||
);
|
||||
workspaceId: string,
|
||||
authUser: User,
|
||||
): Promise<void> {
|
||||
const { emails, role, groupIds } = inviteUserDto;
|
||||
|
||||
if (invitedUser) {
|
||||
let invites: WorkspaceInvitation[] = [];
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
// we do not want to invite existing members
|
||||
const findExistingUsers = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.where('users.email', 'in', emails)
|
||||
.where('users.workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
let existingUserEmails = [];
|
||||
if (findExistingUsers) {
|
||||
existingUserEmails = findExistingUsers.map((user) => user.email);
|
||||
}
|
||||
|
||||
// filter out existing users
|
||||
const inviteEmails = emails.filter(
|
||||
(email) => !existingUserEmails.includes(email),
|
||||
);
|
||||
|
||||
let validGroups = [];
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
validGroups = await trx
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name'])
|
||||
.where('groups.id', 'in', groupIds)
|
||||
.where('groups.workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const invitesToInsert = inviteEmails.map((email) => ({
|
||||
email: email,
|
||||
role: role,
|
||||
token: nanoIdGen(16),
|
||||
workspaceId: workspaceId,
|
||||
invitedById: authUser.id,
|
||||
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
|
||||
}));
|
||||
|
||||
invites = await trx
|
||||
.insertInto('workspaceInvitations')
|
||||
.values(invitesToInsert)
|
||||
.onConflict((oc) => oc.columns(['email', 'workspaceId']).doNothing())
|
||||
.returningAll()
|
||||
.execute();
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`createInvitation - ${err}`);
|
||||
throw new BadRequestException(
|
||||
'User is already a member of this workspace',
|
||||
'An error occurred while processing the invitations.',
|
||||
);
|
||||
}
|
||||
|
||||
// check if user was already invited
|
||||
const existingInvitation = await this.findInvitedUserByEmail(
|
||||
inviteUserDto.email,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (existingInvitation) {
|
||||
throw new BadRequestException('User has already been invited');
|
||||
// do not send code to do nothing users
|
||||
if (invites) {
|
||||
invites.forEach((invitation: WorkspaceInvitation) => {
|
||||
this.sendInvitationMail(
|
||||
invitation.id,
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
authUser.name,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const invitation = new WorkspaceInvitation();
|
||||
invitation.workspaceId = workspaceId;
|
||||
invitation.email = inviteUserDto.email;
|
||||
invitation.role = inviteUserDto.role;
|
||||
invitation.invitedById = authUser.id;
|
||||
|
||||
// TODO: send invitation email
|
||||
|
||||
return await this.workspaceInvitationRepository.save(invitation);
|
||||
}
|
||||
|
||||
async acceptInvitation(invitationId: string) {
|
||||
const invitation = await this.workspaceInvitationRepository.findOneBy({
|
||||
id: invitationId,
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new BadRequestException('Invalid or expired invitation code');
|
||||
}
|
||||
|
||||
// TODO: to be completed
|
||||
|
||||
// check if user is already a member
|
||||
const invitedUser =
|
||||
await this.workspaceUserService.findWorkspaceUserByEmail(
|
||||
invitation.email,
|
||||
invitation.workspaceId,
|
||||
);
|
||||
|
||||
if (invitedUser) {
|
||||
throw new BadRequestException(
|
||||
'User is already a member of this workspace',
|
||||
);
|
||||
}
|
||||
// add create account for user
|
||||
// add the user to the workspace
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async revokeInvitation(invitationId: string): Promise<void> {
|
||||
const invitation = await this.workspaceInvitationRepository.findOneBy({
|
||||
id: invitationId,
|
||||
});
|
||||
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.selectAll()
|
||||
.where('id', '=', dto.invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
throw new BadRequestException('Invitation not found');
|
||||
}
|
||||
|
||||
await this.workspaceInvitationRepository.delete(invitationId);
|
||||
if (dto.token !== invitation.token) {
|
||||
throw new BadRequestException('Invalid invitation token');
|
||||
}
|
||||
|
||||
const password = await hashPassword(dto.password);
|
||||
let newUser: User;
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
newUser = await trx
|
||||
.insertInto('users')
|
||||
.values({
|
||||
name: dto.name,
|
||||
email: invitation.email,
|
||||
password: password,
|
||||
workspaceId: workspaceId,
|
||||
role: invitation.role,
|
||||
lastLoginAt: new Date(),
|
||||
invitedById: invitation.invitedById,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
// add user to default group
|
||||
await this.groupUserRepo.addUserToDefaultGroup(
|
||||
newUser.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
if (invitation.groupIds && invitation.groupIds.length > 0) {
|
||||
// Ensure the groups are valid
|
||||
const validGroups = await trx
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name'])
|
||||
.where('groups.id', 'in', invitation.groupIds)
|
||||
.where('groups.workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (validGroups && validGroups.length > 0) {
|
||||
const groupUsersToInsert = validGroups.map((group) => ({
|
||||
userId: newUser.id,
|
||||
groupId: group.id,
|
||||
}));
|
||||
|
||||
// add user to groups specified during invite
|
||||
await trx
|
||||
.insertInto('groupUsers')
|
||||
.values(groupUsersToInsert)
|
||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
// delete invitation record
|
||||
await trx
|
||||
.deleteFrom('workspaceInvitations')
|
||||
.where('id', '=', invitation.id)
|
||||
.execute();
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(`acceptInvitation - ${err}`);
|
||||
if (err.message.includes('unique constraint')) {
|
||||
throw new BadRequestException('Invitation already accepted');
|
||||
}
|
||||
throw new BadRequestException(
|
||||
'Failed to accept invitation. An error occurred.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!newUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// notify the inviter
|
||||
const invitedByUser = await this.userRepo.findById(
|
||||
invitation.invitedById,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (invitedByUser) {
|
||||
const emailTemplate = InvitationAcceptedEmail({
|
||||
invitedUserName: newUser.name,
|
||||
invitedUserEmail: newUser.email,
|
||||
});
|
||||
|
||||
await this.mailService.sendToQueue({
|
||||
to: invitation.email,
|
||||
subject: `${newUser.name} has accepted your Docmost invite`,
|
||||
template: emailTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
const tokens: TokensDto = await this.tokenService.generateTokens(newUser);
|
||||
return { tokens };
|
||||
}
|
||||
|
||||
*/
|
||||
async resendInvitation(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
//
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.selectAll()
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
throw new BadRequestException('Invitation not found');
|
||||
}
|
||||
|
||||
const invitedByUser = await this.userRepo.findById(
|
||||
invitation.invitedById,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.sendInvitationMail(
|
||||
invitation.id,
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
invitedByUser.name,
|
||||
);
|
||||
}
|
||||
|
||||
async revokeInvitation(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('workspaceInvitations')
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async sendInvitationMail(
|
||||
invitationId: string,
|
||||
inviteeEmail: string,
|
||||
inviteToken: string,
|
||||
invitedByName: string,
|
||||
): Promise<void> {
|
||||
const inviteLink = `${this.environmentService.getAppUrl()}/invites/${invitationId}?token=${inviteToken}`;
|
||||
|
||||
const emailTemplate = InvitationEmail({
|
||||
inviteLink,
|
||||
});
|
||||
|
||||
await this.mailService.sendToQueue({
|
||||
to: inviteeEmail,
|
||||
subject: `${invitedByName} invited you to Docmost`,
|
||||
template: emailTemplate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
|
||||
import { UserRole } from '../../../helpers/types/permission';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceUserService {
|
||||
constructor(private userRepo: UserRepo) {}
|
||||
|
||||
async getWorkspaceUsers(
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<PaginationResult<User>> {
|
||||
const users = await this.userRepo.getUsersPaginated(
|
||||
workspaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async updateWorkspaceUserRole(
|
||||
authUser: User,
|
||||
userRoleDto: UpdateWorkspaceUserRoleDto,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
if (user.role === userRoleDto.role) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
role: userRoleDto.role,
|
||||
},
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateUser(): Promise<any> {
|
||||
return 'todo';
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@ import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||
import { SpaceService } from '../../space/services/space.service';
|
||||
import { CreateSpaceDto } from '../../space/dto/create-space.dto';
|
||||
import { SpaceRole, UserRole } from '../../../helpers/types/permission';
|
||||
import { GroupService } from '../../group/services/group.service';
|
||||
import { SpaceMemberService } from '../../space/services/space-member.service';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
@ -16,6 +15,11 @@ import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@ -23,8 +27,9 @@ export class WorkspaceService {
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private spaceService: SpaceService,
|
||||
private spaceMemberService: SpaceMemberService,
|
||||
private groupService: GroupService,
|
||||
private groupRepo: GroupRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private userRepo: UserRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@ -33,7 +38,6 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
async getWorkspaceInfo(workspaceId: string) {
|
||||
// todo: add member count
|
||||
const workspace = this.workspaceRepo.findById(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
@ -61,11 +65,10 @@ export class WorkspaceService {
|
||||
);
|
||||
|
||||
// create default group
|
||||
const group = await this.groupService.createDefaultGroup(
|
||||
workspace.id,
|
||||
user.id,
|
||||
trx,
|
||||
);
|
||||
const group = await this.groupRepo.createDefaultGroup(workspace.id, {
|
||||
userId: user.id,
|
||||
trx: trx,
|
||||
});
|
||||
|
||||
// add user to workspace
|
||||
await trx
|
||||
@ -181,11 +184,54 @@ export class WorkspaceService {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async delete(workspaceId: string): Promise<void> {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
async getWorkspaceUsers(
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<PaginationResult<User>> {
|
||||
const users = await this.userRepo.getUsersPaginated(
|
||||
workspaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async updateWorkspaceUserRole(
|
||||
authUser: User,
|
||||
userRoleDto: UpdateWorkspaceUserRoleDto,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
//delete
|
||||
|
||||
if (user.role === userRoleDto.role) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
role: userRoleDto.role,
|
||||
},
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateUser(): Promise<any> {
|
||||
return 'todo';
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,18 +3,12 @@ import { WorkspaceService } from './services/workspace.service';
|
||||
import { WorkspaceController } from './controllers/workspace.controller';
|
||||
import { SpaceModule } from '../space/space.module';
|
||||
import { WorkspaceInvitationService } from './services/workspace-invitation.service';
|
||||
import { WorkspaceUserService } from './services/workspace-user.service';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { GroupModule } from '../group/group.module';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
@Module({
|
||||
imports: [SpaceModule, UserModule, GroupModule],
|
||||
imports: [SpaceModule, TokenModule],
|
||||
controllers: [WorkspaceController],
|
||||
providers: [
|
||||
WorkspaceService,
|
||||
WorkspaceUserService,
|
||||
WorkspaceInvitationService,
|
||||
],
|
||||
providers: [WorkspaceService, WorkspaceInvitationService],
|
||||
exports: [WorkspaceService],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
|
||||
5
apps/server/src/helpers/nanoid.utils.ts
Normal file
5
apps/server/src/helpers/nanoid.utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { customAlphabet } = require('fix-esm').require('nanoid');
|
||||
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
export const nanoIdGen = customAlphabet(alphabet, 10);
|
||||
@ -9,9 +9,21 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('NODE_ENV');
|
||||
}
|
||||
|
||||
getAppUrl(): string {
|
||||
return (
|
||||
this.configService.get<string>('APP_URL') ||
|
||||
'http://localhost:' + this.getPort()
|
||||
);
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return parseInt(this.configService.get<string>('PORT'));
|
||||
}
|
||||
|
||||
getAppSecret(): string {
|
||||
return this.configService.get<string>('APP_SECRET');
|
||||
}
|
||||
|
||||
getDatabaseURL(): string {
|
||||
return this.configService.get<string>('DATABASE_URL');
|
||||
}
|
||||
|
||||
@ -7,6 +7,9 @@ export class EnvironmentVariables {
|
||||
|
||||
@IsUrl({ protocols: ['postgres', 'postgresql'], require_tld: false })
|
||||
DATABASE_URL: string;
|
||||
|
||||
@IsString()
|
||||
APP_SECRET: string;
|
||||
}
|
||||
|
||||
export function validate(config: Record<string, any>) {
|
||||
@ -14,7 +17,13 @@ export function validate(config: Record<string, any>) {
|
||||
|
||||
const errors = validateSync(validatedConfig);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.toString());
|
||||
errors.map((error) => {
|
||||
console.error(error.toString());
|
||||
});
|
||||
console.log(
|
||||
'Please fix the environment variables and try again. Shutting down...',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return validatedConfig;
|
||||
}
|
||||
|
||||
@ -18,8 +18,9 @@ export class MailService {
|
||||
|
||||
async sendEmail(message: MailMessage): Promise<void> {
|
||||
if (message.template) {
|
||||
// in case this method is used directly
|
||||
message.html = render(message.template);
|
||||
// in case this method is used directly. we do not send the tsx template from queue
|
||||
message.html = render(message.template, { pretty: true });
|
||||
message.text = render(message.template, { plainText: true });
|
||||
}
|
||||
|
||||
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
|
||||
@ -29,7 +30,10 @@ export class MailService {
|
||||
async sendToQueue(message: MailMessage): Promise<void> {
|
||||
if (message.template) {
|
||||
// transform the React object because it gets lost when sent via the queue
|
||||
message.html = render(message.template);
|
||||
message.html = render(message.template, { pretty: true });
|
||||
message.text = render(message.template, {
|
||||
plainText: true,
|
||||
});
|
||||
delete message.template;
|
||||
}
|
||||
await this.emailQueue.add(QueueJob.SEND_EMAIL, message);
|
||||
|
||||
@ -23,7 +23,7 @@ export const paragraph = {
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
color: '#333',
|
||||
lineHeight: 1.5,
|
||||
lineHeight: 1,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
@ -51,3 +51,16 @@ export const footer = {
|
||||
maxWidth: '580px',
|
||||
margin: '0 auto',
|
||||
};
|
||||
|
||||
export const button = {
|
||||
backgroundColor: '#176ae5',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
|
||||
fontSize: '16px',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'block',
|
||||
width: '100px',
|
||||
padding: '8px',
|
||||
};
|
||||
|
||||
@ -3,11 +3,11 @@ import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface ChangePasswordEmailProps {
|
||||
interface Props {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export const ChangePasswordEmail = ({ username }: ChangePasswordEmailProps) => {
|
||||
export const ChangePasswordEmail = ({ username }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
invitedUserName: string;
|
||||
invitedUserEmail: string;
|
||||
}
|
||||
|
||||
export const InvitationAcceptedEmail = ({
|
||||
invitedUserName,
|
||||
invitedUserEmail,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
{invitedUserName} ({invitedUserEmail}) has accepted your invitation,
|
||||
and is now a member of the workspace.
|
||||
</Text>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitationAcceptedEmail;
|
||||
@ -0,0 +1,37 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const InvitationEmail = ({ inviteLink }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>You have been invited to Docmost.</Text>
|
||||
<Text style={paragraph}>
|
||||
Please click the button below to accept this invitation.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={inviteLink} style={button}>
|
||||
Accept Invite
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitationEmail;
|
||||
@ -40,7 +40,7 @@ export function MailFooter() {
|
||||
<Section style={footer}>
|
||||
<Row>
|
||||
<Text style={{ textAlign: 'center', color: '#706a7b' }}>
|
||||
© {new Date().getFullYear()}, All Rights Reserved <br />
|
||||
© {new Date().getFullYear()} Docmost, All Rights Reserved <br />
|
||||
</Text>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('page_ordering')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn('entity_id', 'uuid', (col) => col.notNull())
|
||||
.addColumn('entity_type', 'varchar', (col) => col.notNull()) // can be page or space
|
||||
.addColumn('children_ids', sql`uuid[]`, (col) => col.notNull())
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.addUniqueConstraint('page_ordering_entity_id_entity_type_unique', [
|
||||
'entity_id',
|
||||
'entity_type',
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_ordering').execute();
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.addColumn('token', 'varchar', (col) => col)
|
||||
.addColumn('group_ids', sql`uuid[]`, (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropColumn('status')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.addUniqueConstraint('invitation_email_workspace_id_unique', [
|
||||
'email',
|
||||
'workspace_id',
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropColumn('token')
|
||||
.execute();
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropColumn('group_ids')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.addColumn('status', 'varchar', (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropConstraint('invitation_email_workspace_id_unique')
|
||||
.execute();
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { type Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('users')
|
||||
.addColumn('invited_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('users').dropColumn('invited_by_id').execute();
|
||||
}
|
||||
@ -1,14 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
||||
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly groupRepo: GroupRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
) {}
|
||||
|
||||
async getGroupUserById(
|
||||
userId: string,
|
||||
@ -62,6 +72,78 @@ export class GroupUserRepo {
|
||||
return result;
|
||||
}
|
||||
|
||||
async addUserToGroup(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
const group = await this.groupRepo.findById(groupId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(userId, workspaceId, {
|
||||
trx: trx,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const groupUserExists = await this.getGroupUserById(
|
||||
userId,
|
||||
groupId,
|
||||
trx,
|
||||
);
|
||||
|
||||
if (groupUserExists) {
|
||||
throw new BadRequestException(
|
||||
'User is already a member of this group',
|
||||
);
|
||||
}
|
||||
|
||||
await this.insertGroupUser(
|
||||
{
|
||||
userId,
|
||||
groupId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
async addUserToDefaultGroup(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
const defaultGroup = await this.groupRepo.getDefaultGroup(
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
await this.insertGroupUser(
|
||||
{
|
||||
userId,
|
||||
groupId: defaultGroup.id,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
async delete(userId: string, groupId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('groupUsers')
|
||||
|
||||
@ -11,6 +11,7 @@ import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GroupRepo {
|
||||
@ -19,9 +20,10 @@ export class GroupRepo {
|
||||
async findById(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount: boolean },
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
): Promise<Group> {
|
||||
return await this.db
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
@ -33,9 +35,10 @@ export class GroupRepo {
|
||||
async findByName(
|
||||
groupName: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount: boolean },
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
): Promise<Group> {
|
||||
return await this.db
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
@ -85,6 +88,21 @@ export class GroupRepo {
|
||||
);
|
||||
}
|
||||
|
||||
async createDefaultGroup(
|
||||
workspaceId: string,
|
||||
opts?: { userId?: string; trx?: KyselyTransaction },
|
||||
): Promise<Group> {
|
||||
const { userId, trx } = opts;
|
||||
const insertableGroup: InsertableGroup = {
|
||||
name: DefaultGroup.EVERYONE,
|
||||
isDefault: true,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
};
|
||||
|
||||
return this.insertGroup(insertableGroup, trx);
|
||||
}
|
||||
|
||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||
let query = this.db
|
||||
.selectFrom('groups')
|
||||
|
||||
@ -93,7 +93,7 @@ export class UserRepo {
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<User> {
|
||||
const user: InsertableUser = {
|
||||
name: insertableUser.name || insertableUser.email.split('@')[0],
|
||||
name: insertableUser.name || insertableUser.email.toLowerCase(),
|
||||
email: insertableUser.email.toLowerCase(),
|
||||
password: await hashPassword(insertableUser.password),
|
||||
locale: 'en',
|
||||
|
||||
19
apps/server/src/kysely/types/db.d.ts
vendored
19
apps/server/src/kysely/types/db.d.ts
vendored
@ -1,15 +1,10 @@
|
||||
import type { ColumnType } from 'kysely';
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
export type Generated<T> =
|
||||
T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Int8 = ColumnType<
|
||||
string,
|
||||
bigint | number | string,
|
||||
bigint | number | string
|
||||
>;
|
||||
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||
|
||||
export type Json = JsonValue;
|
||||
|
||||
@ -151,6 +146,7 @@ export interface Users {
|
||||
email: string;
|
||||
emailVerifiedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
invitedById: string | null;
|
||||
lastActiveAt: Timestamp | null;
|
||||
lastLoginAt: Timestamp | null;
|
||||
locale: string | null;
|
||||
@ -167,10 +163,11 @@ export interface Users {
|
||||
export interface WorkspaceInvitations {
|
||||
createdAt: Generated<Timestamp>;
|
||||
email: string;
|
||||
groupIds: string[] | null;
|
||||
id: Generated<string>;
|
||||
invitedById: string | null;
|
||||
role: string;
|
||||
status: string | null;
|
||||
token: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WsGateway } from './ws.gateway';
|
||||
import { AuthModule } from '../core/auth/auth.module';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [TokenModule],
|
||||
providers: [WsGateway],
|
||||
})
|
||||
export class WsModule {}
|
||||
|
||||
Reference in New Issue
Block a user