mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-24 09:28:31 +00:00
[Feature] Added ability to update and delete app folders (#3132)
* Added migration to add forder_delete column * Added new group permission * Added deleteFolder ability * Added delete folder api * Added menu icon * Added new defualt permissions of admin * Implemented folder menu and delete action * Implemented update folder name in frontend * Added folder name update feature * Refactoring code * Added specs for update and delete apis - Updated test-helper function with new permissions * Resolved failing specs * corrected method name & add count checking to delete spec * added organizationId scope * Changed toast and modal texts * Resolved a mistake * Added a check box for update permission * Now, an user can only delete folders, if he has the permission to view all apps * Edited update and delete spec cases * Added error toasts * Refactored code * Resolved PR changes - Changed permission name in the frontend - Refactored the code * capitalized all toasts - Changed error message * Fixed new user permission issue * Update a spec Co-authored-by: gsmithun4 <gsmithun4@gmail.com>
This commit is contained in:
parent
8f7a1baf44
commit
2fdcfcc117
21 changed files with 596 additions and 35 deletions
|
|
@ -6,6 +6,7 @@ export const commonSelectors={
|
|||
firstWidget:"[data-cy=widget-list]:eq(0)",
|
||||
canvas:"[data-cy=real-canvas]",
|
||||
appCardOptions: "[data-cy=app-card-menu-icon]",
|
||||
folderItemOptions: "[data-cy=folder-item-menu-icon]",
|
||||
deleteApp: "[data-cy=card-options] :nth-child(5)>span",
|
||||
confirmButton: "[data-cy=confirm-yes-button]",
|
||||
autoSave: "[data-cy=autosave-indicator]",
|
||||
|
|
|
|||
54
frontend/src/HomePage/FolderMenu.jsx
Normal file
54
frontend/src/HomePage/FolderMenu.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
||||
import Popover from 'react-bootstrap/Popover';
|
||||
|
||||
export const FolderMenu = function FolderMenu({
|
||||
deleteFolder,
|
||||
editFolder,
|
||||
canDeleteFolder,
|
||||
canUpdateFolder,
|
||||
onMenuOpen,
|
||||
darkMode,
|
||||
}) {
|
||||
const closeMenu = () => {
|
||||
document.body.click();
|
||||
};
|
||||
const Field = ({ text, onClick, customClass }) => {
|
||||
return (
|
||||
<div className={`field mb-3${customClass ? ` ${customClass}` : ''}`}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
rootClose
|
||||
onToggle={onMenuOpen}
|
||||
overlay={
|
||||
<Popover id="popover-app-menu" className={darkMode && 'popover-dark-themed'}>
|
||||
<Popover.Content bsPrefix="popover-body">
|
||||
<div data-cy="card-options">
|
||||
{canUpdateFolder && <Field text="Edit folder" onClick={editFolder} />}
|
||||
{canDeleteFolder && <Field text="Delete folder" customClass="field__danger" onClick={deleteFolder} />}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
}
|
||||
>
|
||||
<div className={`d-grid menu-ico menu-ico`}>
|
||||
<img className="svg-icon" src="/assets/images/icons/three-dots.svg" data-cy="folder-item-menu-icon" />
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { folderService } from '@/_services';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import Modal from './Modal';
|
||||
import { FolderMenu } from './FolderMenu';
|
||||
import useHover from '@/_hooks/useHover';
|
||||
import { ConfirmDialog } from '@/_components';
|
||||
|
||||
export const Folders = function Folders({
|
||||
folders,
|
||||
|
|
@ -10,9 +13,27 @@ export const Folders = function Folders({
|
|||
folderChanged,
|
||||
foldersChanged,
|
||||
canCreateFolder,
|
||||
canUpdateFolder,
|
||||
canDeleteFolder,
|
||||
darkMode,
|
||||
}) {
|
||||
const [isLoading, setLoadingStatus] = useState(foldersLoading);
|
||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||
const [hoverRef, isHovered] = useHover();
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const onMenuToggle = useCallback(
|
||||
(status) => {
|
||||
setMenuOpen(!!status);
|
||||
!status && !isHovered && setFocused(false);
|
||||
},
|
||||
[isHovered]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
!isMenuOpen && setFocused(!!isHovered);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingStatus(foldersLoading);
|
||||
|
|
@ -20,26 +41,28 @@ export const Folders = function Folders({
|
|||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [isCreating, setCreationStatus] = useState(false);
|
||||
const [isDeleting, setDeletionStatus] = useState(false);
|
||||
const [isUpdating, setUpdationStatus] = useState(false);
|
||||
const [deletingFolder, setDeletingFolder] = useState(null);
|
||||
const [updatingFolder, setUpdatingFolder] = useState(null);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [showUpdateForm, setShowUpdateForm] = useState(false);
|
||||
const [activeFolder, setActiveFolder] = useState(currentFolder || {});
|
||||
|
||||
function saveFolder() {
|
||||
if (!newFolderName || !newFolderName.trim()) {
|
||||
toast.error("folder name can't be empty.", {
|
||||
position: 'top-center',
|
||||
if (validateName()) {
|
||||
setCreationStatus(true);
|
||||
folderService.create(newFolderName).then(() => {
|
||||
toast.success('Folder created.', {
|
||||
position: 'top-center',
|
||||
});
|
||||
setCreationStatus(false);
|
||||
setShowForm(false);
|
||||
setNewFolderName('');
|
||||
foldersChanged();
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCreationStatus(true);
|
||||
folderService.create(newFolderName).then(() => {
|
||||
toast.success('folder created.', {
|
||||
position: 'top-center',
|
||||
});
|
||||
setCreationStatus(false);
|
||||
setShowForm(false);
|
||||
setNewFolderName('');
|
||||
foldersChanged();
|
||||
});
|
||||
}
|
||||
|
||||
function handleFolderChange(folder) {
|
||||
|
|
@ -47,8 +70,85 @@ export const Folders = function Folders({
|
|||
folderChanged(folder);
|
||||
}
|
||||
|
||||
function deleteFolder(folder) {
|
||||
setShowDeleteConfirmation(true);
|
||||
setDeletingFolder(folder);
|
||||
}
|
||||
|
||||
function updateFolder(folder) {
|
||||
setNewFolderName(folder.name);
|
||||
setShowUpdateForm(true);
|
||||
setUpdatingFolder(folder);
|
||||
}
|
||||
|
||||
function executeDeletion() {
|
||||
setDeletionStatus(true);
|
||||
folderService
|
||||
.deleteFolder(deletingFolder.id)
|
||||
.then(() => {
|
||||
toast.success('Folder has been deleted.', {
|
||||
position: 'top-center',
|
||||
});
|
||||
setShowDeleteConfirmation(false);
|
||||
setDeletionStatus(false);
|
||||
foldersChanged();
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error);
|
||||
setShowDeleteConfirmation(false);
|
||||
setDeletionStatus(false);
|
||||
});
|
||||
}
|
||||
|
||||
function cancelDeleteDialog() {
|
||||
setShowDeleteConfirmation(false);
|
||||
setDeletingFolder(null);
|
||||
}
|
||||
|
||||
function validateName() {
|
||||
if (!newFolderName?.trim()) {
|
||||
toast.error("Folder name can't be empty.", {
|
||||
position: 'top-center',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function executeEditFolder() {
|
||||
if (validateName()) {
|
||||
setUpdationStatus(true);
|
||||
folderService
|
||||
.updateFolder(newFolderName, updatingFolder.id)
|
||||
.then(() => {
|
||||
toast.success('Folder has been updated.', {
|
||||
position: 'top-center',
|
||||
});
|
||||
setUpdationStatus(false);
|
||||
setShowUpdateForm(false);
|
||||
setNewFolderName('');
|
||||
foldersChanged();
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error);
|
||||
setNewFolderName('');
|
||||
setUpdationStatus(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-100 px-3 pe-lg-4 folder-list">
|
||||
<ConfirmDialog
|
||||
show={showDeleteConfirmation}
|
||||
message={`Are you sure you want to delete the folder?
|
||||
Apps within the folder will not be deleted.`}
|
||||
confirmButtonLoading={isDeleting}
|
||||
onConfirm={() => executeDeletion()}
|
||||
onCancel={() => cancelDeleteDialog()}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-testid="applicationFoldersList"
|
||||
className={`list-group list-group-transparent mb-3 ${darkMode && 'dark'}`}
|
||||
|
|
@ -65,7 +165,13 @@ export const Folders = function Folders({
|
|||
<div className="d-flex justify-content-between mb-3">
|
||||
<div className="folder-info">Folders</div>
|
||||
{canCreateFolder && (
|
||||
<div className="folder-create-btn" onClick={() => setShowForm(true)}>
|
||||
<div
|
||||
className="folder-create-btn"
|
||||
onClick={() => {
|
||||
setNewFolderName('');
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
+ Create new folder
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -88,22 +194,40 @@ export const Folders = function Folders({
|
|||
? folders.map((folder, index) => (
|
||||
<a
|
||||
key={index}
|
||||
ref={hoverRef}
|
||||
className={`list-group-item list-group-item-action d-flex align-items-center ${
|
||||
activeFolder.id === folder.id ? 'active' : ''
|
||||
} ${darkMode && 'dark'}`}
|
||||
onClick={() => handleFolderChange(folder)}
|
||||
} ${darkMode && 'dark'} ${focused ? ' highlight' : ''}`}
|
||||
>
|
||||
<span className="me-2">
|
||||
<img src="/assets/images/icons/folder.svg" alt="" width="14" height="14" className="folder-ico" />
|
||||
</span>
|
||||
{`${folder.name}${folder.count > 0 ? ` (${folder.count})` : ''}`}
|
||||
<div onClick={() => handleFolderChange(folder)} className="flex-grow-1">
|
||||
<span className="me-2">
|
||||
<img src="/assets/images/icons/folder.svg" alt="" width="14" height="14" className="folder-ico" />
|
||||
</span>
|
||||
{`${folder.name}${folder.count > 0 ? ` (${folder.count})` : ''}`}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
{(canDeleteFolder || canUpdateFolder) && (
|
||||
<FolderMenu
|
||||
onMenuOpen={onMenuToggle}
|
||||
canDeleteFolder={canDeleteFolder}
|
||||
canUpdateFolder={canUpdateFolder}
|
||||
deleteFolder={() => deleteFolder(folder)}
|
||||
editFolder={() => updateFolder(folder)}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
: !isLoading && (
|
||||
<div className="folder-info">You haven't created any folders. Use folders to organize your apps</div>
|
||||
)}
|
||||
|
||||
<Modal show={showForm} closeModal={() => setShowForm(false)} title="Create folder">
|
||||
<Modal
|
||||
show={showForm || showUpdateForm}
|
||||
closeModal={() => (showUpdateForm ? setShowUpdateForm(false) : setShowForm(false))}
|
||||
title={showUpdateForm ? 'Update Folder' : 'Create folder'}
|
||||
>
|
||||
<div className="row">
|
||||
<div className="col modal-main">
|
||||
<input
|
||||
|
|
@ -111,18 +235,25 @@ export const Folders = function Folders({
|
|||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
className="form-control"
|
||||
placeholder="folder name"
|
||||
disabled={isCreating}
|
||||
disabled={isCreating || isUpdating}
|
||||
value={newFolderName}
|
||||
maxLength={25}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col d-flex modal-footer-btn">
|
||||
<button className="btn btn-light" onClick={() => setShowForm(false)}>
|
||||
<button
|
||||
className="btn btn-light"
|
||||
onClick={() => (showUpdateForm ? setShowUpdateForm(false) : setShowForm(false))}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={`btn btn-primary ${isCreating ? 'btn-loading' : ''}`} onClick={saveFolder}>
|
||||
Create folder
|
||||
<button
|
||||
className={`btn btn-primary ${isCreating || isUpdating ? 'btn-loading' : ''}`}
|
||||
onClick={showUpdateForm ? executeEditFolder : saveFolder}
|
||||
>
|
||||
{showUpdateForm ? 'Update folder' : 'Create folder'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -274,6 +274,14 @@ class HomePage extends React.Component {
|
|||
return this.canAnyGroupPerformAction('folder_create', this.state.currentUser.group_permissions);
|
||||
};
|
||||
|
||||
canDeleteFolder = () => {
|
||||
return this.canAnyGroupPerformAction('folder_delete', this.state.currentUser.group_permissions);
|
||||
};
|
||||
|
||||
canUpdateFolder = () => {
|
||||
return this.canAnyGroupPerformAction('folder_update', this.state.currentUser.group_permissions);
|
||||
};
|
||||
|
||||
cancelDeleteAppDialog = () => {
|
||||
this.setState({
|
||||
isDeletingApp: false,
|
||||
|
|
@ -622,6 +630,8 @@ class HomePage extends React.Component {
|
|||
folderChanged={this.folderChanged}
|
||||
foldersChanged={this.foldersChanged}
|
||||
canCreateFolder={this.canCreateFolder()}
|
||||
canDeleteFolder={this.canDeleteFolder()}
|
||||
canUpdateFolder={this.canUpdateFolder()}
|
||||
darkMode={this.props.darkMode}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -280,6 +280,10 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
selectedUserIds,
|
||||
} = this.state;
|
||||
|
||||
const folder_permission = groupPermission
|
||||
? groupPermission.folder_create && groupPermission.folder_delete && groupPermission.folder_update
|
||||
: false;
|
||||
|
||||
const appSelectOptions = appsNotInGroup.map((app) => {
|
||||
return { name: app.name, value: app.id };
|
||||
});
|
||||
|
|
@ -606,13 +610,15 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
type="checkbox"
|
||||
onChange={() => {
|
||||
this.updateGroupPermission(groupPermission.id, {
|
||||
folder_create: !groupPermission.folder_create,
|
||||
folder_create: !folder_permission,
|
||||
folder_delete: !folder_permission,
|
||||
folder_update: !folder_permission,
|
||||
});
|
||||
}}
|
||||
checked={groupPermission.folder_create}
|
||||
checked={folder_permission}
|
||||
disabled={groupPermission.group === 'admin'}
|
||||
/>
|
||||
<span className="form-check-label">Create</span>
|
||||
<span className="form-check-label">Create/Update/Delete</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { authHeader, handleResponse } from '@/_helpers';
|
|||
|
||||
export const folderService = {
|
||||
create,
|
||||
deleteFolder,
|
||||
getAll,
|
||||
addToFolder,
|
||||
removeAppFromFolder,
|
||||
updateFolder,
|
||||
};
|
||||
|
||||
function getAll(searchKey = '') {
|
||||
|
|
@ -26,6 +28,27 @@ function create(name) {
|
|||
return fetch(`${config.apiUrl}/folders`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function updateFolder(name, id) {
|
||||
const body = {
|
||||
name,
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
return fetch(`${config.apiUrl}/folders/${id}`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function deleteFolder(id) {
|
||||
const requestOptions = {
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
};
|
||||
return fetch(`${config.apiUrl}/folders/${id}`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function addToFolder(appId, folderId) {
|
||||
const body = {
|
||||
app_id: appId,
|
||||
|
|
|
|||
|
|
@ -4164,6 +4164,25 @@ input[type="text"] {
|
|||
color: #0565ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-ico {
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
border-radius: 13px;
|
||||
&__open {
|
||||
background-color: #d2ddec;
|
||||
}
|
||||
img {
|
||||
padding: 0px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
vertical-align: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-ico:hover {
|
||||
background-color: #d2ddec;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
export class AddFolderUpdateAndDeletePermissionToGroupPermissions1653391166172 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.addColumn(
|
||||
'group_permissions',
|
||||
new TableColumn({
|
||||
name: 'folder_delete',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
isNullable: false,
|
||||
})
|
||||
);
|
||||
|
||||
await queryRunner.addColumn(
|
||||
'group_permissions',
|
||||
new TableColumn({
|
||||
name: 'folder_update',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
isNullable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropColumn('group_permissions', 'folder_delete');
|
||||
await queryRunner.dropColumn('group_permissions', 'folder_update');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { GroupPermission } from '../src/entities/group_permission.entity';
|
||||
|
||||
export class BackfillFolderDeleteFolderUpdatePermissionsAsTruthyForAdminGroup1653474337657
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const entityManager = queryRunner.manager;
|
||||
const GroupPermissionRepostory = entityManager.getRepository(GroupPermission);
|
||||
|
||||
await GroupPermissionRepostory.update({ group: 'admin' }, { folderUpdate: true, folderDelete: true });
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const entityManager = queryRunner.manager;
|
||||
const GroupPermissionRepostory = entityManager.getRepository(GroupPermission);
|
||||
|
||||
await GroupPermissionRepostory.update({ group: 'admin' }, { folderUpdate: false, folderDelete: false });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller, Get, Post, Query, Request, UseGuards, Body } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Query, Request, UseGuards, Body, Delete, Param, Put } from '@nestjs/common';
|
||||
import { decamelizeKeys } from 'humps';
|
||||
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
|
||||
import { FoldersService } from '../services/folders.service';
|
||||
|
|
@ -6,6 +6,7 @@ import { ForbiddenException } from '@nestjs/common';
|
|||
import { FoldersAbilityFactory } from 'src/modules/casl/abilities/folders-ability.factory';
|
||||
import { Folder } from 'src/entities/folder.entity';
|
||||
import { CreateFolderDto } from '@dto/create-folder.dto';
|
||||
import { User } from 'src/decorators/user.decorator';
|
||||
|
||||
@Controller('folders')
|
||||
export class FoldersController {
|
||||
|
|
@ -31,4 +32,28 @@ export class FoldersController {
|
|||
const folder = await this.foldersService.create(req.user, folderName);
|
||||
return decamelizeKeys(folder);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
async update(@User() user, @Param('id') id, @Body('name') folderName: string) {
|
||||
const ability = await this.foldersAbilityFactory.folderActions(user, {});
|
||||
|
||||
if (!ability.can('updateFolder', Folder)) {
|
||||
throw new ForbiddenException('You do not have permissions to perform this action');
|
||||
}
|
||||
const folder = await this.foldersService.update(id, folderName);
|
||||
return decamelizeKeys(folder);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
async delete(@User() user, @Param('id') id) {
|
||||
const ability = await this.foldersAbilityFactory.folderActions(user, {});
|
||||
|
||||
if (!ability.can('deleteFolder', Folder)) {
|
||||
throw new ForbiddenException('You do not have permissions to perform this action');
|
||||
}
|
||||
|
||||
return await this.foldersService.delete(user, id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ export class GroupPermission extends BaseEntity {
|
|||
@Column({ name: 'folder_create', default: false })
|
||||
folderCreate: boolean;
|
||||
|
||||
@Column({ name: 'folder_delete', default: false })
|
||||
folderDelete: boolean;
|
||||
|
||||
@Column({ name: 'folder_update', default: false })
|
||||
folderUpdate: boolean;
|
||||
|
||||
@CreateDateColumn({ default: () => 'now()', name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Injectable } from '@nestjs/common';
|
|||
import { UsersService } from 'src/services/users.service';
|
||||
import { Folder } from 'src/entities/folder.entity';
|
||||
|
||||
type Actions = 'createFolder';
|
||||
type Actions = 'createFolder' | 'updateFolder' | 'deleteFolder';
|
||||
|
||||
type Subjects = InferSubjects<typeof User | typeof Folder> | 'all';
|
||||
|
||||
|
|
@ -21,6 +21,14 @@ export class FoldersAbilityFactory {
|
|||
can('createFolder', Folder);
|
||||
}
|
||||
|
||||
if (await this.usersService.userCan(user, 'update', 'Folder')) {
|
||||
can('updateFolder', Folder);
|
||||
}
|
||||
|
||||
if (await this.usersService.userCan(user, 'delete', 'Folder')) {
|
||||
can('deleteFolder', Folder);
|
||||
}
|
||||
|
||||
return build({
|
||||
detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
import { FolderApp } from 'src/entities/folder_app.entity';
|
||||
import { UserGroupPermission } from 'src/entities/user_group_permission.entity';
|
||||
import { Brackets, createQueryBuilder, Repository } from 'typeorm';
|
||||
import { Brackets, createQueryBuilder, Repository, UpdateResult } from 'typeorm';
|
||||
import { User } from '../../src/entities/user.entity';
|
||||
import { Folder } from '../entities/folder.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
|
@ -31,6 +31,10 @@ export class FoldersService {
|
|||
);
|
||||
}
|
||||
|
||||
async update(folderId: string, folderName: string): Promise<UpdateResult> {
|
||||
return this.foldersRepository.update({ id: folderId }, { name: folderName });
|
||||
}
|
||||
|
||||
async allFolders(user: User): Promise<Folder[]> {
|
||||
if (await this.usersService.hasGroup(user, 'admin')) {
|
||||
return await this.foldersRepository.find({
|
||||
|
|
@ -292,4 +296,36 @@ export class FoldersService {
|
|||
|
||||
return viewableAppsInFolder;
|
||||
}
|
||||
|
||||
async delete(user: User, id: string) {
|
||||
const folder = await this.foldersRepository.findOneOrFail({ id, organizationId: user.organizationId });
|
||||
const allViewableApps = await createQueryBuilder(App, 'apps')
|
||||
.select('apps.id')
|
||||
.innerJoin('apps.groupPermissions', 'group_permissions')
|
||||
.innerJoin('apps.appGroupPermissions', 'app_group_permissions')
|
||||
.innerJoin(
|
||||
UserGroupPermission,
|
||||
'user_group_permissions',
|
||||
'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id'
|
||||
)
|
||||
.where('user_group_permissions.user_id = :userId', { userId: user.id })
|
||||
.andWhere('app_group_permissions.read = :value', { value: true })
|
||||
.orWhere('apps.user_id = :userId', {
|
||||
value: true,
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
const allViewableAppIds = allViewableApps.map((app) => app.id);
|
||||
|
||||
folder.folderApps.map((folderApp: FolderApp) => {
|
||||
if (!allViewableAppIds.includes(folderApp.appId)) {
|
||||
throw new ForbiddenException(
|
||||
'Applications not authorised for you are included in the folder, please contact administrator to remove them and try again'
|
||||
);
|
||||
}
|
||||
});
|
||||
return await this.foldersRepository.delete({ id, organizationId: user.organizationId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,17 @@ export class GroupPermissionsService {
|
|||
},
|
||||
});
|
||||
|
||||
const { app_create, app_delete, add_apps, remove_apps, add_users, remove_users, folder_create } = body;
|
||||
const {
|
||||
app_create,
|
||||
app_delete,
|
||||
add_apps,
|
||||
remove_apps,
|
||||
add_users,
|
||||
remove_users,
|
||||
folder_create,
|
||||
folder_delete,
|
||||
folder_update,
|
||||
} = body;
|
||||
|
||||
await getManager().transaction(async (manager) => {
|
||||
// update group permissions
|
||||
|
|
@ -131,6 +141,8 @@ export class GroupPermissionsService {
|
|||
...(typeof app_create === 'boolean' && { appCreate: app_create }),
|
||||
...(typeof app_delete === 'boolean' && { appDelete: app_delete }),
|
||||
...(typeof folder_create === 'boolean' && { folderCreate: folder_create }),
|
||||
...(typeof folder_delete === 'boolean' && { folderDelete: folder_delete }),
|
||||
...(typeof folder_update === 'boolean' && { folderUpdate: folder_update }),
|
||||
};
|
||||
if (Object.keys(groupPermissionUpdateParams).length !== 0) {
|
||||
await manager.update(GroupPermission, groupPermissionId, groupPermissionUpdateParams);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ export class OrganizationsService {
|
|||
appCreate: isAdmin,
|
||||
appDelete: isAdmin,
|
||||
folderCreate: isAdmin,
|
||||
folderUpdate: isAdmin,
|
||||
folderDelete: isAdmin,
|
||||
});
|
||||
await this.groupPermissionsRepository.save(groupPermission);
|
||||
createdGroupPermissions.push(groupPermission);
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ export class SeedsService {
|
|||
appCreate: group == 'admin',
|
||||
appDelete: group == 'admin',
|
||||
folderCreate: group == 'admin',
|
||||
folderUpdate: group == 'admin',
|
||||
folderDelete: group == 'admin',
|
||||
});
|
||||
|
||||
await manager.save(groupPermission);
|
||||
|
|
|
|||
|
|
@ -311,6 +311,12 @@ export class UsersService {
|
|||
case 'create':
|
||||
permissionGrant = this.canAnyGroupPerformAction('folderCreate', await this.groupPermissions(user));
|
||||
break;
|
||||
case 'update':
|
||||
permissionGrant = this.canAnyGroupPerformAction('folderUpdate', await this.groupPermissions(user));
|
||||
break;
|
||||
case 'delete':
|
||||
permissionGrant = this.canAnyGroupPerformAction('folderDelete', await this.groupPermissions(user));
|
||||
break;
|
||||
default:
|
||||
permissionGrant = false;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -76,11 +76,15 @@ describe('Authentication', () => {
|
|||
expect(adminGroup.appCreate).toBeTruthy();
|
||||
expect(adminGroup.appDelete).toBeTruthy();
|
||||
expect(adminGroup.folderCreate).toBeTruthy();
|
||||
expect(adminGroup.folderUpdate).toBeTruthy();
|
||||
expect(adminGroup.folderDelete).toBeTruthy();
|
||||
|
||||
const allUserGroup = groupPermissions.find((x) => x.group == 'all_users');
|
||||
expect(allUserGroup.appCreate).toBeFalsy();
|
||||
expect(allUserGroup.appDelete).toBeFalsy();
|
||||
expect(allUserGroup.folderCreate).toBeFalsy();
|
||||
expect(allUserGroup.folderUpdate).toBeFalsy();
|
||||
expect(allUserGroup.folderDelete).toBeFalsy();
|
||||
});
|
||||
describe('Single organization operations', () => {
|
||||
beforeEach(async () => {
|
||||
|
|
@ -220,11 +224,15 @@ describe('Authentication', () => {
|
|||
expect(adminGroup.appCreate).toBeTruthy();
|
||||
expect(adminGroup.appDelete).toBeTruthy();
|
||||
expect(adminGroup.folderCreate).toBeTruthy();
|
||||
expect(adminGroup.folderUpdate).toBeTruthy();
|
||||
expect(adminGroup.folderDelete).toBeTruthy();
|
||||
|
||||
const allUserGroup = groupPermissions.find((x) => x.group == 'all_users');
|
||||
expect(allUserGroup.appCreate).toBeFalsy();
|
||||
expect(allUserGroup.appDelete).toBeFalsy();
|
||||
expect(allUserGroup.folderCreate).toBeFalsy();
|
||||
expect(allUserGroup.folderUpdate).toBeFalsy();
|
||||
expect(allUserGroup.folderDelete).toBeFalsy();
|
||||
});
|
||||
it('authenticate if valid credentials', async () => {
|
||||
await request(app.getHttpServer())
|
||||
|
|
@ -380,6 +388,8 @@ describe('Authentication', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_delete',
|
||||
'folder_update',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -441,6 +451,8 @@ describe('Authentication', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_delete',
|
||||
'folder_update',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
|
|||
|
|
@ -294,6 +294,114 @@ describe('folders controller', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/folders/:id', () => {
|
||||
it('should be able to update an existing folder if group is admin or has update permission in the same organization', async () => {
|
||||
const adminUserData = await createUser(nestApp, {
|
||||
email: 'admin@tooljet.io',
|
||||
groups: ['all_users', 'admin'],
|
||||
});
|
||||
const developerUserData = await createUser(nestApp, {
|
||||
email: 'dev@tooljet.io',
|
||||
groups: ['all_users', 'developer'],
|
||||
organization: adminUserData.organization,
|
||||
});
|
||||
|
||||
const viewerUserData = await createUser(nestApp, {
|
||||
email: 'viewer@tooljet.io',
|
||||
groups: ['viewer', 'all_users'],
|
||||
organization: adminUserData.organization,
|
||||
});
|
||||
|
||||
const developerGroup = await getManager().findOneOrFail(GroupPermission, {
|
||||
where: { group: 'developer' },
|
||||
});
|
||||
|
||||
await getManager().update(GroupPermission, developerGroup.id, {
|
||||
folderUpdate: true,
|
||||
});
|
||||
|
||||
const folder = await getManager().save(Folder, {
|
||||
name: 'Folder1',
|
||||
organizationId: adminUserData.organization.id,
|
||||
});
|
||||
|
||||
for (const userData of [adminUserData, developerUserData]) {
|
||||
await request(nestApp.getHttpServer())
|
||||
.put(`/api/folders/${folder.id}`)
|
||||
.set('Authorization', authHeaderForUser(userData.user))
|
||||
.send({ name: 'My folder' })
|
||||
.expect(200);
|
||||
|
||||
const updatedFolder = await getManager().findOne(Folder, folder.id);
|
||||
|
||||
expect(updatedFolder.name).toEqual('My folder');
|
||||
}
|
||||
|
||||
await request(nestApp.getHttpServer())
|
||||
.put(`/api/folders/${folder.id}`)
|
||||
.set('Authorization', authHeaderForUser(viewerUserData.user))
|
||||
.send({ name: 'My folder' })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/folders/:id', () => {
|
||||
it('should be able to delete an existing folder if group is admin or has delete permission in the same organization', async () => {
|
||||
const adminUserData = await createUser(nestApp, {
|
||||
email: 'admin@tooljet.io',
|
||||
groups: ['all_users', 'admin'],
|
||||
});
|
||||
const developerUserData = await createUser(nestApp, {
|
||||
email: 'dev@tooljet.io',
|
||||
groups: ['all_users', 'developer'],
|
||||
organization: adminUserData.organization,
|
||||
});
|
||||
|
||||
const viewerUserData = await createUser(nestApp, {
|
||||
email: 'viewer@tooljet.io',
|
||||
groups: ['viewer', 'all_users'],
|
||||
organization: adminUserData.organization,
|
||||
});
|
||||
|
||||
const developerGroup = await getManager().findOneOrFail(GroupPermission, {
|
||||
where: { group: 'developer' },
|
||||
});
|
||||
|
||||
await getManager().update(GroupPermission, developerGroup.id, {
|
||||
folderDelete: true,
|
||||
});
|
||||
|
||||
for (const userData of [adminUserData, developerUserData]) {
|
||||
const folder = await getManager().save(Folder, {
|
||||
name: 'Folder1',
|
||||
organizationId: adminUserData.organization.id,
|
||||
});
|
||||
|
||||
const preCount = await getManager().count(Folder);
|
||||
|
||||
await request(nestApp.getHttpServer())
|
||||
.delete(`/api/folders/${folder.id}`)
|
||||
.set('Authorization', authHeaderForUser(userData.user))
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
const postCount = await getManager().count(Folder);
|
||||
expect(postCount).toEqual(preCount - 1);
|
||||
}
|
||||
|
||||
const folder = await getManager().save(Folder, {
|
||||
name: 'Folder1',
|
||||
organizationId: adminUserData.organization.id,
|
||||
});
|
||||
|
||||
await request(nestApp.getHttpServer())
|
||||
.delete(`/api/folders/${folder.id}`)
|
||||
.set('Authorization', authHeaderForUser(viewerUserData.user))
|
||||
.send()
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -165,6 +165,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -235,6 +237,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -295,6 +299,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -372,6 +378,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -449,6 +457,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_delete',
|
||||
'folder_update',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -617,6 +627,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -702,6 +714,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -784,6 +798,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -856,6 +872,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -948,6 +966,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1038,6 +1058,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1128,6 +1150,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_delete',
|
||||
'folder_update',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1261,6 +1285,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1331,6 +1357,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1391,6 +1419,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1468,6 +1498,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1545,6 +1577,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_delete',
|
||||
'folder_update',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1713,6 +1747,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1798,6 +1834,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1880,6 +1918,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -1952,6 +1992,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -2044,6 +2086,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -2134,6 +2178,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_update',
|
||||
'folder_delete',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
@ -2224,6 +2270,8 @@ describe('oauth controller', () => {
|
|||
'updated_at',
|
||||
'created_at',
|
||||
'folder_create',
|
||||
'folder_delete',
|
||||
'folder_update',
|
||||
].sort()
|
||||
);
|
||||
expect(app_group_permissions).toHaveLength(0);
|
||||
|
|
|
|||
|
|
@ -321,6 +321,8 @@ export async function maybeCreateDefaultGroupPermissions(nestApp, organizationId
|
|||
appCreate: group == 'admin',
|
||||
appDelete: group == 'admin',
|
||||
folderCreate: group == 'admin',
|
||||
folderUpdate: group == 'admin',
|
||||
folderDelete: group == 'admin',
|
||||
});
|
||||
await groupPermissionRepository.save(groupPermission);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue