diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 744262fb..d1f8a5d3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -399,6 +399,13 @@ "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.", + "Deactivate member": "Deactivate member", + "Activate member": "Activate member", + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.", + "Are you sure you want to activate this workspace member?": "Are you sure you want to activate this workspace member?", + "Deactivate": "Deactivate", + "Activate": "Activate", + "Deactivated": "Deactivated", "Move": "Move", "Move page": "Move page", "Move page to a different space.": "Move page to a different space.", diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index f2fec147..8bb6a2f6 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -23,6 +23,8 @@ export const auditEventLabels: Record = { "user.password_changed": "Changed password", "user.password_reset": "Reset password", "user.updated": "Updated user", + "user.deactivated": "Deactivated user", + "user.activated": "Activated user", "user.mfa_enabled": "Enabled MFA", "user.mfa_disabled": "Disabled MFA", "user.mfa_backup_code_generated": "Generated MFA backup codes", @@ -88,6 +90,8 @@ export const eventFilterOptions: EventGroup[] = [ { value: "user.logout", label: "Logged out" }, { value: "user.created", label: "Created user" }, { value: "user.deleted", label: "Deleted user" }, + { value: "user.deactivated", label: "Deactivated user" }, + { value: "user.activated", label: "Activated user" }, { value: "user.role_changed", label: "Changed user role" }, { value: "user.password_changed", label: "Changed password" }, { value: "user.mfa_enabled", label: "Enabled MFA" }, 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 index 9c1b705b..e9376dda 100644 --- 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 @@ -1,19 +1,57 @@ import { Menu, ActionIcon, Text } from "@mantine/core"; import React from "react"; -import { IconDots, IconTrash } from "@tabler/icons-react"; +import { IconDots, IconTrash, IconUserOff, IconUserCheck } from "@tabler/icons-react"; import { modals } from "@mantine/modals"; -import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts"; +import { + useDeleteWorkspaceMemberMutation, + useDeactivateWorkspaceMemberMutation, + useActivateWorkspaceMemberMutation, +} from "@/features/workspace/queries/workspace-query.ts"; import { useTranslation } from "react-i18next"; import useUserRole from "@/hooks/use-user-role.tsx"; interface Props { userId: string; + deactivatedAt: Date | null; } -export default function MemberActionMenu({ userId }: Props) { +export default function MemberActionMenu({ userId, deactivatedAt }: Props) { const { t } = useTranslation(); const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation(); + const deactivateMutation = useDeactivateWorkspaceMemberMutation(); + const activateMutation = useActivateWorkspaceMemberMutation(); const { isAdmin } = useUserRole(); + const isDeactivated = !!deactivatedAt; + + const onDeactivate = async () => { + await deactivateMutation.mutateAsync({ userId }); + }; + + const onActivate = async () => { + await activateMutation.mutateAsync({ userId }); + }; + + const openDeactivateModal = () => + modals.openConfirmModal({ + title: isDeactivated ? t("Activate member") : t("Deactivate member"), + children: ( + + {isDeactivated + ? t("Are you sure you want to activate this workspace member?") + : t( + "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.", + )} + + ), + centered: true, + labels: { + confirm: isDeactivated ? t("Activate") : t("Deactivate"), + cancel: t("Cancel"), + }, + confirmProps: { color: isDeactivated ? "blue" : "orange" }, + onConfirm: isDeactivated ? onActivate : onDeactivate, + }); + const onRevoke = async () => { await deleteWorkspaceMemberMutation.mutateAsync({ userId }); }; @@ -51,6 +89,22 @@ export default function MemberActionMenu({ userId }: Props) { + + ) : ( + + ) + } + disabled={!isAdmin} + > + {isDeactivated ? t("Activate member") : t("Deactivate member")} + + + + - {t("Active")} + {user.deactivatedAt ? ( + + {t("Deactivated")} + + ) : ( + {t("Active")} + )} - {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 6bf35aec..9e1ce514 100644 --- a/apps/client/src/features/workspace/queries/workspace-query.ts +++ b/apps/client/src/features/workspace/queries/workspace-query.ts @@ -17,6 +17,8 @@ import { getWorkspacePublicData, getAppVersion, deleteWorkspaceMember, + deactivateWorkspaceMember, + activateWorkspaceMember, } from "@/features/workspace/services/workspace-service"; import { IPagination, QueryParams } from "@/lib/types.ts"; import { notifications } from "@mantine/notifications"; @@ -81,6 +83,52 @@ export function useDeleteWorkspaceMemberMutation() { }); } +export function useDeactivateWorkspaceMemberMutation() { + const queryClient = useQueryClient(); + + return useMutation< + void, + Error, + { + userId: string; + } + >({ + mutationFn: (data) => deactivateWorkspaceMember(data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["workspaceMembers"], + }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} + +export function useActivateWorkspaceMemberMutation() { + const queryClient = useQueryClient(); + + return useMutation< + void, + Error, + { + userId: string; + } + >({ + mutationFn: (data) => activateWorkspaceMember(data), + onSuccess: () => { + 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 2c36968f..20da1146 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -42,6 +42,18 @@ export async function deleteWorkspaceMember(data: { await api.post("/workspace/members/delete", data); } +export async function deactivateWorkspaceMember(data: { + userId: string; +}): Promise { + await api.post("/workspace/members/deactivate", data); +} + +export async function activateWorkspaceMember(data: { + userId: string; +}): Promise { + await api.post("/workspace/members/activate", data); +} + export async function updateWorkspace(data: Partial) { const req = await api.post("/workspace/update", data); return req.data; diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index 4045601d..1eaed935 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -12,6 +12,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { SpaceRole } from '../../common/helpers/types/permission'; +import { isUserDisabled } from '../../common/helpers'; import { getPageId } from '../collaboration.util'; import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload'; @@ -48,7 +49,7 @@ export class AuthenticationExtension implements Extension { throw new UnauthorizedException(); } - if (user.deactivatedAt || user.deletedAt) { + if (isUserDisabled(user)) { throw new UnauthorizedException(); } diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index a1fa4bae..5f5e3554 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -15,6 +15,8 @@ export const AuditEvent = { USER_PASSWORD_CHANGED: 'user.password_changed', USER_PASSWORD_RESET: 'user.password_reset', USER_UPDATED: 'user.updated', + USER_DEACTIVATED: 'user.deactivated', + USER_ACTIVATED: 'user.activated', // API Keys API_KEY_CREATED: 'api_key.created', diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 1970ecf9..e1bb2009 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -144,6 +144,13 @@ export function diffAuditTrackedFields( return hasChanges ? { before: beforeDiff, after: afterDiff } : null; } +export function isUserDisabled(user: { + deactivatedAt?: Date | null; + deletedAt?: Date | null; +}): boolean { + return !!(user.deactivatedAt || user.deletedAt); +} + export function createByteCountingStream(source: Readable) { let bytesRead = 0; const stream = new Transform({ diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index da5be855..bc907b8e 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -14,6 +14,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { comparePasswordHash, hashPassword, + isUserDisabled, nanoIdGen, } from '../../../common/helpers'; import { ChangePasswordDto } from '../dto/change-password.dto'; @@ -55,7 +56,7 @@ export class AuthService { }); const errorMessage = 'Email or password does not match'; - if (!user || user?.deletedAt) { + if (!user || isUserDisabled(user)) { throw new UnauthorizedException(errorMessage); } @@ -103,7 +104,7 @@ export class AuthService { includePassword: true, }); - if (!user || user.deletedAt) { + if (!user || isUserDisabled(user)) { throw new NotFoundException('User not found'); } @@ -149,7 +150,7 @@ export class AuthService { workspace.id, ); - if (!user || user.deletedAt) { + if (!user || isUserDisabled(user)) { return; } @@ -208,7 +209,7 @@ export class AuthService { const user = await this.userRepo.findById(userToken.userId, workspace.id, { includeUserMfa: true, }); - if (!user || user.deletedAt) { + if (!user || isUserDisabled(user)) { 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 0415b22d..3929e992 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -15,6 +15,7 @@ import { JwtType, } from '../dto/jwt-payload'; import { User } from '@docmost/db/types/entity.types'; +import { isUserDisabled } from '../../../common/helpers'; @Injectable() export class TokenService { @@ -24,7 +25,7 @@ export class TokenService { ) {} async generateAccessToken(user: User): Promise { - if (user.deactivatedAt || user.deletedAt) { + if (isUserDisabled(user)) { throw new ForbiddenException(); } @@ -38,7 +39,7 @@ export class TokenService { } async generateCollabToken(user: User, workspaceId: string): Promise { - if (user.deactivatedAt || user.deletedAt) { + if (isUserDisabled(user)) { throw new ForbiddenException(); } @@ -79,7 +80,7 @@ export class TokenService { } async generateMfaToken(user: User, workspaceId: string): Promise { - if (user.deactivatedAt || user.deletedAt) { + if (isUserDisabled(user)) { throw new ForbiddenException(); } @@ -98,7 +99,7 @@ export class TokenService { expiresIn?: string | number; }): Promise { const { apiKeyId, user, workspaceId, expiresIn } = opts; - if (user.deactivatedAt || user.deletedAt) { + if (isUserDisabled(user)) { throw new ForbiddenException(); } diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index 7616fb61..61096245 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -6,7 +6,7 @@ import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { FastifyRequest } from 'fastify'; -import { extractBearerTokenFromHeader } from '../../../common/helpers'; +import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers'; import { ModuleRef } from '@nestjs/core'; @Injectable() @@ -53,7 +53,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { } const user = await this.userRepo.findById(payload.sub, payload.workspaceId); - if (!user || user.deactivatedAt || user.deletedAt) { + if (!user || isUserDisabled(user)) { throw new UnauthorizedException(); } diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index f9062878..b54bbea3 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -109,6 +109,7 @@ export class WorkspaceController { @HttpCode(HttpStatus.OK) @Post('members/deactivate') async deactivateWorkspaceMember( + @Body() dto: RemoveWorkspaceUserDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { @@ -118,6 +119,23 @@ export class WorkspaceController { ) { throw new ForbiddenException(); } + await this.workspaceService.deactivateUser(user, dto.userId, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('members/activate') + async activateWorkspaceMember( + @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.activateUser(user, dto.userId, workspace.id); } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index f344efe1..db2bf85a 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -616,6 +616,105 @@ export class WorkspaceService { return { hostname: this.domainService.getUrl(hostname) }; } + async deactivateUser( + 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'); + } + + if (user.deactivatedAt) { + throw new BadRequestException('User is already deactivated'); + } + + if (authUser.id === userId) { + throw new BadRequestException('You cannot deactivate yourself'); + } + + if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) { + throw new BadRequestException( + 'You cannot deactivate a user with owner role', + ); + } + + if (user.role === UserRole.OWNER) { + const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId( + UserRole.OWNER, + workspaceId, + ); + + if (workspaceOwnerCount === 1) { + throw new BadRequestException( + 'There must be at least one workspace owner', + ); + } + } + + await this.userRepo.updateUser( + { deactivatedAt: new Date() }, + userId, + workspaceId, + ); + + this.auditService.log({ + event: AuditEvent.USER_DEACTIVATED, + resourceType: AuditResource.USER, + resourceId: user.id, + changes: { + before: { + name: user.name, + email: user.email, + role: user.role, + }, + }, + }); + } + + async activateUser( + 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'); + } + + if (!user.deactivatedAt) { + throw new BadRequestException('User is not deactivated'); + } + + if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) { + throw new BadRequestException( + 'You cannot activate a user with owner role', + ); + } + + await this.userRepo.updateUser( + { deactivatedAt: null }, + userId, + workspaceId, + ); + + this.auditService.log({ + event: AuditEvent.USER_ACTIVATED, + resourceType: AuditResource.USER, + resourceId: user.id, + changes: { + before: { + name: user.name, + email: user.email, + role: user.role, + }, + }, + }); + } + async deleteUser( authUser: User, userId: string, diff --git a/apps/server/src/ee b/apps/server/src/ee index faf5aba4..c9f8c198 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit faf5aba4a86dd02b80f77c13dabd3b8c6eea31c5 +Subproject commit c9f8c1983e9993fbfdf7406e8cc5830eba01bd7d