feat: user deactivation

This commit is contained in:
Philipinho
2026-03-02 19:05:10 +00:00
parent a3fd79dae8
commit 721651e2e2
15 changed files with 282 additions and 17 deletions
@@ -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');
}
@@ -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<string> {
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<string> {
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<string> {
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -98,7 +99,7 @@ export class TokenService {
expiresIn?: string | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -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();
}
@@ -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)
@@ -616,6 +616,105 @@ export class WorkspaceService {
return { hostname: this.domainService.getUrl(hostname) };
}
async deactivateUser(
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');
}
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<void> {
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,