feat: delete workspace member (#987)

* add delete user endpoint (server)

* delete user (UI)

* prevent token generation

* more checks
This commit is contained in:
Philip Okugbe
2025-04-07 19:26:03 +01:00
committed by GitHub
parent 3559358d14
commit 7431804a46
15 changed files with 250 additions and 23 deletions

View File

@ -354,6 +354,9 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}", "Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update", "New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available", "{{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": "Move",
"Move page": "Move page", "Move page": "Move page",
"Move page to a different space.": "Move page to a different space.", "Move page to a different space.": "Move page to a different space.",

View File

@ -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: (
<Text size="sm">
{t(
"Are you sure you want to delete this workspace member? This action is irreversible.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
return (
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} />}
disabled={!isAdmin}
>
{t("Delete member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@ -17,6 +17,8 @@ import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx"; import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx"; import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.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() { export default function WorkspaceMembersTable() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -96,6 +98,9 @@ export default function WorkspaceMembersTable() {
disabled={!isAdmin} disabled={!isAdmin}
/> />
</Table.Td> </Table.Td>
<Table.Td>
{isAdmin && <MemberActionMenu userId={user.id} />}
</Table.Td>
</Table.Tr> </Table.Tr>
)) ))
) : ( ) : (

View File

@ -16,6 +16,7 @@ import {
getWorkspace, getWorkspace,
getWorkspacePublicData, getWorkspacePublicData,
getAppVersion, getAppVersion,
deleteWorkspaceMember,
} from "@/features/workspace/services/workspace-service"; } from "@/features/workspace/services/workspace-service";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications"; 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() { export function useChangeMemberRoleMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@ -36,6 +36,12 @@ export async function getWorkspaceMembers(
return req.data; return req.data;
} }
export async function deleteWorkspaceMember(data: {
userId: string;
}): Promise<void> {
await api.post("/workspace/members/delete", data);
}
export async function updateWorkspace(data: Partial<IWorkspace>) { export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>("/workspace/update", data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data; return req.data;

View File

@ -17,6 +17,9 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) { if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id); await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
} }
if (job.name === QueueJob.DELETE_USER_AVATARS) {
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
}
} catch (err) { } catch (err) {
throw err; throw err;
} }

View File

@ -281,10 +281,42 @@ export class AttachmentService {
}), }),
); );
if(failedDeletions.length === attachments.length){ if (failedDeletions.length === attachments.length) {
throw new Error(`Failed to delete any attachments for spaceId: ${spaceId}`); 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) { } catch (err) {
throw err; throw err;
} }

View File

@ -43,18 +43,16 @@ export class AuthService {
) {} ) {}
async login(loginDto: LoginDto, workspaceId: string) { async login(loginDto: LoginDto, workspaceId: string) {
const user = await this.userRepo.findByEmail( const user = await this.userRepo.findByEmail(loginDto.email, workspaceId, {
loginDto.email, includePassword: true,
workspaceId, });
{
includePassword: true const isPasswordMatch = await comparePasswordHash(
} loginDto.password,
user.password,
); );
if ( if (!user || !isPasswordMatch || user.deletedAt) {
!user ||
!(await comparePasswordHash(loginDto.password, user.password))
) {
throw new UnauthorizedException('email or password does not match'); throw new UnauthorizedException('email or password does not match');
} }
@ -86,7 +84,7 @@ export class AuthService {
includePassword: true, includePassword: true,
}); });
if (!user) { if (!user || user.deletedAt) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
@ -125,7 +123,7 @@ export class AuthService {
workspace.id, workspace.id,
); );
if (!user) { if (!user || user.deletedAt) {
return; return;
} }
@ -168,7 +166,7 @@ export class AuthService {
} }
const user = await this.userRepo.findById(userToken.userId, workspaceId); const user = await this.userRepo.findById(userToken.userId, workspaceId);
if (!user) { if (!user || user.deletedAt) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }

View File

@ -1,4 +1,8 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { import {
@ -17,6 +21,10 @@ export class TokenService {
) {} ) {}
async generateAccessToken(user: User): Promise<string> { async generateAccessToken(user: User): Promise<string> {
if (user.deletedAt) {
throw new ForbiddenException();
}
const payload: JwtPayload = { const payload: JwtPayload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,

View File

@ -1,9 +1,4 @@
import { import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
BadRequestException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; 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); const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
if (!user) { if (!user || user.deletedAt) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }

View File

@ -84,6 +84,7 @@ export class SearchService {
.select(['id', 'name', 'avatarUrl']) .select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`)) .where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.limit(limit) .limit(limit)
.execute(); .execute();
} }

View File

@ -34,6 +34,7 @@ import { addDays } from 'date-fns';
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto'; import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('workspace') @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) @HttpCode(HttpStatus.OK)
@Post('members/change-role') @Post('members/change-role')
async updateWorkspaceMemberRole( async updateWorkspaceMemberRole(

View File

@ -27,6 +27,8 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; 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 { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
@ -45,6 +47,7 @@ export class WorkspaceService {
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private domainService: DomainService, private domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
) {} ) {}
@ -419,4 +422,66 @@ export class WorkspaceService {
} }
return { hostname: this.domainService.getUrl(hostname) }; return { hostname: this.domainService.getUrl(hostname) };
} }
async deleteUser(
authUser: User,
userId: string,
workspaceId: string,
): Promise<void> {
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
}
}
} }

View File

@ -139,6 +139,7 @@ export class UserRepo {
.selectFrom('users') .selectFrom('users')
.select(this.baseFields) .select(this.baseFields)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'asc'); .orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {

View File

@ -11,6 +11,8 @@ export enum QueueJob {
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update', PAGE_CONTENT_UPDATE = 'page-content-update',
DELETE_USER_AVATARS = 'delete-user-avatars',
PAGE_BACKLINKS = 'page-backlinks', PAGE_BACKLINKS = 'page-backlinks',
STRIPE_SEATS_SYNC = 'sync-stripe-seats', STRIPE_SEATS_SYNC = 'sync-stripe-seats',