mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 18:41:14 +00:00
Adds soft‑deletion support for workflows through a new boolean column `isArchived`. When a workflow is archived we now set `isArchived` flag to true and the workflows stays in the database and is omitted from the default workflow listing query. Archived workflows can be viewed in read-only mode, but they cannot be activated. Archived workflows are still available by ID and can be invoked as sub-executions, so existing Execute Workflow nodes continue to work. Execution engine doesn't care about isArchived flag. Users can restore workflows via Unarchive action at the UI.
312 lines
9.3 KiB
TypeScript
312 lines
9.3 KiB
TypeScript
import { RoleChangeRequestDto, SettingsUpdateRequestDto } from '@n8n/api-types';
|
|
import type { PublicUser } from '@n8n/db';
|
|
import { Project, User, AuthIdentity, ProjectRepository } from '@n8n/db';
|
|
import {
|
|
GlobalScope,
|
|
Delete,
|
|
Get,
|
|
RestController,
|
|
Patch,
|
|
Licensed,
|
|
Body,
|
|
Param,
|
|
} from '@n8n/decorators';
|
|
import { Response } from 'express';
|
|
import { Logger } from 'n8n-core';
|
|
|
|
import { AuthService } from '@/auth/auth.service';
|
|
import { CredentialsService } from '@/credentials/credentials.service';
|
|
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
|
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
|
import { UserRepository } from '@/databases/repositories/user.repository';
|
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
|
import { EventService } from '@/events/event.service';
|
|
import { ExternalHooks } from '@/external-hooks';
|
|
import { listQueryMiddleware } from '@/middlewares';
|
|
import { ListQuery, AuthenticatedRequest, UserRequest } from '@/requests';
|
|
import { FolderService } from '@/services/folder.service';
|
|
import { ProjectService } from '@/services/project.service.ee';
|
|
import { UserService } from '@/services/user.service';
|
|
import { WorkflowService } from '@/workflows/workflow.service';
|
|
|
|
@RestController('/users')
|
|
export class UsersController {
|
|
constructor(
|
|
private readonly logger: Logger,
|
|
private readonly externalHooks: ExternalHooks,
|
|
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
|
private readonly userRepository: UserRepository,
|
|
private readonly authService: AuthService,
|
|
private readonly userService: UserService,
|
|
private readonly projectRepository: ProjectRepository,
|
|
private readonly workflowService: WorkflowService,
|
|
private readonly credentialsService: CredentialsService,
|
|
private readonly projectService: ProjectService,
|
|
private readonly eventService: EventService,
|
|
private readonly folderService: FolderService,
|
|
) {}
|
|
|
|
static ERROR_MESSAGES = {
|
|
CHANGE_ROLE: {
|
|
NO_USER: 'Target user not found',
|
|
NO_ADMIN_ON_OWNER: 'Admin cannot change role on global owner',
|
|
NO_OWNER_ON_OWNER: 'Owner cannot change role on global owner',
|
|
},
|
|
} as const;
|
|
|
|
private removeSupplementaryFields(
|
|
publicUsers: Array<Partial<PublicUser>>,
|
|
listQueryOptions: ListQuery.Options,
|
|
) {
|
|
const { take, select, filter } = listQueryOptions;
|
|
|
|
// remove fields added to satisfy query
|
|
|
|
if (take && select && !select?.id) {
|
|
for (const user of publicUsers) delete user.id;
|
|
}
|
|
|
|
if (filter?.isOwner) {
|
|
for (const user of publicUsers) delete user.role;
|
|
}
|
|
|
|
// remove computed fields (unselectable)
|
|
|
|
if (select) {
|
|
for (const user of publicUsers) {
|
|
delete user.isOwner;
|
|
delete user.isPending;
|
|
delete user.signInType;
|
|
}
|
|
}
|
|
|
|
return publicUsers;
|
|
}
|
|
|
|
@Get('/', { middlewares: listQueryMiddleware })
|
|
@GlobalScope('user:list')
|
|
async listUsers(req: ListQuery.Request) {
|
|
const { listQueryOptions } = req;
|
|
|
|
const findManyOptions = await this.userRepository.toFindManyOptions(listQueryOptions);
|
|
|
|
const users = await this.userRepository.find(findManyOptions);
|
|
|
|
const publicUsers: Array<Partial<PublicUser>> = await Promise.all(
|
|
users.map(
|
|
async (u) =>
|
|
await this.userService.toPublic(u, { withInviteUrl: true, inviterId: req.user.id }),
|
|
),
|
|
);
|
|
|
|
return listQueryOptions
|
|
? this.removeSupplementaryFields(publicUsers, listQueryOptions)
|
|
: publicUsers;
|
|
}
|
|
|
|
@Get('/:id/password-reset-link')
|
|
@GlobalScope('user:resetPassword')
|
|
async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) {
|
|
const user = await this.userRepository.findOneOrFail({
|
|
where: { id: req.params.id },
|
|
});
|
|
if (!user) {
|
|
throw new NotFoundError('User not found');
|
|
}
|
|
|
|
if (req.user.role === 'global:admin' && user.role === 'global:owner') {
|
|
throw new ForbiddenError('Admin cannot reset password of global owner');
|
|
}
|
|
|
|
const link = this.authService.generatePasswordResetUrl(user);
|
|
return { link };
|
|
}
|
|
|
|
@Patch('/:id/settings')
|
|
@GlobalScope('user:update')
|
|
async updateUserSettings(
|
|
_req: AuthenticatedRequest,
|
|
_res: Response,
|
|
@Body payload: SettingsUpdateRequestDto,
|
|
@Param('id') id: string,
|
|
) {
|
|
await this.userService.updateSettings(id, payload);
|
|
|
|
const user = await this.userRepository.findOneOrFail({
|
|
select: ['settings'],
|
|
where: { id },
|
|
});
|
|
|
|
return user.settings;
|
|
}
|
|
|
|
/**
|
|
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
|
|
*/
|
|
@Delete('/:id')
|
|
@GlobalScope('user:delete')
|
|
async deleteUser(req: UserRequest.Delete) {
|
|
const { id: idToDelete } = req.params;
|
|
|
|
if (req.user.id === idToDelete) {
|
|
this.logger.debug(
|
|
'Request to delete a user failed because it attempted to delete the requesting user',
|
|
{ userId: req.user.id },
|
|
);
|
|
throw new BadRequestError('Cannot delete your own user');
|
|
}
|
|
|
|
const { transferId } = req.query;
|
|
|
|
const userToDelete = await this.userRepository.findOneBy({ id: idToDelete });
|
|
|
|
if (!userToDelete) {
|
|
throw new NotFoundError(
|
|
'Request to delete a user failed because the user to delete was not found in DB',
|
|
);
|
|
}
|
|
|
|
if (userToDelete.role === 'global:owner') {
|
|
throw new ForbiddenError('Instance owner cannot be deleted.');
|
|
}
|
|
|
|
const personalProjectToDelete = await this.projectRepository.getPersonalProjectForUserOrFail(
|
|
userToDelete.id,
|
|
);
|
|
|
|
if (transferId === personalProjectToDelete.id) {
|
|
throw new BadRequestError(
|
|
'Request to delete a user failed because the user to delete and the transferee are the same user',
|
|
);
|
|
}
|
|
|
|
let transfereeId;
|
|
|
|
if (transferId) {
|
|
const transfereeProject = await this.projectRepository.findOneBy({ id: transferId });
|
|
|
|
if (!transfereeProject) {
|
|
throw new NotFoundError(
|
|
'Request to delete a user failed because the transferee project was not found in DB',
|
|
);
|
|
}
|
|
|
|
const transferee = await this.userRepository.findOneByOrFail({
|
|
projectRelations: {
|
|
projectId: transfereeProject.id,
|
|
},
|
|
});
|
|
|
|
transfereeId = transferee.id;
|
|
|
|
await this.userService.getManager().transaction(async (trx) => {
|
|
await this.workflowService.transferAll(
|
|
personalProjectToDelete.id,
|
|
transfereeProject.id,
|
|
trx,
|
|
);
|
|
await this.credentialsService.transferAll(
|
|
personalProjectToDelete.id,
|
|
transfereeProject.id,
|
|
trx,
|
|
);
|
|
|
|
await this.folderService.transferAllFoldersToProject(
|
|
personalProjectToDelete.id,
|
|
transfereeProject.id,
|
|
trx,
|
|
);
|
|
});
|
|
|
|
await this.projectService.clearCredentialCanUseExternalSecretsCache(transfereeProject.id);
|
|
}
|
|
|
|
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
|
|
this.sharedWorkflowRepository.find({
|
|
select: { workflowId: true },
|
|
where: { projectId: personalProjectToDelete.id, role: 'workflow:owner' },
|
|
}),
|
|
this.sharedCredentialsRepository.find({
|
|
relations: { credentials: true },
|
|
where: { projectId: personalProjectToDelete.id, role: 'credential:owner' },
|
|
}),
|
|
]);
|
|
|
|
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
|
|
|
|
for (const { workflowId } of ownedSharedWorkflows) {
|
|
await this.workflowService.delete(userToDelete, workflowId, true);
|
|
}
|
|
|
|
for (const credential of ownedCredentials) {
|
|
await this.credentialsService.delete(userToDelete, credential.id);
|
|
}
|
|
|
|
await this.userService.getManager().transaction(async (trx) => {
|
|
await trx.delete(AuthIdentity, { userId: userToDelete.id });
|
|
await trx.delete(Project, { id: personalProjectToDelete.id });
|
|
await trx.delete(User, { id: userToDelete.id });
|
|
});
|
|
|
|
this.eventService.emit('user-deleted', {
|
|
user: req.user,
|
|
publicApi: false,
|
|
targetUserOldStatus: userToDelete.isPending ? 'invited' : 'active',
|
|
targetUserId: idToDelete,
|
|
migrationStrategy: transferId ? 'transfer_data' : 'delete_data',
|
|
migrationUserId: transfereeId,
|
|
});
|
|
|
|
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
@Patch('/:id/role')
|
|
@GlobalScope('user:changeRole')
|
|
@Licensed('feat:advancedPermissions')
|
|
async changeGlobalRole(
|
|
req: AuthenticatedRequest,
|
|
_: Response,
|
|
@Body payload: RoleChangeRequestDto,
|
|
@Param('id') id: string,
|
|
) {
|
|
const { NO_ADMIN_ON_OWNER, NO_USER, NO_OWNER_ON_OWNER } =
|
|
UsersController.ERROR_MESSAGES.CHANGE_ROLE;
|
|
|
|
const targetUser = await this.userRepository.findOneBy({ id });
|
|
if (targetUser === null) {
|
|
throw new NotFoundError(NO_USER);
|
|
}
|
|
|
|
if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') {
|
|
throw new ForbiddenError(NO_ADMIN_ON_OWNER);
|
|
}
|
|
|
|
if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') {
|
|
throw new ForbiddenError(NO_OWNER_ON_OWNER);
|
|
}
|
|
|
|
await this.userService.changeUserRole(req.user, targetUser, payload);
|
|
|
|
this.eventService.emit('user-changed-role', {
|
|
userId: req.user.id,
|
|
targetUserId: targetUser.id,
|
|
targetUserNewRole: payload.newRoleName,
|
|
publicApi: false,
|
|
});
|
|
|
|
const projects = await this.projectService.getUserOwnedOrAdminProjects(targetUser.id);
|
|
await Promise.all(
|
|
projects.map(
|
|
async (p) => await this.projectService.clearCredentialCanUseExternalSecretsCache(p.id),
|
|
),
|
|
);
|
|
|
|
return { success: true };
|
|
}
|
|
}
|