mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
@@ -6,6 +6,7 @@ import {
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
@@ -54,6 +55,11 @@ import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Controller()
|
||||
export class AttachmentController {
|
||||
@@ -69,6 +75,7 @@ export class AttachmentController {
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -132,6 +139,18 @@ export class AttachmentController {
|
||||
attachmentId: attachmentId,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.ATTACHMENT_UPLOADED,
|
||||
resourceType: AuditResource.ATTACHMENT,
|
||||
resourceId: fileResponse?.id ?? attachmentId,
|
||||
spaceId,
|
||||
metadata: {
|
||||
fileName: fileResponse?.fileName,
|
||||
pageId,
|
||||
spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
@@ -24,6 +25,11 @@ import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -33,6 +39,7 @@ export class AuthController {
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -169,8 +176,17 @@ export class AuthController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('logout')
|
||||
async logout(@Res({ passthrough: true }) res: FastifyReply) {
|
||||
async logout(
|
||||
@AuthUser() user: User,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
res.clearCookie('authToken');
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_LOGOUT,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
setAuthCookie(res: FastifyReply, token: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
@@ -29,6 +30,11 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
|
||||
import { DomainService } from '../../../integrations/environment/domain.service';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -40,6 +46,7 @@ export class AuthService {
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async login(loginDto: LoginDto, workspaceId: string) {
|
||||
@@ -64,6 +71,13 @@ export class AuthService {
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_LOGIN,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
metadata: { source: 'password' },
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
@@ -112,6 +126,12 @@ export class AuthService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_CHANGED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: userId,
|
||||
});
|
||||
|
||||
const emailTemplate = ChangePasswordEmail({ username: user.name });
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
@@ -135,16 +155,27 @@ export class AuthService {
|
||||
|
||||
const token = nanoIdGen(16);
|
||||
|
||||
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await trx
|
||||
.deleteFrom('userTokens')
|
||||
.where('userId', '=', user.id)
|
||||
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
|
||||
.execute();
|
||||
|
||||
await this.userTokenRepo.insertUserToken({
|
||||
token: token,
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour
|
||||
type: UserTokenType.FORGOT_PASSWORD,
|
||||
await this.userTokenRepo.insertUserToken(
|
||||
{
|
||||
token,
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
|
||||
type: UserTokenType.FORGOT_PASSWORD,
|
||||
},
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
|
||||
|
||||
const emailTemplate = ForgotPasswordEmail({
|
||||
username: user.name,
|
||||
resetLink: resetLink,
|
||||
@@ -201,6 +232,13 @@ export class AuthService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
this.auditService.setActorId(user.id);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_RESET,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
});
|
||||
|
||||
const emailTemplate = ChangePasswordEmail({ username: user.name });
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { WorkspaceService } from '../../workspace/services/workspace.service';
|
||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
||||
@@ -10,6 +10,11 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
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';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
@@ -18,6 +23,7 @@ export class SignupService {
|
||||
private workspaceService: WorkspaceService,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async signup(
|
||||
@@ -36,7 +42,7 @@ export class SignupService {
|
||||
);
|
||||
}
|
||||
|
||||
return await executeTx(
|
||||
const user = await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
// create user
|
||||
@@ -66,6 +72,24 @@ export class SignupService {
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_CREATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: 'signup',
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async initialSetup(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('comments')
|
||||
@@ -35,6 +41,7 @@ export class CommentController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -51,7 +58,7 @@ export class CommentController {
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.commentService.create(
|
||||
const comment = await this.commentService.create(
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
@@ -59,6 +66,18 @@ export class CommentController {
|
||||
},
|
||||
createCommentDto,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_CREATED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: page.spaceId,
|
||||
metadata: {
|
||||
pageId: page.id,
|
||||
},
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -136,20 +155,32 @@ export class CommentController {
|
||||
|
||||
if (isOwner) {
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
} else {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
comment.spaceId,
|
||||
);
|
||||
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
);
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_DELETED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: comment.pageId,
|
||||
creatorId: comment.creatorId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,15 @@ import { GroupModule } from './group/group.module';
|
||||
import { CaslModule } from './casl/casl.module';
|
||||
import { PageAccessModule } from './page/page-access/page-access.module';
|
||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
NoopAuditService,
|
||||
} from '../integrations/audit/audit.service';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,17 +43,31 @@ import { WatcherModule } from './watcher/watcher.module';
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: NoopAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
const excludedRoutes = [
|
||||
{ path: 'auth/setup', method: RequestMethod.POST },
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/live', method: RequestMethod.GET },
|
||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||
];
|
||||
|
||||
consumer
|
||||
.apply(DomainMiddleware)
|
||||
.exclude(
|
||||
{ path: 'auth/setup', method: RequestMethod.POST },
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/live', method: RequestMethod.GET },
|
||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||
)
|
||||
.exclude(...excludedRoutes)
|
||||
.forRoutes('*');
|
||||
|
||||
consumer
|
||||
.apply(AuditContextMiddleware)
|
||||
.exclude(...excludedRoutes)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserService {
|
||||
@@ -25,6 +30,7 @@ export class GroupUserService {
|
||||
private groupService: GroupService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async getGroupUsers(
|
||||
@@ -72,6 +78,20 @@ export class GroupUserService {
|
||||
.values(groupUsersToInsert)
|
||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||
.execute();
|
||||
|
||||
for (const user of validUsers) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_MEMBER_ADDED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: groupId,
|
||||
changes: {
|
||||
after: {
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserFromGroup(
|
||||
@@ -115,8 +135,24 @@ export class GroupUserService {
|
||||
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||
[userId],
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_MEMBER_REMOVED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: groupId,
|
||||
changes: {
|
||||
before: {
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
groupName: group.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ import { GroupUserService } from './group-user.service';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import { diffAuditTrackedFields } from '../../../common/helpers';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class GroupService {
|
||||
@@ -29,6 +35,7 @@ export class GroupService {
|
||||
private groupUserService: GroupUserService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
|
||||
@@ -74,6 +81,18 @@ export class GroupService {
|
||||
);
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_CREATED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: createdGroup.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: createdGroup.name,
|
||||
description: createdGroup.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return createdGroup;
|
||||
}
|
||||
|
||||
@@ -95,6 +114,8 @@ export class GroupService {
|
||||
throw new BadRequestException('You cannot update a default group');
|
||||
}
|
||||
|
||||
const groupBefore = { name: group.name, description: group.description };
|
||||
|
||||
if (updateGroupDto.name) {
|
||||
const existingGroup = await this.groupRepo.findByName(
|
||||
updateGroupDto.name,
|
||||
@@ -121,6 +142,22 @@ export class GroupService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const changes = diffAuditTrackedFields(
|
||||
['name', 'description'],
|
||||
updateGroupDto,
|
||||
groupBefore,
|
||||
group,
|
||||
);
|
||||
|
||||
if (changes) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_UPDATED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: group.id,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -154,6 +191,18 @@ export class GroupService {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.GROUP_DELETED,
|
||||
resourceType: AuditResource.GROUP,
|
||||
resourceId: groupId,
|
||||
changes: {
|
||||
before: {
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAndValidateGroup(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
@@ -25,7 +26,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { SidebarPageDto } from './dto/sidebar-page.dto';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
@@ -40,6 +41,12 @@ import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
} from '../../collaboration/collaboration.util';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { getPageTitle } from '../../common/helpers';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -50,6 +57,7 @@ export class PageController {
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -129,6 +137,19 @@ export class PageController {
|
||||
|
||||
const permissions = { canEdit, hasRestriction };
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_CREATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
after: {
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
createPageDto.format &&
|
||||
createPageDto.format !== 'json' &&
|
||||
@@ -153,8 +174,10 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const { hasRestriction } =
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
const { hasRestriction } = await this.pageAccessService.validateCanEdit(
|
||||
page,
|
||||
user,
|
||||
);
|
||||
|
||||
const updatedPage = await this.pageService.update(
|
||||
page,
|
||||
@@ -202,6 +225,21 @@ export class PageController {
|
||||
);
|
||||
}
|
||||
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_DELETED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: page.id,
|
||||
slugId: page.slugId,
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// User with edit permission can delete
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
@@ -211,6 +249,21 @@ export class PageController {
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_TRASHED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: page.id,
|
||||
slugId: page.slugId,
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,20 +280,30 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
|
||||
// so page is virtually lost. Fix.
|
||||
// only users with "can edit" space level permission can restore pages
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
|
||||
|
||||
// Check page-level edit permission (if restoring to a restricted ancestor)
|
||||
// make sure they have page level access to the page
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_RESTORED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
changes: {
|
||||
after: {
|
||||
title: getPageTitle(page.title),
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||
includeHasChildren: true,
|
||||
});
|
||||
@@ -286,7 +349,7 @@ export class PageController {
|
||||
deletedPageDto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -410,7 +473,26 @@ export class PageController {
|
||||
await this.pageAccessService.validateCanEdit(movedPage, user);
|
||||
|
||||
// Moves only accessible pages; inaccessible child pages become root pages in original space
|
||||
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
|
||||
const { childPageIds } = await this.pageService.movePageToSpace(
|
||||
movedPage,
|
||||
dto.spaceId,
|
||||
user.id,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_MOVED_TO_SPACE,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: movedPage.id,
|
||||
spaceId: movedPage.spaceId,
|
||||
changes: {
|
||||
before: { spaceId: movedPage.spaceId },
|
||||
after: { spaceId: dto.spaceId },
|
||||
},
|
||||
metadata: {
|
||||
title: getPageTitle(movedPage.title),
|
||||
...(childPageIds.length > 0 && { childPageIds }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -425,6 +507,8 @@ export class PageController {
|
||||
// Inaccessible child branches are automatically skipped during duplication
|
||||
await this.pageAccessService.validateCanView(copiedPage, user);
|
||||
|
||||
let result;
|
||||
|
||||
// If spaceId is provided, it's a copy to different space
|
||||
if (dto.spaceId) {
|
||||
const abilities = await Promise.all([
|
||||
@@ -440,7 +524,27 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
|
||||
result = await this.pageService.duplicatePage(
|
||||
copiedPage,
|
||||
dto.spaceId,
|
||||
user,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_DUPLICATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: result.id,
|
||||
spaceId: dto.spaceId,
|
||||
metadata: {
|
||||
sourcePageId: copiedPage.id,
|
||||
title: getPageTitle(copiedPage.title),
|
||||
sourceSpaceId: copiedPage.spaceId,
|
||||
targetSpaceId: dto.spaceId,
|
||||
...(result.childPageIds.length > 0 && {
|
||||
childPageIds: result.childPageIds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If no spaceId, it's a duplicate in same space
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
@@ -451,8 +555,28 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.duplicatePage(copiedPage, undefined, user);
|
||||
result = await this.pageService.duplicatePage(
|
||||
copiedPage,
|
||||
undefined,
|
||||
user,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_DUPLICATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: result.id,
|
||||
spaceId: copiedPage.spaceId,
|
||||
metadata: {
|
||||
sourcePageId: copiedPage.id,
|
||||
title: getPageTitle(copiedPage.title),
|
||||
...(result.childPageIds.length > 0 && {
|
||||
childPageIds: result.childPageIds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -368,6 +368,8 @@ export class PageService {
|
||||
}
|
||||
|
||||
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
||||
let childPageIds: string[] = [];
|
||||
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: false,
|
||||
});
|
||||
@@ -413,11 +415,13 @@ export class PageService {
|
||||
|
||||
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
||||
|
||||
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// Update sub pages (all accessible pages except root)
|
||||
await this.pageRepo.updatePages(
|
||||
{ spaceId },
|
||||
pageIdsToMove.filter((id) => id !== rootPage.id),
|
||||
childPageIds,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
@@ -462,6 +466,8 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { childPageIds };
|
||||
}
|
||||
|
||||
async duplicatePage(
|
||||
@@ -680,10 +686,12 @@ export class PageService {
|
||||
});
|
||||
|
||||
const hasChildren = pages.length > 1;
|
||||
const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
|
||||
|
||||
return {
|
||||
...duplicatedPage,
|
||||
hasChildren,
|
||||
childPageIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class TrashCleanupService {
|
||||
private readonly logger = new Logger(TrashCleanupService.name);
|
||||
private readonly RETENTION_DAYS = 30;
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@@ -21,36 +22,46 @@ export class TrashCleanupService {
|
||||
try {
|
||||
this.logger.debug('Starting trash cleanup job');
|
||||
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
|
||||
|
||||
// Get all pages that were deleted more than 30 days ago
|
||||
const oldDeletedPages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'spaceId', 'workspaceId'])
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
const workspaces = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'trashRetentionDays'])
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
if (oldDeletedPages.length === 0) {
|
||||
this.logger.debug('No old trash items to clean up');
|
||||
return;
|
||||
}
|
||||
let totalCleaned = 0;
|
||||
|
||||
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
|
||||
for (const workspace of workspaces) {
|
||||
const retentionDays =
|
||||
workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
|
||||
|
||||
// Process each page
|
||||
for (const page of oldDeletedPages) {
|
||||
try {
|
||||
await this.cleanupPage(page.id);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
const retentionDate = new Date();
|
||||
retentionDate.setDate(retentionDate.getDate() - retentionDays);
|
||||
|
||||
const oldDeletedPages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
.execute();
|
||||
|
||||
for (const page of oldDeletedPages) {
|
||||
try {
|
||||
await this.cleanupPage(page.id);
|
||||
totalCleaned++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Trash cleanup job completed');
|
||||
this.logger.debug(
|
||||
totalCleaned > 0
|
||||
? `Trash cleanup completed: ${totalCleaned} pages cleaned`
|
||||
: 'No old trash items to clean up',
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Trash cleanup job failed',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
@@ -29,6 +30,11 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { hasLicenseOrEE } from '../../common/helpers';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
@@ -40,6 +46,7 @@ export class ShareController {
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -156,12 +163,25 @@ export class ShareController {
|
||||
throw new ForbiddenException('Public sharing is disabled');
|
||||
}
|
||||
|
||||
return this.shareService.createShare({
|
||||
const share = await this.shareService.createShare({
|
||||
page,
|
||||
authUserId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
createShareDto,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SHARE_CREATED,
|
||||
resourceType: AuditResource.SHARE,
|
||||
resourceId: share.id,
|
||||
spaceId: page.spaceId,
|
||||
metadata: {
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -202,6 +222,19 @@ export class ShareController {
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.shareRepo.deleteShare(share.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SHARE_DELETED,
|
||||
resourceType: AuditResource.SHARE,
|
||||
resourceId: share.id,
|
||||
spaceId: share.spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
pageId: share.pageId,
|
||||
spaceId: share.spaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -17,6 +18,11 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceMemberService {
|
||||
@@ -26,6 +32,7 @@ export class SpaceMemberService {
|
||||
private spaceRepo: SpaceRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async addUserToSpace(
|
||||
@@ -90,7 +97,6 @@ export class SpaceMemberService {
|
||||
authUser: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
// await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
|
||||
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) {
|
||||
@@ -164,8 +170,45 @@ export class SpaceMemberService {
|
||||
|
||||
if (membersToAdd.length > 0) {
|
||||
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
|
||||
} else {
|
||||
// either they are already members or do not exist on the workspace
|
||||
|
||||
// Audit log for each member added
|
||||
for (const user of validUsers) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ADDED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
after: { role: dto.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
memberType: 'user',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of validGroups) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ADDED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
after: { role: dto.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
memberType: 'group',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +273,23 @@ export class SpaceMemberService {
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_REMOVED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
before: { role: spaceMember.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
userId: spaceMember.userId,
|
||||
groupId: spaceMember.groupId,
|
||||
memberType: spaceMember.userId ? 'user' : 'group',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSpaceMemberRole(
|
||||
@@ -280,6 +340,24 @@ export class SpaceMemberService {
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
changes: {
|
||||
before: { role: spaceMember.role },
|
||||
after: { role: dto.role },
|
||||
},
|
||||
metadata: {
|
||||
spaceId: dto.spaceId,
|
||||
spaceName: space.name,
|
||||
userId: spaceMember.userId,
|
||||
groupId: spaceMember.groupId,
|
||||
memberType: spaceMember.userId ? 'user' : 'group',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateLastAdmin(spaceId: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -21,6 +22,12 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import { diffAuditTrackedFields } from '../../../common/helpers';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceService {
|
||||
@@ -32,6 +39,7 @@ export class SpaceService {
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async createSpace(
|
||||
@@ -63,6 +71,19 @@ export class SpaceService {
|
||||
trx,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_CREATED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: space.id,
|
||||
spaceId: space.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { ...space, memberCount: 1 };
|
||||
}
|
||||
|
||||
@@ -124,28 +145,74 @@ export class SpaceService {
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSharingSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateSpaceDto.disablePublicSharing,
|
||||
);
|
||||
|
||||
if (updateSpaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
description: updateSpaceDto.description,
|
||||
slug: updateSpaceDto.slug,
|
||||
},
|
||||
const spaceBefore = await this.spaceRepo.findById(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
);
|
||||
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
|
||||
|
||||
const before: Record<string, any> = {};
|
||||
const after: Record<string, any> = {};
|
||||
|
||||
let updatedSpace: Space;
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||
const prev = settingsBefore?.sharing?.disabled ?? false;
|
||||
if (prev !== updateSpaceDto.disablePublicSharing) {
|
||||
before.disablePublicSharing = prev;
|
||||
after.disablePublicSharing = updateSpaceDto.disablePublicSharing;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSharingSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateSpaceDto.disablePublicSharing,
|
||||
trx,
|
||||
);
|
||||
|
||||
if (updateSpaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId, trx);
|
||||
}
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
description: updateSpaceDto.description,
|
||||
slug: updateSpaceDto.slug,
|
||||
},
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
const columnChanges = diffAuditTrackedFields(
|
||||
['name', 'slug', 'description'],
|
||||
updateSpaceDto,
|
||||
spaceBefore,
|
||||
updatedSpace,
|
||||
);
|
||||
if (columnChanges) {
|
||||
Object.assign(before, columnChanges.before);
|
||||
Object.assign(after, columnChanges.after);
|
||||
}
|
||||
|
||||
if (Object.keys(after).length > 0) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_UPDATED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: updateSpaceDto.spaceId,
|
||||
spaceId: updateSpaceDto.spaceId,
|
||||
changes: { before, after },
|
||||
});
|
||||
}
|
||||
|
||||
return updatedSpace;
|
||||
}
|
||||
|
||||
async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> {
|
||||
@@ -174,5 +241,19 @@ export class SpaceService {
|
||||
|
||||
await this.spaceRepo.deleteSpace(spaceId, workspaceId);
|
||||
await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_DELETED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: spaceId,
|
||||
spaceId: spaceId,
|
||||
changes: {
|
||||
before: {
|
||||
name: space.name,
|
||||
slug: space.slug,
|
||||
description: space.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { comparePasswordHash } from 'src/common/helpers/utils';
|
||||
import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { validateSsoEnforcement } from '../auth/auth.util';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private userRepo: UserRepo) {}
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async findById(userId: string, workspaceId: string) {
|
||||
return this.userRepo.findById(userId, workspaceId);
|
||||
@@ -51,6 +60,8 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
const userBefore = { name: user.name, email: user.email, locale: user.locale };
|
||||
|
||||
if (updateUserDto.name) {
|
||||
user.name = updateUserDto.name;
|
||||
}
|
||||
@@ -91,6 +102,23 @@ export class UserService {
|
||||
delete updateUserDto.confirmPassword;
|
||||
|
||||
await this.userRepo.updateUser(updateUserDto, userId, workspace.id);
|
||||
|
||||
const changes = diffAuditTrackedFields(
|
||||
['name', 'email'],
|
||||
updateUserDto,
|
||||
userBefore,
|
||||
user,
|
||||
);
|
||||
|
||||
if (changes) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_UPDATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: userId,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from './create-workspace.dto';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@@ -34,4 +41,9 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
trashRetentionDays: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -33,6 +34,11 @@ import {
|
||||
validateAllowedEmail,
|
||||
validateSsoEnforcement,
|
||||
} from '../../auth/auth.util';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@@ -46,6 +52,7 @@ export class WorkspaceInvitationService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
|
||||
@@ -180,6 +187,24 @@ export class WorkspaceInvitationService {
|
||||
workspace.hostname,
|
||||
);
|
||||
});
|
||||
|
||||
// Audit log for each invitation created
|
||||
for (const invitation of invites) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_INVITE_CREATED,
|
||||
resourceType: AuditResource.WORKSPACE_INVITATION,
|
||||
resourceId: invitation.id,
|
||||
changes: {
|
||||
after: {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
groupIds: invitation.groupIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +321,23 @@ export class WorkspaceInvitationService {
|
||||
});
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_CREATED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: newUser.id,
|
||||
changes: {
|
||||
after: {
|
||||
name: newUser.name,
|
||||
email: newUser.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: 'invitation',
|
||||
invitationId: invitation.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.environmentService.isCloud()) {
|
||||
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
|
||||
workspaceId: workspace.id,
|
||||
@@ -339,17 +381,48 @@ export class WorkspaceInvitationService {
|
||||
invitedByUser.name,
|
||||
workspace.hostname,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_INVITE_RESENT,
|
||||
resourceType: AuditResource.WORKSPACE_INVITATION,
|
||||
resourceId: invitation.id,
|
||||
metadata: {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revokeInvitation(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select(['id', 'email', 'role'])
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
await this.db
|
||||
.deleteFrom('workspaceInvitations')
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (invitation) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_INVITE_REVOKED,
|
||||
resourceType: AuditResource.WORKSPACE_INVITATION,
|
||||
resourceId: invitation.id,
|
||||
changes: {
|
||||
before: {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getInvitationLinkById(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -31,11 +32,19 @@ import { v4 } from 'uuid';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
import {
|
||||
generateRandomSuffixNumbers,
|
||||
diffAuditTrackedFields,
|
||||
} from '../../../common/helpers';
|
||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -57,6 +66,7 @@ export class WorkspaceService {
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -280,7 +290,7 @@ export class WorkspaceService {
|
||||
if (updateWorkspaceDto.enforceSso) {
|
||||
const sso = await this.db
|
||||
.selectFrom('authProviders')
|
||||
.selectAll()
|
||||
.select(['id'])
|
||||
.where('isEnabled', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
@@ -295,9 +305,7 @@ export class WorkspaceService {
|
||||
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);
|
||||
@@ -313,93 +321,170 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
|
||||
await this.workspaceRepo.updateApiSettings(
|
||||
workspaceId,
|
||||
'restrictToAdmins',
|
||||
updateWorkspaceDto.restrictApiToAdmins,
|
||||
);
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
}
|
||||
const before: Record<string, any> = {};
|
||||
const after: Record<string, any> = {};
|
||||
|
||||
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'search',
|
||||
updateWorkspaceDto.aiSearch,
|
||||
);
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'licenseKey', 'trashRetentionDays'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (updateWorkspaceDto.aiSearch) {
|
||||
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
||||
if (!tableExists) {
|
||||
throw new BadRequestException(
|
||||
'Failed to activate. Make sure pgvector postgres extension is installed.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
|
||||
workspaceId,
|
||||
});
|
||||
} else {
|
||||
// Schedule deletion after 24 hours
|
||||
const deleteJobId = `ai-search-disabled-${workspaceId}`;
|
||||
await this.aiQueue.add(
|
||||
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
|
||||
{ workspaceId },
|
||||
{
|
||||
jobId: deleteJobId,
|
||||
delay: 24 * 60 * 60 * 1000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'generative',
|
||||
updateWorkspaceDto.generativeAi,
|
||||
);
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
|
||||
) {
|
||||
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateSharingSettings(
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateWorkspaceDto.disablePublicSharing,
|
||||
);
|
||||
|
||||
if (updateWorkspaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteByWorkspaceId(workspaceId);
|
||||
if (
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
|
||||
updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays
|
||||
) {
|
||||
before.trashRetentionDays = ws.trashRetentionDays;
|
||||
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||
if (updateWorkspaceDto.aiSearch) {
|
||||
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
||||
if (!tableExists) {
|
||||
throw new BadRequestException(
|
||||
'Failed to activate. Make sure pgvector postgres extension is installed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceBefore = await this.workspaceRepo.findById(workspaceId);
|
||||
const settingsBefore = (workspaceBefore?.settings ?? {}) as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
|
||||
const prev = settingsBefore?.api?.restrictToAdmins ?? false;
|
||||
if (prev !== updateWorkspaceDto.restrictApiToAdmins) {
|
||||
before.restrictApiToAdmins = prev;
|
||||
after.restrictApiToAdmins = updateWorkspaceDto.restrictApiToAdmins;
|
||||
}
|
||||
await this.workspaceRepo.updateApiSettings(
|
||||
workspaceId,
|
||||
'restrictToAdmins',
|
||||
updateWorkspaceDto.restrictApiToAdmins,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.search ?? false;
|
||||
if (prev !== updateWorkspaceDto.aiSearch) {
|
||||
before.aiSearch = prev;
|
||||
after.aiSearch = updateWorkspaceDto.aiSearch;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'search',
|
||||
updateWorkspaceDto.aiSearch,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.generative ?? false;
|
||||
if (prev !== updateWorkspaceDto.generativeAi) {
|
||||
before.generativeAi = prev;
|
||||
after.generativeAi = updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'generative',
|
||||
updateWorkspaceDto.generativeAi,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||
const prev = settingsBefore?.sharing?.disabled ?? false;
|
||||
if (prev !== updateWorkspaceDto.disablePublicSharing) {
|
||||
before.disablePublicSharing = prev;
|
||||
after.disablePublicSharing = updateWorkspaceDto.disablePublicSharing;
|
||||
}
|
||||
await this.workspaceRepo.updateSharingSettings(
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateWorkspaceDto.disablePublicSharing,
|
||||
trx,
|
||||
);
|
||||
if (updateWorkspaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteByWorkspaceId(workspaceId, trx);
|
||||
}
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
updateWorkspaceDto,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
if (after.aiSearch === true) {
|
||||
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
|
||||
workspaceId,
|
||||
});
|
||||
} else if (after.aiSearch === false) {
|
||||
const deleteJobId = `ai-search-disabled-${workspaceId}`;
|
||||
await this.aiQueue.add(
|
||||
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
|
||||
{ workspaceId },
|
||||
{
|
||||
jobId: deleteJobId,
|
||||
delay: 24 * 60 * 60 * 1000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withMemberCount: true,
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
const columnChanges = diffAuditTrackedFields(
|
||||
[
|
||||
'name',
|
||||
'logo',
|
||||
'enforceSso',
|
||||
'enforceMfa',
|
||||
'emailDomains',
|
||||
],
|
||||
updateWorkspaceDto,
|
||||
workspaceBefore,
|
||||
workspace,
|
||||
);
|
||||
if (columnChanges) {
|
||||
Object.assign(before, columnChanges.before);
|
||||
Object.assign(after, columnChanges.after);
|
||||
}
|
||||
|
||||
if (Object.keys(after).length > 0) {
|
||||
this.auditService.log({
|
||||
event: AuditEvent.WORKSPACE_UPDATED,
|
||||
resourceType: AuditResource.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
changes: { before, after },
|
||||
});
|
||||
}
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
return {
|
||||
...rest,
|
||||
@@ -457,6 +542,16 @@ export class WorkspaceService {
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_ROLE_CHANGED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
before: { role: user.role },
|
||||
after: { role: newRole },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async generateHostname(
|
||||
@@ -564,6 +659,19 @@ export class WorkspaceService {
|
||||
});
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_DELETED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
changes: {
|
||||
before: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user