diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 71102b73..3cd9fae6 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -354,6 +354,9 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available",
+ "Delete member": "Delete member",
+ "Member deleted successfully": "Member deleted successfully",
+ "Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible."
"Move": "Move",
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
diff --git a/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx b/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx
new file mode 100644
index 00000000..9c1b705b
--- /dev/null
+++ b/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx
@@ -0,0 +1,66 @@
+import { Menu, ActionIcon, Text } from "@mantine/core";
+import React from "react";
+import { IconDots, IconTrash } from "@tabler/icons-react";
+import { modals } from "@mantine/modals";
+import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
+import { useTranslation } from "react-i18next";
+import useUserRole from "@/hooks/use-user-role.tsx";
+
+interface Props {
+ userId: string;
+}
+export default function MemberActionMenu({ userId }: Props) {
+ const { t } = useTranslation();
+ const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
+ const { isAdmin } = useUserRole();
+
+ const onRevoke = async () => {
+ await deleteWorkspaceMemberMutation.mutateAsync({ userId });
+ };
+
+ const openRevokeModal = () =>
+ modals.openConfirmModal({
+ title: t("Delete member"),
+ children: (
+
+ {t(
+ "Are you sure you want to delete this workspace member? This action is irreversible.",
+ )}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Delete"), cancel: t("Don't") },
+ confirmProps: { color: "red" },
+ onConfirm: onRevoke,
+ });
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
index 83d9af8c..a8c802d9 100644
--- a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
+++ b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
@@ -17,6 +17,8 @@ import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
+import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
+import MemberActionMenu from "@/features/workspace/components/members/components/members-action-menu.tsx";
export default function WorkspaceMembersTable() {
const { t } = useTranslation();
@@ -96,6 +98,9 @@ export default function WorkspaceMembersTable() {
disabled={!isAdmin}
/>
+
+ {isAdmin && }
+
))
) : (
diff --git a/apps/client/src/features/workspace/queries/workspace-query.ts b/apps/client/src/features/workspace/queries/workspace-query.ts
index 1d1f1c73..0add1d0a 100644
--- a/apps/client/src/features/workspace/queries/workspace-query.ts
+++ b/apps/client/src/features/workspace/queries/workspace-query.ts
@@ -16,6 +16,7 @@ import {
getWorkspace,
getWorkspacePublicData,
getAppVersion,
+ deleteWorkspaceMember,
} from "@/features/workspace/services/workspace-service";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
@@ -56,6 +57,30 @@ export function useWorkspaceMembersQuery(
});
}
+export function useDeleteWorkspaceMemberMutation() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ void,
+ Error,
+ {
+ userId: string;
+ }
+ >({
+ mutationFn: (data) => deleteWorkspaceMember(data),
+ onSuccess: (data, variables) => {
+ notifications.show({ message: "Member deleted successfully" });
+ queryClient.invalidateQueries({
+ queryKey: ["workspaceMembers"],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
+
export function useChangeMemberRoleMutation() {
const queryClient = useQueryClient();
diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts
index ecaae58a..293629fe 100644
--- a/apps/client/src/features/workspace/services/workspace-service.ts
+++ b/apps/client/src/features/workspace/services/workspace-service.ts
@@ -36,6 +36,12 @@ export async function getWorkspaceMembers(
return req.data;
}
+export async function deleteWorkspaceMember(data: {
+ userId: string;
+}): Promise {
+ await api.post("/workspace/members/delete", data);
+}
+
export async function updateWorkspace(data: Partial) {
const req = await api.post("/workspace/update", data);
return req.data;
diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts
index feba813b..cd4430f6 100644
--- a/apps/server/src/core/attachment/processors/attachment.processor.ts
+++ b/apps/server/src/core/attachment/processors/attachment.processor.ts
@@ -17,6 +17,9 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
}
+ if (job.name === QueueJob.DELETE_USER_AVATARS) {
+ await this.attachmentService.handleDeleteUserAvatars(job.data.id);
+ }
} catch (err) {
throw err;
}
diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts
index b79c8c65..2a1aae34 100644
--- a/apps/server/src/core/attachment/services/attachment.service.ts
+++ b/apps/server/src/core/attachment/services/attachment.service.ts
@@ -281,10 +281,42 @@ export class AttachmentService {
}),
);
- if(failedDeletions.length === attachments.length){
- throw new Error(`Failed to delete any attachments for spaceId: ${spaceId}`);
+ if (failedDeletions.length === attachments.length) {
+ throw new Error(
+ `Failed to delete any attachments for spaceId: ${spaceId}`,
+ );
+ }
+ } catch (err) {
+ throw err;
+ }
+ }
+
+ async handleDeleteUserAvatars(userId: string) {
+ try {
+ const userAvatars = await this.db
+ .selectFrom('attachments')
+ .select(['id', 'filePath'])
+ .where('creatorId', '=', userId)
+ .where('type', '=', AttachmentType.Avatar)
+ .execute();
+
+ if (!userAvatars || userAvatars.length === 0) {
+ return;
}
+ await Promise.all(
+ userAvatars.map(async (attachment) => {
+ try {
+ await this.storageService.delete(attachment.filePath);
+ await this.attachmentRepo.deleteAttachmentById(attachment.id);
+ } catch (err) {
+ this.logger.log(
+ `DeleteUserAvatar: failed to delete user avatar ${attachment.id}:`,
+ err,
+ );
+ }
+ }),
+ );
} catch (err) {
throw err;
}
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index 73fc0a50..bd61adcb 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -43,18 +43,16 @@ export class AuthService {
) {}
async login(loginDto: LoginDto, workspaceId: string) {
- const user = await this.userRepo.findByEmail(
- loginDto.email,
- workspaceId,
- {
- includePassword: true
- }
+ const user = await this.userRepo.findByEmail(loginDto.email, workspaceId, {
+ includePassword: true,
+ });
+
+ const isPasswordMatch = await comparePasswordHash(
+ loginDto.password,
+ user.password,
);
- if (
- !user ||
- !(await comparePasswordHash(loginDto.password, user.password))
- ) {
+ if (!user || !isPasswordMatch || user.deletedAt) {
throw new UnauthorizedException('email or password does not match');
}
@@ -86,7 +84,7 @@ export class AuthService {
includePassword: true,
});
- if (!user) {
+ if (!user || user.deletedAt) {
throw new NotFoundException('User not found');
}
@@ -125,7 +123,7 @@ export class AuthService {
workspace.id,
);
- if (!user) {
+ if (!user || user.deletedAt) {
return;
}
@@ -168,7 +166,7 @@ export class AuthService {
}
const user = await this.userRepo.findById(userToken.userId, workspaceId);
- if (!user) {
+ if (!user || user.deletedAt) {
throw new NotFoundException('User not found');
}
diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts
index 9ef57ba1..ad745290 100644
--- a/apps/server/src/core/auth/services/token.service.ts
+++ b/apps/server/src/core/auth/services/token.service.ts
@@ -1,4 +1,8 @@
-import { Injectable, UnauthorizedException } from '@nestjs/common';
+import {
+ ForbiddenException,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
@@ -17,6 +21,10 @@ export class TokenService {
) {}
async generateAccessToken(user: User): Promise {
+ if (user.deletedAt) {
+ throw new ForbiddenException();
+ }
+
const payload: JwtPayload = {
sub: user.id,
email: user.email,
diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts
index d2217500..083444f2 100644
--- a/apps/server/src/core/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts
@@ -1,9 +1,4 @@
-import {
- BadRequestException,
- Injectable,
- Logger,
- UnauthorizedException,
-} from '@nestjs/common';
+import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@@ -47,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
- if (!user) {
+ if (!user || user.deletedAt) {
throw new UnauthorizedException();
}
diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts
index c1339f7e..412a2cb0 100644
--- a/apps/server/src/core/search/search.service.ts
+++ b/apps/server/src/core/search/search.service.ts
@@ -84,6 +84,7 @@ export class SearchService {
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
+ .where('deletedAt', 'is', null)
.limit(limit)
.execute();
}
diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts
index 96a81362..218c79a3 100644
--- a/apps/server/src/core/workspace/controllers/workspace.controller.ts
+++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts
@@ -34,6 +34,7 @@ import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
+import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
@UseGuards(JwtAuthGuard)
@Controller('workspace')
@@ -120,6 +121,22 @@ export class WorkspaceController {
}
}
+ @HttpCode(HttpStatus.OK)
+ @Post('members/delete')
+ async deleteWorkspaceMember(
+ @Body() dto: RemoveWorkspaceUserDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ) {
+ const ability = this.workspaceAbility.createForUser(user, workspace);
+ if (
+ ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
+ ) {
+ throw new ForbiddenException();
+ }
+ await this.workspaceService.deleteUser(user, dto.userId, workspace.id);
+ }
+
@HttpCode(HttpStatus.OK)
@Post('members/change-role')
async updateWorkspaceMemberRole(
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index 4c6c8d09..eb42f9e9 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -27,6 +27,8 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
+import { v4 } from 'uuid';
+import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
@@ -45,6 +47,7 @@ export class WorkspaceService {
private environmentService: EnvironmentService,
private domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
+ @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
) {}
@@ -419,4 +422,66 @@ export class WorkspaceService {
}
return { hostname: this.domainService.getUrl(hostname) };
}
+
+ async deleteUser(
+ authUser: User,
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ const user = await this.userRepo.findById(userId, workspaceId);
+
+ if (!user || user.deletedAt) {
+ throw new BadRequestException('Workspace member not found');
+ }
+
+ 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',
+ );
+ }
+
+ if (authUser.id === userId) {
+ throw new BadRequestException('You cannot delete yourself');
+ }
+
+ if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
+ throw new BadRequestException('You cannot delete a user with owner role');
+ }
+
+ await executeTx(this.db, async (trx) => {
+ await this.userRepo.updateUser(
+ {
+ name: 'Deleted user',
+ email: v4() + '@deleted.docmost.com',
+ avatarUrl: null,
+ settings: null,
+ deletedAt: new Date(),
+ },
+ userId,
+ workspaceId,
+ trx,
+ );
+
+ await trx.deleteFrom('groupUsers').where('userId', '=', userId).execute();
+ await trx
+ .deleteFrom('spaceMembers')
+ .where('userId', '=', userId)
+ .execute();
+ await trx
+ .deleteFrom('authAccounts')
+ .where('userId', '=', userId)
+ .execute();
+ });
+
+ try {
+ await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
+ } catch (err) {
+ // empty
+ }
+ }
}
diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts
index fdf8c0c2..f87f4daa 100644
--- a/apps/server/src/database/repos/user/user.repo.ts
+++ b/apps/server/src/database/repos/user/user.repo.ts
@@ -139,6 +139,7 @@ export class UserRepo {
.selectFrom('users')
.select(this.baseFields)
.where('workspaceId', '=', workspaceId)
+ .where('deletedAt', 'is', null)
.orderBy('createdAt', 'asc');
if (pagination.query) {
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index d1678ea3..61d7163b 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -11,6 +11,8 @@ export enum QueueJob {
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update',
+ DELETE_USER_AVATARS = 'delete-user-avatars',
+
PAGE_BACKLINKS = 'page-backlinks',
STRIPE_SEATS_SYNC = 'sync-stripe-seats',