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 ( + <> + + + + + + + + + } + disabled={!isAdmin} + > + {t("Delete member")} + + + + + ); +} 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',