Merge pull request #12566 from ToolJet/feat/page-permissions-be

Feat: Backend for Page level permissions for end-users and Frontend changes
This commit is contained in:
Johnson Cherian 2025-04-25 12:45:34 +05:30 committed by GitHub
commit 234e9fdfd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1887 additions and 252 deletions

View file

@ -0,0 +1,3 @@
<svg width="11" height="14" viewBox="0 0 11 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.33317 3.49967C3.33317 2.30306 4.30322 1.33301 5.49984 1.33301C6.69645 1.33301 7.6665 2.30306 7.6665 3.49967V4.33301H3.33317V3.49967ZM2.33317 4.37981V3.49967C2.33317 1.75077 3.75094 0.333008 5.49984 0.333008C7.24874 0.333008 8.6665 1.75077 8.6665 3.49967V4.37981C9.90027 4.61384 10.8332 5.69781 10.8332 6.99967V10.9997C10.8332 12.4724 9.63926 13.6663 8.1665 13.6663H2.83317C1.36041 13.6663 0.166504 12.4724 0.166504 10.9997V6.99967C0.166504 5.69781 1.09941 4.61384 2.33317 4.37981ZM6.83317 8.99967C6.83317 9.73605 6.23622 10.333 5.49984 10.333C4.76346 10.333 4.1665 9.73605 4.1665 8.99967C4.1665 8.2633 4.76346 7.66634 5.49984 7.66634C6.23622 7.66634 6.83317 8.2633 6.83317 8.99967Z" fill="#ACB2B9"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

@ -1 +1 @@
Subproject commit a1435b3b0e66a0c731812256d3d495d5bf48d5bb
Subproject commit dcd948d284b5f14a868480830e09b90496db8572

View file

@ -2,6 +2,9 @@ import React from 'react';
import { Overlay, Popover } from 'react-bootstrap';
import { Button } from '@/_ui/LeftSidebar';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { ToolTip } from '@/_components/ToolTip';
import SolidIcon from '@/_ui/Icon/SolidIcons';
export const PageHandlerMenu = ({ darkMode }) => {
const setShowEditingPopover = useStore((state) => state.setShowEditingPopover);
@ -20,23 +23,9 @@ export const PageHandlerMenu = ({ darkMode }) => {
const toggleDeleteConfirmationModal = useStore((state) => state.toggleDeleteConfirmationModal);
const clonePage = useStore((state) => state.clonePage);
const markAsHomePage = useStore((state) => state.markAsHomePage);
// const popoverTargetRef = null;
// console.log(
// {
// setShowEditingPopover,
// setShowRenameHandlerModal,
// setEditingPage,
// setShowPageEventsModal,
// popoverTargetRef,
// editingPage,
// showRenameHandlerModal,
// showPageEventsModal,
// setEditingPageName,
// showEditingPopover,
// closeEditingPopover,
// },
// 'editingPage'
// );
const togglePagePermissionModal = useStore((state) => state.togglePagePermissionModal);
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const closeMenu = () => {
closePageEditPopover();
@ -119,7 +108,6 @@ export const PageHandlerMenu = ({ darkMode }) => {
callback={() => markAsHomePage(editingPage.id)}
/>
)}
{!isDisabled && (
<Field
id={isHidden ? 'unhide-page' : 'hide-page'}
@ -132,7 +120,6 @@ export const PageHandlerMenu = ({ darkMode }) => {
disabled={isHomePage}
/>
)}
<Field
id="clone-page"
text="Duplicate page"
@ -142,7 +129,6 @@ export const PageHandlerMenu = ({ darkMode }) => {
clonePage(editingPage.id);
}}
/>
<Field
id="settings"
text="Event Handlers"
@ -164,6 +150,32 @@ export const PageHandlerMenu = ({ darkMode }) => {
}}
disabled={isHomePage}
/>
<Field
id={isDisabled ? 'enable-page' : 'disable-page'}
disabled={!licenseValid}
classNames={'page-permission-btn'}
text={() => {
return (
<ToolTip
message={'Page permissions are available only in paid plans'}
placement="right"
show={!licenseValid}
>
<div className="d-flex align-items-center">
<div>Page permission</div>
{!licenseValid && <SolidIcon name="enterprisesmall" />}
</div>
</ToolTip>
);
}}
customClass={'delete-btn'}
iconSrc={`assets/images/icons/editor/left-sidebar/authorization.svg`}
closeMenu={closeMenu}
callback={(id) => {
togglePagePermissionModal(true);
}}
/>
<Field
id="delete-page"
text="Delete page"
@ -223,7 +235,16 @@ const PageHandleField = ({ page, updatePageHandle }) => {
);
};
const Field = ({ id, text, iconSrc, customClass = '', closeMenu, disabled = false, callback = () => null }) => {
const Field = ({
id,
text,
iconSrc,
customClass = '',
classNames,
closeMenu,
disabled = false,
callback = () => null,
}) => {
const handleOnClick = (e) => {
e.preventDefault();
e.stopPropagation();
@ -233,7 +254,12 @@ const Field = ({ id, text, iconSrc, customClass = '', closeMenu, disabled = fals
return (
<div className={`field ${customClass ? ` ${customClass}` : ''}`}>
<Button.UnstyledButton onClick={handleOnClick} styles={{ height: '28px' }} disabled={disabled}>
<Button.UnstyledButton
onClick={handleOnClick}
styles={{ height: '28px' }}
classNames={classNames}
disabled={disabled}
>
<Button.Content title={text} iconSrc={iconSrc} direction="left" />
</Button.UnstyledButton>
</div>

View file

@ -16,6 +16,7 @@ import { EditModal } from './EditModal';
import { SettingsModal } from './SettingsModal';
import { DeletePageConfirmationModal } from './DeletePageConfirmationModal';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import PagePermission from './PagePermission';
export const PageMenu = ({ darkMode, switchPage, pinned, setPinned }) => {
const showAddNewPageInput = useStore((state) => state.showAddNewPageInput);
@ -94,6 +95,7 @@ export const PageMenu = ({ darkMode, switchPage, pinned, setPinned }) => {
>
<div>
<PageHandlerMenu darkMode={darkMode} />
{isLicensed ? <PagePermission darkMode={darkMode} /> : <></>}
<EditModal darkMode={darkMode} />
<SettingsModal darkMode={darkMode} />
<DeletePageConfirmationModal darkMode={darkMode} />

View file

@ -16,6 +16,7 @@ import { RenameInput } from './RenameInput';
import IconSelector from './IconSelector';
import { withRouter } from '@/_hoc/withRouter';
import OverflowTooltip from '@/_components/OverflowTooltip';
import { shallow } from 'zustand/shallow';
export const PageMenuItem = withRouter(
memo(({ darkMode, page, navigate }) => {
@ -27,7 +28,10 @@ export const PageMenuItem = withRouter(
const isDisabled = page?.disabled ?? false;
const [isHovered, setIsHovered] = useState(false);
const shouldFreeze = useStore((state) => state.getShouldFreeze());
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const showEditingPopover = useStore((state) => state.showEditingPopover);
const restricted = page?.permissions && page?.permissions?.length > 0;
const {
definition: { styles, properties },
} = useStore((state) => state.pageSettings);
@ -195,8 +199,11 @@ export const PageMenuItem = withRouter(
{isHidden && !isDisabled && 'Hidden'}
</span>
</div>
{!shouldFreeze && (
<div className={cx('right', { 'handler-menu-open': showEditingPopover })}>
<div style={{ marginLeft: '8px', marginRight: 'auto' }}>
{licenseValid && restricted && <SolidIcon width="16" name="lock" fill="var(--icon-strong)" />}
</div>
<div className={cx('right', { 'handler-menu-open': showEditingPopover })}>
{!shouldFreeze && (
<button
style={{
backgroundColor: 'transparent',
@ -215,8 +222,8 @@ export const PageMenuItem = withRouter(
>
<SolidIcon width="20" dataCy={`page-menu`} name="morevertical" />
</button>
</div>
)}
)}
</div>
</>
)}
</div>

View file

@ -0,0 +1,516 @@
import React, { useEffect, useMemo, useState } from 'react';
import { components } from 'react-select';
import ModalBase from '@/_ui/Modal';
import Select from '@/_ui/Select';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import useStore from '@/AppBuilder/_stores/store';
import { appPermissionService } from '@/_services';
import { ConfirmDialog } from '@/_components';
import toast from 'react-hot-toast';
import Spinner from '@/_ui/Spinner';
const PERMISSION_TYPES = {
single: 'SINGLE',
group: 'GROUP',
all: 'ALL',
};
export default function PagePermission({ darkMode }) {
const showPagePermissionModal = useStore((state) => state.showPagePermissionModal);
const togglePagePermissionModal = useStore((state) => state.togglePagePermissionModal);
const editingPage = useStore((state) => state.editingPage);
const appId = useStore((state) => state.app.appId);
const selectedUserGroups = useStore((state) => state.selectedUserGroups);
const setSelectedUserGroups = useStore((state) => state.setSelectedUserGroups);
const selectedUsers = useStore((state) => state.selectedUsers);
const setSelectedUsers = useStore((state) => state.setSelectedUsers);
const pagePermission = useStore((state) => state.pagePermission);
const setPagePermission = useStore((state) => state.setPagePermission);
const updatePageWithPermissions = useStore((state) => state.updatePageWithPermissions);
const [pagePermissionType, setPagePermissionType] = useState('all');
const [showUserGroupSelect, toggleUserGroupSelect] = useState(false);
const [showUsersSelect, toggleUsersSelect] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isPermissionsLoading, setPermissionsLoading] = useState(true);
const [pageToDelete, setPageToDelete] = useState(null);
const [initialSelectedGroups, setInitialSelectedGroups] = useState([]);
const [initialSelectedUsers, setInitialSelectedUsers] = useState([]);
const [initalPagePermissionType, setInitialPagePermissionType] = useState('all');
useEffect(() => {
if (!showPagePermissionModal) return;
const fetchPagePermission = () => {
appPermissionService.getPagePermission(appId, editingPage?.id || pageToDelete).then((data) => {
if (data) {
if (data[0] && data[0]?.type === PERMISSION_TYPES.group) {
const groups =
data[0]?.groups?.map((user) => ({
label: user?.permissionGroup?.name,
value: user?.permissionGroup?.id,
count: user?.permissionGroup?.count,
})) ?? [];
setPagePermissionType(data[0]?.type?.toLowerCase());
setInitialPagePermissionType(data[0]?.type?.toLowerCase());
setPagePermission(data);
toggleUserGroupSelect(true);
setPageToDelete(null);
setInitialSelectedGroups(groups);
data?.length && setSelectedUserGroups(groups);
} else if (data[0] && data[0]?.type === PERMISSION_TYPES.single) {
const users =
data[0]?.users?.map(({ user }) => {
const firstName = user.firstName || '';
const lastName = user.lastName || '';
return {
value: user.id,
label: `${firstName} ${lastName}`.trim(),
email: user.email,
initials: `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(),
};
}) ?? [];
setPagePermissionType(data[0]?.type?.toLowerCase());
setInitialPagePermissionType(data[0]?.type?.toLowerCase());
setPagePermission(data);
toggleUsersSelect(true);
setPageToDelete(null);
setInitialSelectedUsers(users);
data?.length && setSelectedUsers(users);
}
}
setPermissionsLoading(false);
});
};
fetchPagePermission();
}, [showPagePermissionModal, pageToDelete]);
const isSelectionUnchanged = useMemo(() => {
if (pagePermissionType === 'group') {
if (!selectedUserGroups.length) return true;
const current = selectedUserGroups
.map((g) => g.value)
.sort()
.join(',');
const initial = initialSelectedGroups
.map((g) => g.value)
.sort()
.join(',');
return current === initial;
} else if (pagePermissionType === 'single') {
if (!selectedUsers.length) return true;
const current = selectedUsers
.map((u) => u.value)
.sort()
.join(',');
const initial = initialSelectedUsers
.map((u) => u.value)
.sort()
.join(',');
return current === initial;
} else {
if (!pagePermission?.length) {
return true;
} else {
return initalPagePermissionType == pagePermissionType;
}
}
}, [
pagePermissionType,
selectedUserGroups,
initialSelectedGroups,
selectedUsers,
initialSelectedUsers,
initalPagePermissionType,
]);
const permissionTypeOptions = useMemo(
() => [
{
label: 'All users with access to the app',
value: 'all',
icon: 'globe',
},
{
label: 'Users',
value: 'single',
icon: 'user',
},
{
label: 'User groups',
value: 'group',
icon: 'usergroup',
},
],
[]
);
const handlePermissionTypeChange = (value) => {
switch (value) {
case 'group': {
toggleUserGroupSelect(true);
toggleUsersSelect(false);
setPagePermissionType('group');
break;
}
case 'single': {
toggleUsersSelect(true);
toggleUserGroupSelect(false);
setPagePermissionType('single');
break;
}
case 'all': {
toggleUsersSelect(false);
toggleUserGroupSelect(false);
setPagePermissionType('all');
}
}
};
const handlePagePermissionModalClose = () => {
togglePagePermissionModal(false);
toggleUserGroupSelect(false);
toggleUsersSelect(false);
setPagePermissionType('all');
setPagePermission(null);
setSelectedUsers([]);
setSelectedUserGroups([]);
setInitialSelectedGroups([]);
setInitialSelectedUsers([]);
};
const createPagePermission = () => {
const body = {
pageId: editingPage?.id,
type: PERMISSION_TYPES[pagePermissionType],
...(pagePermissionType === 'group'
? { groups: selectedUserGroups.map((group) => group?.value) }
: { users: selectedUsers.map((user) => user?.value) }),
};
setIsLoading(true);
appPermissionService
.createPagePermission(appId, editingPage?.id, body)
.then((data) => {
toast.success('Permission successfully created!', {
className: 'text-nowrap w-auto mw-100',
});
updatePageWithPermissions(editingPage?.id, data);
})
.catch(() => {
toast.error('Permission could not be created. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
})
.finally(() => {
setIsLoading(false);
handlePagePermissionModalClose();
});
};
const updatePagePermission = () => {
const body = {
pageId: editingPage?.id,
type: PERMISSION_TYPES[pagePermissionType],
...(pagePermissionType === 'group'
? { groups: selectedUserGroups.map((group) => group?.value) }
: { users: selectedUsers.map((user) => user?.value) }),
};
setIsLoading(true);
appPermissionService
.updatePagePermission(appId, editingPage?.id, body)
.then((data) => {
toast.success('Permission successfully updated!', {
className: 'text-nowrap w-auto mw-100',
});
updatePageWithPermissions(editingPage?.id, data);
})
.catch(() => {
toast.error('Permission could not be updated. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
})
.finally(() => {
setIsLoading(false);
handlePagePermissionModalClose();
});
};
const deletePagePermission = () => {
setIsLoading(true);
appPermissionService
.deletePagePermission(appId, pageToDelete)
.then((data) => {
toast.success('Permission successfully deleted!', {
className: 'text-nowrap w-auto mw-100',
});
updatePageWithPermissions(pageToDelete, []);
setPageToDelete(null);
})
.catch(() => {
toast.error('Permission could not be deleted. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
setShowConfirmDelete(false);
togglePagePermissionModal(true);
})
.finally(() => {
setIsLoading(false);
setShowConfirmDelete(false);
});
};
const renderPermissionTypeOptions = ({ label, icon }) => {
return (
<div className="row permission-type-select" style={{ padding: '0px 8px' }}>
<div className="col-auto">
<SolidIcon width="20" name={icon} />
</div>
<div className="col">
<span>{label}</span>
</div>
</div>
);
};
return (
<>
<ModalBase
title={
<div className="my-3">
<span className="tj-text-md font-weight-500">Page permission</span>
</div>
}
handleConfirm={!pagePermission ? createPagePermission : updatePagePermission}
show={showPagePermissionModal}
isLoading={isLoading}
handleClose={handlePagePermissionModalClose}
confirmBtnProps={{
title: pagePermission ? 'Update' : pagePermissionType === 'all' ? 'Default permission' : 'Create permission',
disabled: isPermissionsLoading || isSelectionUnchanged,
tooltipMessage: '',
}}
darkMode={darkMode}
className="page-permissions-modal"
headerAction={() =>
pagePermission && (
<span
onClick={(e) => {
setPageToDelete(editingPage?.id);
togglePagePermissionModal(false);
setShowConfirmDelete(true);
}}
>
<SolidIcon fill="var(--tomato10)" width="20" name="trash" />
</span>
)
}
>
<div className="page-permission">
{isPermissionsLoading ? (
<div className="spinner-center">
<Spinner />
</div>
) : (
<>
<div className="info-container">
<div className="col-md-1 info-btn">
<SolidIcon name="informationcircle" fill="#3E63DD" />
</div>
<div className="col-md-11">
<div className="message">
<p style={{ lineHeight: '18px' }}>
Only selected users will be allowed to access this page. Read docs to know more.
</p>
</div>
</div>
</div>
<label className="form-label">Type</label>
<Select
options={permissionTypeOptions}
value={pagePermissionType}
width={'100%'}
customOption={renderPermissionTypeOptions}
useMenuPortal={false}
onChange={handlePermissionTypeChange}
/>
{showUserGroupSelect && <UserGroupSelect />}
{showUsersSelect && <UserSelect />}
</>
)}
</div>
</ModalBase>
{showConfirmDelete && (
<ConfirmDialog
title={'Delete page permission'}
show={showConfirmDelete}
message={
'Deleting the permission will allow all users with access to the app to view this page. Are you sure you want to continue?'
}
confirmButtonLoading={isLoading}
onConfirm={() => deletePagePermission()}
onCancel={() => setShowConfirmDelete(false)}
confirmButtonText={'Delete'}
darkMode={darkMode}
confirmButtonIcon={'trash'}
confirmButtonIconWidth="20"
confirmButtonIconFill={'var(--slate3)'}
/>
)}
</>
);
}
const UserGroupSelect = () => {
const appId = useStore((state) => state.app.appId);
const selectedUserGroups = useStore((state) => state.selectedUserGroups);
const setSelectedUserGroups = useStore((state) => state.setSelectedUserGroups);
const [userGroups, setUserGroups] = useState([]);
useEffect(() => {
const fetchUserGroups = () => {
appPermissionService.getUsers(appId, 'user-groups').then((data) => {
if (data?.length) {
const groups = [];
data.map((group) => {
groups.push({ value: group.id, label: group.name, count: group.count });
});
setUserGroups(groups);
}
});
};
fetchUserGroups();
}, []);
const CustomOption = (props) => {
const { data, isFocused, isSelected } = props;
return (
<components.Option {...props}>
<div className={`user-select-option ${isFocused ? 'focused' : ''}`}>
<input
style={{ width: '1.2rem', height: '1.2rem', borderRadius: '6px !important' }}
type={'checkbox'}
className="form-check-input"
checked={isSelected}
/>
<div className="group-info">
<div className="name">{data.label}</div>
<div className="count">{data.count} users</div>
</div>
</div>
</components.Option>
);
};
return (
<div>
<label className="form-label mt-3">User groups</label>
<Select
isMulti={true}
options={userGroups}
value={selectedUserGroups}
width={'100%'}
closeMenuOnSelect={false}
components={{ Option: CustomOption, MenuList: CustomMenuList }}
useMenuPortal={false}
hideSelectedOptions={false}
onChange={(groups) => setSelectedUserGroups(groups)}
info="Only user groups with access to this application can be selected"
/>
</div>
);
};
const UserSelect = () => {
const appId = useStore((state) => state.app.appId);
const editingPage = useStore((state) => state.editingPage);
const selectedUsers = useStore((state) => state.selectedUsers);
const setSelectedUsers = useStore((state) => state.setSelectedUsers);
const [users, setUsers] = useState([]);
useEffect(() => {
const fetchUsers = () => {
appPermissionService.getUsers(appId, 'users').then((data) => {
if (data?.length) {
const users = [];
data.map((user) => {
const firstName = user.firstName || '';
const lastName = user.lastName || '';
users.push({
value: user.id,
label: `${firstName} ${lastName}`.trim(),
email: user.email,
initials: `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(),
});
});
setUsers(users);
}
});
};
fetchUsers();
}, []);
const CustomOption = (props) => {
const { data, isFocused, isSelected } = props;
return (
<components.Option {...props}>
<div className={`user-select-option ${isFocused ? 'focused' : ''}`}>
<input
style={{ width: '1.2rem', height: '1.2rem', borderRadius: '6px !important' }}
type={'checkbox'}
className="form-check-input"
checked={isSelected}
/>
<div className="avatar">{data.initials}</div>
<div className="user-info">
<div className="name">{data.label}</div>
<div className="email">{data.email}</div>
</div>
</div>
</components.Option>
);
};
const selectStyles = {
option: (base) => ({
...base,
padding: '8px 0px',
}),
};
return (
<div>
<label className="form-label mt-3">Users</label>
<Select
isMulti={true}
options={users}
value={selectedUsers}
width={'100%'}
useMenuPortal={false}
closeMenuOnSelect={false}
components={{ Option: CustomOption, MenuList: CustomMenuList }}
styles={selectStyles}
hideSelectedOptions={false}
info="Only user with access to this application can be selected"
onChange={(users) => {
setSelectedUsers(users);
}}
/>
</div>
);
};
const CustomMenuList = (props) => {
const { info } = props.selectProps;
return (
<components.MenuList {...props}>
<div className="info-container" style={{ marginLeft: '12px', marginRight: '12px', marginTop: '8px' }}>
<div className="col-md-1 info-btn">
<SolidIcon name="informationcircle" fill="#3E63DD" />
</div>
<div className="col-md-11">
<div className="message">
<p style={{ lineHeight: '18px' }}>{info}</p>
</div>
</div>
</div>
{props.children}
</components.MenuList>
);
};

View file

@ -266,4 +266,112 @@
}
}
}
}
.page-permission {
.info-container {
display: flex;
width: auto;
height: auto;
padding: 10px 12px 8px 12px;
border: 1px solid var(--slate5);
background: var(--slate2);
border-radius: 6px 6px 6px 6px;
margin-bottom: 13px;
margin-top: 0px;
}
.permission-type-select {
align-items: center;
.col-auto {
padding-right: 0px;
}
}
}
.page-permissions-modal {
#header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.react-select__option {
padding: 8px 0px;
input {
margin-right: 10px;
}
}
.react-select__menu-list {
overflow-y: unset !important;
}
.user-select-option {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
&.focused {
background-color: #f3f4f6; // Tailwind's gray-100 vibe
}
.avatar {
background-color: var(--slate5); // light gray
color: var(--slate12); // dark text
font-weight: 500;
font-size: 16px;
width: 36px;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
.name {
font-weight: 500;
font-size: 14px;
color: var(--slate12);
}
.email {
font-size: 12px;
color: var(--slate10);
}
}
.group-info {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
.name {
font-weight: 400;
font-size: 14px;
color: var(--slate12);
}
.count {
font-size: 12px;
color: var(--slate9);
}
}
}
}
.page-permission {
.spinner-center {
min-height: 250px;
}
}

View file

@ -15,7 +15,7 @@ const RenderPage = ({ page, currentPageId, switchPageWrapper, labelStyle, comput
console.log({ isHomePage });
const iconName = isHomePage && !page.icon ? 'IconHome2' : page.icon;
const IconElement = Icons?.[iconName] ?? Icons?.['IconFileDescription'];
return page.hidden || page.disabled ? null : (
return (page.hidden || page.disabled) && page?.restricted ? null : (
<FolderList
key={page.handle}
onClick={() => switchPageWrapper(page?.id)}
@ -142,21 +142,26 @@ const RenderPageGroup = ({
export const RenderPageAndPageGroup = ({ pages, labelStyle, computeStyles, darkMode, switchPageWrapper }) => {
// Don't render empty folders if displaying only icons
const tree = buildTree(pages, !!labelStyle?.label?.hidden);
const filteredPages = tree.filter((page) => (!page?.isPageGroup || page.children?.length > 0) && !page?.restricted);
const currentPageId = useStore((state) => state.currentPageId);
const currentPage = pages.find((page) => page.id === currentPageId);
const homePageId = useStore((state) => state.app.homePageId);
return (
<div className={cx('page-handler-wrapper viewer', { 'dark-theme': darkMode })}>
{/* <Accordion alwaysOpen defaultActiveKey={tree.map((page) => page.id)}> */}
{tree.map((page, index) => {
if (page.isPageGroup && page.children.length === 0 && labelStyle?.label?.hidden) {
{filteredPages.map((page, index) => {
if (
page.isPageGroup &&
page.children.length === 0 &&
labelStyle?.label?.hidden &&
!page.children.some((child) => child?.restricted === true)
) {
return null;
}
if (page.children && page.isPageGroup) {
if (page.children && page.isPageGroup && !page.children.some((child) => child?.restricted === true)) {
// if we are only displaying icons, we don't display the groups instead display separator to separate a page groups
const renderSeparatorTop = index !== 0 && labelStyle?.label?.hidden;
const renderSeparatorBottom = !tree[index + 1]?.isPageGroup && labelStyle?.label?.hidden;
const renderSeparatorBottom = !filteredPages[index + 1]?.isPageGroup && labelStyle?.label?.hidden;
return (
<>
{renderSeparatorTop && (
@ -193,7 +198,7 @@ export const RenderPageAndPageGroup = ({ pages, labelStyle, computeStyles, darkM
)}
</>
);
} else {
} else if (!page.isPageGroup) {
return (
<RenderPage
key={page.handle}

View file

@ -149,7 +149,7 @@ export const ViewerSidebarNavigation = ({
const iconName = isHomePage && !page.icon ? 'IconHome2' : page.icon;
// eslint-disable-next-line import/namespace
const IconElement = Icons?.[iconName] ?? Icons?.['IconFileDescription'];
return page.hidden || page.disabled ? null : (
return page.hidden || page.disabled || page?.restricted ? null : (
<FolderList
key={page.handle}
onClick={() => switchPageWrapper(page?.id)}

View file

@ -28,6 +28,8 @@ import { baseTheme, convertAllKeysToSnakeCase } from '../_stores/utils';
import { getPreviewQueryParams } from '@/_helpers/routes';
import { useLocation, useMatch, useParams } from 'react-router-dom';
import useThemeAccess from './useThemeAccess';
import { handleError } from '@/_helpers/handleAppAccess';
import toast from 'react-hot-toast';
/**
* this is to normalize the query transformation options to match the expected schema. Takes care of corrupted data.
@ -214,224 +216,248 @@ const useAppData = (appId, moduleId, darkMode, mode = 'edit', { environmentId, v
}
// const appDataPromise = appService.fetchApp(appId);
appDataPromise.then(async (result) => {
let appData = { ...result };
let editorEnvironment = result.editorEnvironment;
if (isPreviewForVersion) {
const rawDataQueries = appData?.data_queries;
const rawEditingVersionDataQueries = appData?.editing_version?.data_queries;
appData = convertAllKeysToSnakeCase(appData);
appDataPromise
.then(async (result) => {
let appData = { ...result };
let editorEnvironment = result.editorEnvironment;
if (isPreviewForVersion) {
const rawDataQueries = appData?.data_queries;
const rawEditingVersionDataQueries = appData?.editing_version?.data_queries;
appData = convertAllKeysToSnakeCase(appData);
appData.data_queries = rawDataQueries;
if (appData.editing_version && rawEditingVersionDataQueries) {
appData.editing_version.data_queries = rawEditingVersionDataQueries;
}
appData.data_queries = rawDataQueries;
if (appData.editing_version && rawEditingVersionDataQueries) {
appData.editing_version.data_queries = rawEditingVersionDataQueries;
}
editorEnvironment = {
id: environmentId,
name: queryParams.env,
};
}
let constantsResp;
if (mode !== 'edit') {
try {
const queryParams = { slug: slug };
const viewerEnvironment = await appEnvironmentService.getEnvironment(environmentId, queryParams);
editorEnvironment = {
id: viewerEnvironment?.environment?.id,
name: viewerEnvironment?.environment?.name,
id: environmentId,
name: queryParams.env,
};
constantsResp =
isPublicAccess && appData.is_public
? await orgEnvironmentConstantService.getConstantsFromPublicApp(slug, viewerEnvironment?.environment?.id)
: await orgEnvironmentConstantService.getConstantsFromApp(slug, viewerEnvironment?.environment?.id);
} catch (error) {
console.error('Error fetching viewer environment:', error);
}
}
if (mode === 'edit') {
constantsResp = await orgEnvironmentConstantService.getConstantsFromEnvironment(editorEnvironment?.id);
}
// get the constants for specific environment
constantsResp.constants = extractEnvironmentConstantsFromConstantsList(
constantsResp?.constants,
editorEnvironment?.name
);
setIsPublicAccess(isPublicAccess && mode !== 'edit' && appData.is_public);
fetchAndInjectCustomStyles(isPublicAccess && mode !== 'edit' && appData.is_public);
const pages = appData.pages.map((page) => {
return page;
});
const conversation = appData.ai_conversation;
const docsConversation = appData.ai_conversation_learn;
if (setConversation && setDocsConversation) {
setConversation(conversation);
setDocsConversation(docsConversation);
// important to control ai inputs
getCreditBalance();
}
let showWalkthrough = true;
// if app was created from propmt, and no earlier messages are present in the conversation, send the prompt message
// handles the getappdataby slug api call. Gets the homePageId from the appData.
const homePageId =
appData.editing_version?.homePageId || appData.editing_version?.home_page_id || appData.home_page_id;
setApp({
appName: appData.name,
appId: appData.id,
slug: appData.slug,
currentAppEnvironmentId: editorEnvironment.id,
isMaintenanceOn:
'is_maintenance_on' in result
? result.is_maintenance_on
: 'isMaintenanceOn' in result
? result.isMaintenanceOn
: false,
organizationId: appData.organizationId || appData.organization_id,
homePageId: homePageId,
isPublic: appData.is_public,
creationMode: appData.creation_mode,
});
setIsEditorFreezed(appData.should_freeze_editor);
const global_settings = mapKeys(
appData.editing_version?.global_settings || appData.global_settings,
(value, key) => camelCase(key)
);
if (!global_settings?.theme) {
global_settings.theme = baseTheme;
}
setGlobalSettings(global_settings);
setPages(pages, moduleId);
setPageSettings(
computePageSettings(deepCamelCase(appData?.editing_version?.page_settings ?? appData?.page_settings))
);
// set starting page as homepage initially
let startingPage = appData.pages.find((page) => page.id === homePageId);
if (initialLoadRef.current) {
// if initial load, check if the path has a page handle and set that as the starting page
const initialLoadPath = location.pathname.split('/').pop();
const page = appData.pages.find((page) => page.handle === initialLoadPath && !page.isPageGroup);
if (page) {
// if page is disabled, and not editing redirect to home page
if (mode !== 'edit' && page?.disabled) {
const currentUrl = window.location.href;
const replacedUrl = currentUrl.replace(initialLoadPath, startingPage.handle);
window.history.replaceState(null, null, replacedUrl);
} else {
startingPage = page;
let constantsResp;
if (mode !== 'edit') {
try {
const queryParams = { slug: slug };
const viewerEnvironment = await appEnvironmentService.getEnvironment(environmentId, queryParams);
editorEnvironment = {
id: viewerEnvironment?.environment?.id,
name: viewerEnvironment?.environment?.name,
};
constantsResp =
isPublicAccess && appData.is_public
? await orgEnvironmentConstantService.getConstantsFromPublicApp(
slug,
viewerEnvironment?.environment?.id
)
: await orgEnvironmentConstantService.getConstantsFromApp(slug, viewerEnvironment?.environment?.id);
} catch (error) {
console.error('Error fetching viewer environment:', error);
}
}
// navigate(`/${getWorkspaceId()}/apps/${slug ?? appId}/${startingPage.handle}`);
}
// Add page id and handle to the state on initial load
const currentState = window.history.state || {};
const pageInfo = {
id: startingPage.id,
handle: startingPage.handle,
};
const newState = { ...currentState, ...pageInfo };
window.history.replaceState(newState, '', window.location.href);
setCurrentPageHandle(startingPage.handle);
updateFeatureAccess();
setCurrentPageId(startingPage.id, moduleId);
setResolvedPageConstants({
id: startingPage?.id,
handle: startingPage?.handle,
name: startingPage?.name,
});
setComponentNameIdMapping(moduleId);
updateEventsField('events', appData.events);
setCurrentVersionId(appData.editing_version?.id || appData.current_version_id);
setAppHomePageId(homePageId);
const queryData =
isPublicAccess || (mode !== 'edit' && appData.is_public)
? appData
: await dataqueryService.getAll(appData.editing_version?.id || appData.current_version_id);
const dataQueries = queryData.data_queries || queryData?.editing_version?.data_queries;
dataQueries.forEach((query) => normalizeQueryTransformationOptions(query));
setQueries(dataQueries);
if (dataQueries?.length > 0) {
setSelectedQuery(dataQueries[0]?.id);
initialiseResolvedQuery(dataQueries.map((query) => query.id));
}
const constants = constantsResp?.constants;
if (constants) {
const orgConstants = {};
const orgSecrets = {};
constants.map((constant) => {
if (constant.type !== 'Secret') {
orgConstants[constant.name] = constant.value;
} else {
orgSecrets[constant.name] = constant.value;
}
});
setResolvedConstants(orgConstants);
setSecrets(orgSecrets);
}
setQueryMapping(moduleId);
setResolvedGlobals('environment', editorEnvironment);
setResolvedGlobals('mode', { value: mode });
setResolvedGlobals('currentUser', {
...user,
groups: currentSession?.groups,
role: currentSession?.role?.name,
ssoUserInfo: currentSession?.ssoUserInfo,
...(currentSession?.currentUser?.metadata && !isEmpty(currentSession?.currentUser?.metadata)
? { metadata: currentSession?.currentUser?.metadata }
: {}),
});
setResolvedGlobals('urlparams', JSON.parse(JSON.stringify(queryString.parse(location?.search))));
initDependencyGraph(moduleId);
setCurrentMode(mode); // TODO: set mode based on the slug/appDef
if (
state.ai &&
state?.prompt &&
initialLoadRef.current &&
(conversation?.aiConversationMessages || []).length === 0
) {
setSelectedSidebarItem('tooljetai');
toggleLeftSidebar('true');
sendMessage(state.prompt);
setConversationZeroState(true);
showWalkthrough = false;
}
// fetchDataSources(appData.editing_version.id, editorEnvironment.id);
if (!isPublicAccess) {
const envFromQueryParams = mode === 'view' && new URLSearchParams(location?.search)?.get('env');
useStore.getState().init(appData.editing_version?.id || appData.current_version_id, envFromQueryParams);
fetchGlobalDataSources(
appData.organization_id,
appData.editing_version?.id || appData.current_version_id,
editorEnvironment.id
if (mode === 'edit') {
constantsResp = await orgEnvironmentConstantService.getConstantsFromEnvironment(editorEnvironment?.id);
}
// get the constants for specific environment
constantsResp.constants = extractEnvironmentConstantsFromConstantsList(
constantsResp?.constants,
editorEnvironment?.name
);
}
useStore.getState().updateEditingVersion(appData.editing_version?.id || appData.current_version_id); //check if this is needed
updateReleasedVersionId(appData.current_version_id);
setEditorLoading(false);
initialLoadRef.current = false;
// only show if app is not created from prompt
if (showWalkthrough) initEditorWalkThrough();
checkAndSetTrueBuildSuggestionsFlag();
return () => {
document.title = retrieveWhiteLabelText();
};
});
setIsPublicAccess(isPublicAccess && mode !== 'edit' && appData.is_public);
fetchAndInjectCustomStyles(isPublicAccess && mode !== 'edit' && appData.is_public);
const pages = appData.pages.map((page) => {
return page;
});
const conversation = appData.ai_conversation;
const docsConversation = appData.ai_conversation_learn;
if (setConversation && setDocsConversation) {
setConversation(conversation);
setDocsConversation(docsConversation);
// important to control ai inputs
getCreditBalance();
}
let showWalkthrough = true;
// if app was created from propmt, and no earlier messages are present in the conversation, send the prompt message
// handles the getappdataby slug api call. Gets the homePageId from the appData.
const homePageId =
appData.editing_version?.homePageId || appData.editing_version?.home_page_id || appData.home_page_id;
setApp({
appName: appData.name,
appId: appData.id,
slug: appData.slug,
currentAppEnvironmentId: editorEnvironment.id,
isMaintenanceOn:
'is_maintenance_on' in result
? result.is_maintenance_on
: 'isMaintenanceOn' in result
? result.isMaintenanceOn
: false,
organizationId: appData.organizationId || appData.organization_id,
homePageId: homePageId,
isPublic: appData.is_public,
creationMode: appData.creation_mode,
});
setIsEditorFreezed(appData.should_freeze_editor);
const global_settings = mapKeys(
appData.editing_version?.global_settings || appData.global_settings,
(value, key) => camelCase(key)
);
if (!global_settings?.theme) {
global_settings.theme = baseTheme;
}
setGlobalSettings(global_settings);
setPages(pages, moduleId);
setPageSettings(
computePageSettings(deepCamelCase(appData?.editing_version?.page_settings ?? appData?.page_settings))
);
// set starting page as homepage initially
let startingPage = appData.pages.find((page) => page.id === homePageId);
//no access to homepage, set to the next available page
if (startingPage?.restricted) {
startingPage = appData.pages.find((page) => !page?.restricted);
}
if (initialLoadRef.current) {
// if initial load, check if the path has a page handle and set that as the starting page
const initialLoadPath = location.pathname.split('/').pop();
const page = appData.pages.find((page) => page.handle === initialLoadPath && !page.isPageGroup);
if (page) {
// if page is disabled, and not editing redirect to home page
const shouldRedirect = page?.restricted || (mode !== 'edit' && page?.disabled);
if (shouldRedirect) {
const newUrl = window.location.href.replace(initialLoadPath, startingPage.handle);
window.history.replaceState(null, null, newUrl);
if (page?.restricted) {
toast.error('Access to this page is restricted. Contact admin to know more.', {
className: 'text-nowrap w-auto mw-100',
});
}
} else {
startingPage = page;
}
}
// navigate(`/${getWorkspaceId()}/apps/${slug ?? appId}/${startingPage.handle}`);
}
// Add page id and handle to the state on initial load
const currentState = window.history.state || {};
const pageInfo = {
id: startingPage.id,
handle: startingPage.handle,
};
const newState = { ...currentState, ...pageInfo };
window.history.replaceState(newState, '', window.location.href);
setCurrentPageHandle(startingPage.handle);
updateFeatureAccess();
setCurrentPageId(startingPage.id, moduleId);
setResolvedPageConstants({
id: startingPage?.id,
handle: startingPage?.handle,
name: startingPage?.name,
});
setComponentNameIdMapping(moduleId);
updateEventsField('events', appData.events);
setCurrentVersionId(appData.editing_version?.id || appData.current_version_id);
setAppHomePageId(homePageId);
const queryData =
isPublicAccess || (mode !== 'edit' && appData.is_public)
? appData
: await dataqueryService.getAll(appData.editing_version?.id || appData.current_version_id);
const dataQueries = queryData.data_queries || queryData?.editing_version?.data_queries;
dataQueries.forEach((query) => normalizeQueryTransformationOptions(query));
setQueries(dataQueries);
if (dataQueries?.length > 0) {
setSelectedQuery(dataQueries[0]?.id);
initialiseResolvedQuery(dataQueries.map((query) => query.id));
}
const constants = constantsResp?.constants;
if (constants) {
const orgConstants = {};
const orgSecrets = {};
constants.map((constant) => {
if (constant.type !== 'Secret') {
orgConstants[constant.name] = constant.value;
} else {
orgSecrets[constant.name] = constant.value;
}
});
setResolvedConstants(orgConstants);
setSecrets(orgSecrets);
}
setQueryMapping(moduleId);
setResolvedGlobals('environment', editorEnvironment);
setResolvedGlobals('mode', { value: mode });
setResolvedGlobals('currentUser', {
...user,
groups: currentSession?.groups,
role: currentSession?.role?.name,
ssoUserInfo: currentSession?.ssoUserInfo,
...(currentSession?.currentUser?.metadata && !isEmpty(currentSession?.currentUser?.metadata)
? { metadata: currentSession?.currentUser?.metadata }
: {}),
});
setResolvedGlobals('urlparams', JSON.parse(JSON.stringify(queryString.parse(location?.search))));
initDependencyGraph(moduleId);
setCurrentMode(mode); // TODO: set mode based on the slug/appDef
if (
state.ai &&
state?.prompt &&
initialLoadRef.current &&
(conversation?.aiConversationMessages || []).length === 0
) {
setSelectedSidebarItem('tooljetai');
toggleLeftSidebar('true');
sendMessage(state.prompt);
setConversationZeroState(true);
showWalkthrough = false;
}
// fetchDataSources(appData.editing_version.id, editorEnvironment.id);
if (!isPublicAccess) {
const envFromQueryParams = mode === 'view' && new URLSearchParams(location?.search)?.get('env');
useStore.getState().init(appData.editing_version?.id || appData.current_version_id, envFromQueryParams);
fetchGlobalDataSources(
appData.organization_id,
appData.editing_version?.id || appData.current_version_id,
editorEnvironment.id
);
}
useStore.getState().updateEditingVersion(appData.editing_version?.id || appData.current_version_id); //check if this is needed
updateReleasedVersionId(appData.current_version_id);
setEditorLoading(false);
initialLoadRef.current = false;
// only show if app is not created from prompt
if (showWalkthrough) initEditorWalkThrough();
checkAndSetTrueBuildSuggestionsFlag();
return () => {
document.title = retrieveWhiteLabelText();
};
})
.catch((error) => {
if (isPublicAccess) {
if (mode !== 'edit') {
handleError('view', error);
}
}
});
}, [setApp, setEditorLoading, currentSession]);
useEffect(() => {

View file

@ -860,7 +860,9 @@ export const createEventsSlice = (set, get) => ({
const { switchPage } = get();
const page = get().modules.canvas.pages.find((page) => page.id === event.pageId);
const queryParams = event.queryParams || [];
if (!page.disabled) {
if (page.restricted && mode !== 'edit') {
toast.error('Access to this page is restricted. Contact admin to know more.');
} else if (!page.disabled) {
const resolvedQueryParams = [];
queryParams.forEach((param) => {
resolvedQueryParams.push([

View file

@ -41,7 +41,7 @@ export const savePageChanges = async (appId, versionId, pageId, diff, operation
};
const createPageUpdateCommand =
(updatePaths, afterUpdateFn = () => {}) =>
(updatePaths, afterUpdateFn = () => {}, enableSave = true) =>
(pageId, values) => {
return (set, get) => {
set((state) => {
@ -57,7 +57,7 @@ const createPageUpdateCommand =
const { app, currentVersionId } = get();
const diff = _.zipObject(updatePaths, values);
savePageChanges(app.appId, currentVersionId, pageId, diff);
if (enableSave) savePageChanges(app.appId, currentVersionId, pageId, diff);
};
};
@ -82,6 +82,8 @@ export const createPageMenuSlice = (set, get) => {
state.editingPage = null;
});
const updatePageWithPermissions = createPageUpdateCommand(['permissions'], (state) => {}, false);
return {
editingPage: null,
showEditingPopover: false,
@ -96,6 +98,11 @@ export const createPageMenuSlice = (set, get) => {
isPageGroup: false,
pageSettingSelected: false,
pageSettings: {},
showPagePermissionModal: false,
permissionPage: null,
selectedUserGroups: [],
selectedUsers: [],
pagePermission: null,
toggleSearch: (show) =>
set((state) => {
@ -117,7 +124,6 @@ export const createPageMenuSlice = (set, get) => {
closePageEditPopover: () =>
set((state) => {
state.editingPage = null;
state.showEditingPopover = false;
state.showEditPageEventsModal = false;
state.showRenamePageHandleModal = false;
@ -190,6 +196,7 @@ export const createPageMenuSlice = (set, get) => {
updatePageHandle(pageId, [value])(set, get);
},
updatePageGroupName: (pageId, value) => updatePageGroupName(pageId, [value])(set, get),
updatePageWithPermissions: (pageId, value) => updatePageWithPermissions(pageId, [value])(set, get),
// unsure about this one
clonePage: async (pageId) => {
const {
@ -419,5 +426,30 @@ export const createPageMenuSlice = (set, get) => {
console.error('Error updating page:', error);
}
},
setPagePermission: (pagePermission) =>
set((state) => {
state.pagePermission = pagePermission;
}),
togglePagePermissionModal: (show) => {
set((state) => {
state.showPagePermissionModal = show;
});
},
setSelectedUserGroups: (groups) =>
set((state) => {
state.selectedUserGroups = groups;
}),
setSelectedUsers: (users) =>
set((state) => {
state.selectedUsers = users;
}),
setEditingPage: (page) =>
set((state) => {
state.editingPage = page;
}),
};
};

View file

@ -31,6 +31,7 @@ export const ON_BOARDING_ROLES = [
export const ERROR_TYPES = {
URL_UNAVAILABLE: 'url-unavailable',
RESTRICTED: 'restricted',
NO_ACCESSIBLE_PAGES: 'no-accessible-pages',
INVALID: 'invalid-link',
UNKNOWN: 'unknown',
WORKSPACE_ARCHIVED: 'Organization is Archived',
@ -53,6 +54,12 @@ export const ERROR_MESSAGES = {
retry: false,
queryParams: [],
},
'no-accessible-pages': {
title: 'Restricted access',
message: 'You dont have access to any page in this app. Kindly contact admin to know more.',
retry: false,
queryParams: [],
},
'ws-login-restricted': {
title: 'Restricted access',
message:

View file

@ -47,7 +47,7 @@ const switchOrganization = (componentType, orgId, redirectPath) => {
);
};
const handleError = (componentType, error, redirectPath, editPermission, appSlug = null) => {
export const handleError = (componentType, error, redirectPath, editPermission, appSlug = null) => {
try {
if (error?.data) {
const statusCode = error.data?.statusCode;
@ -63,6 +63,10 @@ const handleError = (componentType, error, redirectPath, editPermission, appSlug
switchOrganization(componentType, errorObj?.organizationId, redirectPath);
return;
}
if (errorObj?.type === ERROR_TYPES.NO_ACCESSIBLE_PAGES) {
redirectToErrorPage(ERROR_TYPES.NO_ACCESSIBLE_PAGES);
return;
}
redirectToErrorPage(ERROR_TYPES.RESTRICTED);
return;
}

View file

@ -0,0 +1,49 @@
import config from 'config';
import { authHeader, handleResponse } from '@/_helpers';
export const appPermissionService = {
getPagePermission,
getUsers,
createPagePermission,
updatePagePermission,
deletePagePermission,
};
function getPagePermission(appId, pageId) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/app-permissions/${appId}/pages/${pageId}`, requestOptions).then(handleResponse);
}
function getUsers(appId, type) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/app-permissions/${appId}/pages/${type}`, requestOptions).then(handleResponse);
}
function createPagePermission(appId, pageId, body) {
const requestOptions = {
method: 'POST',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/pages/${pageId}`, requestOptions).then(handleResponse);
}
function updatePagePermission(appId, pageId, body) {
const requestOptions = {
method: 'PUT',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/pages/${pageId}`, requestOptions).then(handleResponse);
}
function deletePagePermission(appId, pageId) {
const requestOptions = {
method: 'DELETE',
headers: authHeader(),
credentials: 'include',
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/pages/${pageId}`, requestOptions).then(handleResponse);
}

View file

@ -33,3 +33,4 @@ export * from './workflow_schedules.service';
export * from './session.service';
export * from './login_configs.service';
export * from './ai.service';
export * from './appPermission.service';

View file

@ -237,6 +237,16 @@ $btn-dark-color: #FFFFFF;
}
}
}
.page-permission-btn {
display: flex;
align-items: baseline;
gap: 5px;
&.disabled {
opacity: 1 !important;
}
}
}
.notification-dot {

View file

@ -17,6 +17,7 @@ export default function ModalBase({
cancelDisabled,
className = '',
size = 'sm',
headerAction,
}) {
return (
<Modal
@ -30,7 +31,8 @@ export default function ModalBase({
<Modal.Title className="font-weight-500" data-cy="modal-title">
{title}
</Modal.Title>
<div onClick={handleClose} className="cursor-pointer" data-cy="modal-close-button">
<div onClick={handleClose} id="header-actions" className="cursor-pointer" data-cy="modal-close-button">
{headerAction && headerAction()}
<SolidIcon name="remove" width="20" />
</div>
</Modal.Header>

@ -1 +1 @@
Subproject commit 8a6b2e586cb92ecc1cd14859f665206324152586
Subproject commit 12599a28b17d84e30b0ea4897a239ed89c011425

View file

@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
import { TOOLJET_EDITIONS } from '@modules/app/constants';
import { getTooljetEdition } from '@helpers/utils.helper';
export class CreatePagePermissions1744610362161 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
if (getTooljetEdition() === TOOLJET_EDITIONS.CE) {
return;
}
await queryRunner.createTable(
new Table({
name: 'page_permissions',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'page_id',
type: 'uuid',
isNullable: false,
},
{
name: 'type',
type: 'enum',
enum: ['SINGLE', 'GROUP'],
},
{
name: 'created_at',
type: 'timestamp',
isNullable: false,
default: 'now()',
},
],
}),
true
);
await queryRunner.createForeignKey(
'page_permissions',
new TableForeignKey({
columnNames: ['page_id'],
referencedColumnNames: ['id'],
referencedTableName: 'pages',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('page_permissions');
}
}

View file

@ -0,0 +1,82 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
import { TOOLJET_EDITIONS } from '@modules/app/constants';
import { getTooljetEdition } from '@helpers/utils.helper';
export class CreatePageUsers1744611380594 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
if (getTooljetEdition() === TOOLJET_EDITIONS.CE) {
return;
}
await queryRunner.createTable(
new Table({
name: 'page_users',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'page_permissions_id',
type: 'uuid',
isNullable: false,
},
{
name: 'user_id',
type: 'uuid',
isNullable: true,
},
{
name: 'permission_groups_id',
type: 'uuid',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
isNullable: false,
default: 'now()',
},
],
}),
true
);
await queryRunner.createForeignKey(
'page_users',
new TableForeignKey({
columnNames: ['page_permissions_id'],
referencedColumnNames: ['id'],
referencedTableName: 'page_permissions',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'page_users',
new TableForeignKey({
columnNames: ['user_id'],
referencedColumnNames: ['id'],
referencedTableName: 'users',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'page_users',
new TableForeignKey({
columnNames: ['permission_groups_id'],
referencedColumnNames: ['id'],
referencedTableName: 'permission_groups',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('page_users');
}
}

View file

@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
@ -13,12 +14,14 @@ import { Organization } from './organization.entity';
import { GroupUsers } from './group_users.entity';
import { GranularPermissions } from './granular_permissions.entity';
import { GROUP_PERMISSIONS_TYPE } from '@modules/group-permissions/constants';
import { PageUser } from './page_users.entity';
@Entity({ name: 'permission_groups' })
export class GroupPermissions extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'organization_id', nullable: false })
organizationId: string;
@ -62,5 +65,8 @@ export class GroupPermissions extends BaseEntity {
@OneToMany(() => GranularPermissions, (granularPermissions) => granularPermissions.group, { onDelete: 'CASCADE' })
groupGranularPermissions: GranularPermissions[];
@OneToMany(() => PageUser, (pageUser) => pageUser.permissionGroup)
pageUsers: PageUser[];
disabled?: boolean;
}

View file

@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@ -16,9 +17,11 @@ export class GroupUsers extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', nullable: false })
userId: string;
@Index()
@Column({ name: 'group_id', nullable: false })
groupId: string;

View file

@ -10,6 +10,7 @@ import {
} from 'typeorm';
import { AppVersion } from './app_version.entity';
import { Component } from './component.entity';
import { PagePermission } from './page_permissions.entity';
@Entity({ name: 'pages' })
export class Page {
@ -61,4 +62,7 @@ export class Page {
@OneToMany(() => Component, (component) => component.page)
components: Component[];
@OneToMany(() => PagePermission, (permission) => permission.page)
permissions: PagePermission[];
}

View file

@ -0,0 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, OneToMany } from 'typeorm';
import { Page } from './page.entity';
import { PageUser } from './page_users.entity';
import { PAGE_PERMISSION_TYPE } from '@modules/app-permissions/constants';
@Entity('page_permissions')
export class PagePermission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'page_id', type: 'uuid', nullable: false })
pageId: string;
@Column({
type: 'enum',
enum: PAGE_PERMISSION_TYPE,
})
type: PAGE_PERMISSION_TYPE;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => Page, (page) => page.permissions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'page_id' })
page: Page;
@OneToMany(() => PageUser, (pageUser) => pageUser.pagePermission)
users: PageUser[];
}

View file

@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, Index } from 'typeorm';
import { User } from './user.entity';
import { PagePermission } from './page_permissions.entity';
import { GroupPermissions } from './group_permissions.entity';
@Entity('page_users')
export class PageUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'page_permissions_id', type: 'uuid' })
pagePermissionsId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Index()
@Column({ name: 'permission_groups_id', type: 'uuid', nullable: true })
permissionGroupsId: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => PagePermission, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'page_permissions_id' })
pagePermission: PagePermission;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => GroupPermissions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'permission_groups_id' })
permissionGroup: GroupPermissions;
}

View file

@ -29,6 +29,7 @@ import { OnboardingStatus } from '@modules/onboarding/constants';
import { AiConversation } from './ai_conversation.entity';
import { AiResponseVote } from './ai_response_vote.entity';
import { USER_ROLE } from '@modules/group-permissions/constants';
import { PageUser } from './page_users.entity';
@Entity({ name: 'users' })
export class User extends BaseEntity {
@ -184,6 +185,9 @@ export class User extends BaseEntity {
@OneToMany(() => AiResponseVote, (aiResponseVote) => aiResponseVote.user, { onDelete: 'CASCADE' })
aiResponseVotes: AiResponseVote[];
@OneToMany(() => PageUser, (pageUser) => pageUser.user)
pageUsers: PageUser[];
organizationId: string;
invitedOrganizationId: string;
organizationIds?: Array<string>;

View file

@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { FeatureAbilityFactory } from '.';
import { AbilityGuard } from '@modules/app/guards/ability.guard';
import { App } from '@entities/app.entity';
import { ResourceDetails } from '@modules/app/types';
import { MODULES } from '@modules/app/constants/modules';
@Injectable()
export class FeatureAbilityGuard extends AbilityGuard {
protected getResource(): ResourceDetails {
return {
resourceType: MODULES.APP_PERMISSIONS,
};
}
protected getAbilityFactory() {
return FeatureAbilityFactory;
}
protected getSubjectType() {
return App;
}
protected forwardAbility(): boolean {
return true;
}
}

View file

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { Ability, AbilityBuilder, InferSubjects } from '@casl/ability';
import { AbilityFactory } from '@modules/app/ability-factory';
import { UserAllPermissions } from '@modules/app/types';
import { FEATURE_KEY } from '../constants';
import { App } from '@entities/app.entity';
import { MODULES } from '@modules/app/constants/modules';
type Subjects = InferSubjects<typeof App> | 'all';
export type FeatureAbility = Ability<[FEATURE_KEY, Subjects]>;
@Injectable()
export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects> {
protected getSubjectType() {
return App;
}
protected defineAbilityFor(
can: AbilityBuilder<FeatureAbility>['can'],
UserAllPermissions: UserAllPermissions,
extractedMetadata: { moduleName: string; features: string[] },
request?: any
): void {
const appId = request?.tj_resource_id;
const { superAdmin, isAdmin, userPermission } = UserAllPermissions;
const userAppPermissions = userPermission?.[MODULES.APP];
const isAllAppsEditable = !!userAppPermissions?.isAllEditable;
const isAllAppsViewable = !!userAppPermissions?.isAllViewable;
if (isAdmin || superAdmin) {
// Admin or super admin and do all operations
can(
[
FEATURE_KEY.FETCH_USERS,
FEATURE_KEY.FETCH_USER_GROUPS,
FEATURE_KEY.FETCH_PAGE_PERMISSIONS,
FEATURE_KEY.CREATE_PAGE_PERMISSIONS,
FEATURE_KEY.UPDATE_PAGE_PERMISSIONS,
FEATURE_KEY.DELETE_PAGE_PERMISSIONS,
],
App
);
return;
}
if (
isAllAppsEditable ||
(userAppPermissions?.editableAppsId?.length && appId && userAppPermissions.editableAppsId.includes(appId))
) {
can(
[
FEATURE_KEY.FETCH_USERS,
FEATURE_KEY.FETCH_USER_GROUPS,
FEATURE_KEY.FETCH_PAGE_PERMISSIONS,
FEATURE_KEY.CREATE_PAGE_PERMISSIONS,
FEATURE_KEY.UPDATE_PAGE_PERMISSIONS,
FEATURE_KEY.DELETE_PAGE_PERMISSIONS,
],
App
);
return;
}
if (
isAllAppsViewable ||
(userAppPermissions?.viewableAppsId?.length && appId && userAppPermissions.viewableAppsId.includes(appId))
) {
can([FEATURE_KEY.FETCH_USERS, FEATURE_KEY.FETCH_USER_GROUPS, FEATURE_KEY.FETCH_PAGE_PERMISSIONS], App);
}
}
}

View file

@ -0,0 +1,14 @@
import { FEATURE_KEY } from './index';
import { MODULES } from '@modules/app/constants/modules';
import { FeaturesConfig } from '../types';
export const FEATURES: FeaturesConfig = {
[MODULES.APP_PERMISSIONS]: {
[FEATURE_KEY.FETCH_USERS]: {},
[FEATURE_KEY.FETCH_USER_GROUPS]: {},
[FEATURE_KEY.FETCH_PAGE_PERMISSIONS]: {},
[FEATURE_KEY.CREATE_PAGE_PERMISSIONS]: {},
[FEATURE_KEY.UPDATE_PAGE_PERMISSIONS]: {},
[FEATURE_KEY.DELETE_PAGE_PERMISSIONS]: {},
},
};

View file

@ -0,0 +1,14 @@
export enum PAGE_PERMISSION_TYPE {
SINGLE = 'SINGLE',
GROUP = 'GROUP',
ALL = 'ALL',
}
export enum FEATURE_KEY {
FETCH_USERS = 'fetch_users',
FETCH_USER_GROUPS = 'fetch_user_groups',
FETCH_PAGE_PERMISSIONS = 'fetch_page_permissions',
CREATE_PAGE_PERMISSIONS = 'create_page_permissions',
UPDATE_PAGE_PERMISSIONS = 'update_page_permissions',
DELETE_PAGE_PERMISSIONS = 'delete_page_permissions',
}

View file

@ -0,0 +1,84 @@
import { Body, Controller, Delete, Get, NotFoundException, Param, Post, Put, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { User } from '@modules/app/decorators/user.decorator';
import { IAppPermissionsController } from './interfaces/IController';
import { FeatureAbilityGuard } from './ability/guard';
import { InitModule } from '@modules/app/decorators/init-module';
import { MODULES } from '@modules/app/constants/modules';
import { InitFeature } from '@modules/app/decorators/init-feature.decorator';
import { FEATURE_KEY } from './constants';
import { JwtAuthGuard } from '@modules/session/guards/jwt-auth.guard';
import { CreatePagePermissionDto } from './dto';
@InitModule(MODULES.APP_PERMISSIONS)
@UseGuards(JwtAuthGuard, FeatureAbilityGuard)
@Controller('app-permissions')
export class AppPermissionsController implements IAppPermissionsController {
constructor() {}
@InitFeature(FEATURE_KEY.FETCH_USERS)
@Get(':appId/pages/users')
async fetchUsers(
@User() user,
@Param('appId') appId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.FETCH_USER_GROUPS)
@Get(':appId/pages/user-groups')
async fetchUserGroups(
@User() user,
@Param('appId') appId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.FETCH_PAGE_PERMISSIONS)
@Get(':appId/pages/:pageId')
async fetchPagePermissions(
@User() user,
@Param('appId') appId: string,
@Param('pageId') pageId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.CREATE_PAGE_PERMISSIONS)
@Post(':appId/pages/:pageId')
async createPagePermissions(
@User() user,
@Param('appId') appId: string,
@Param('pageId') pageId: string,
@Body() body: CreatePagePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.UPDATE_PAGE_PERMISSIONS)
@Put(':appId/pages/:pageId')
async updatePagePermissions(
@User() user,
@Param('appId') appId: string,
@Param('pageId') pageId: string,
@Body() body: CreatePagePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.DELETE_PAGE_PERMISSIONS)
@Delete(':appId/pages/:pageId')
async deletePagePermissions(
@User() user,
@Param('appId') appId: string,
@Param('pageId') pageId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
}

View file

@ -0,0 +1,26 @@
import { IsUUID, IsEnum, IsArray, IsString, IsOptional, ValidateIf } from 'class-validator';
import { Type } from 'class-transformer';
import { PAGE_PERMISSION_TYPE } from '../constants';
export class CreatePagePermissionDto {
@IsUUID(4)
@IsOptional()
pageId: string;
@IsEnum(PAGE_PERMISSION_TYPE)
type: PAGE_PERMISSION_TYPE;
@ValidateIf((o) => o.type === PAGE_PERMISSION_TYPE.SINGLE)
@IsArray()
@IsString({ each: true })
@IsOptional()
@Type(() => String)
users?: string[];
@ValidateIf((o) => o.type === PAGE_PERMISSION_TYPE.GROUP)
@IsArray()
@IsString({ each: true })
@IsOptional()
@Type(() => String)
groups?: string[];
}

View file

@ -0,0 +1,29 @@
import { User } from '@entities/user.entity';
import { Response } from 'express';
import { CreatePagePermissionDto } from '../dto';
export interface IAppPermissionsController {
fetchUsers(user: User, appId: string, response: Response): Promise<any>;
fetchUserGroups(user: User, appId: string, response: Response): Promise<any>;
fetchPagePermissions(user: User, appId: string, pageId: string, response: Response): Promise<any>;
createPagePermissions(
user: User,
appId: string,
pageId: string,
body: CreatePagePermissionDto,
response: Response
): Promise<any>;
updatePagePermissions(
user: User,
appId: string,
pageId: string,
body: CreatePagePermissionDto,
response: Response
): Promise<any>;
deletePagePermissions(user: User, appId: string, pageId: string, response: Response): Promise<any>;
}

View file

@ -0,0 +1,16 @@
import { User } from '@entities/user.entity';
import { CreatePagePermissionDto } from '../dto';
export interface IAppPermissionsService {
fetchUsers(appId: string, user: User): Promise<any>;
fetchUserGroups(appId: string, user: User): Promise<any>;
fetchPagePermissions(pageId: string): Promise<any>;
createPagePermissions(pageId: string, body: CreatePagePermissionDto): Promise<any>;
updatePagePermissions(appId: string, pageId: string, body: CreatePagePermissionDto, user: User): Promise<any>;
deletePagePermissions(pageId: string): Promise<any>;
}

View file

@ -0,0 +1,13 @@
import { User } from '@entities/user.entity';
import { GroupPermissions } from '@entities/group_permissions.entity';
import { CreatePagePermissionDto } from '../dto';
export interface IUtilService {
getUsersWithViewAccess(appId: string, organizationId: string): Promise<User[]>;
getUserGroupsWithViewAccess(appId: string, organizationId: string): Promise<GroupPermissions[]>;
createPagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any>;
updatePagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any>;
}

View file

@ -0,0 +1,35 @@
import { getImportPath } from '@modules/app/constants';
import { DynamicModule } from '@nestjs/common';
import { FeatureAbilityFactory } from './ability';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GroupPermissions } from '@entities/group_permissions.entity';
import { User } from '@entities/user.entity';
import { RolesRepository } from '@modules/roles/repository';
import { PageUsersRepository } from './repositories/page-users.repository';
import { PagePermissionsRepository } from './repositories/page-permissions.repository';
import { PageUser } from '@entities/page_users.entity';
import { PagePermission } from '@entities/page_permissions.entity';
export class AppPermissionsModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
const importPath = await getImportPath(configs.IS_GET_CONTEXT);
const { AppPermissionsController } = await import(`${importPath}/app-permissions/controller`);
const { AppPermissionsService } = await import(`${importPath}/app-permissions/service`);
const { AppPermissionsUtilService } = await import(`${importPath}/app-permissions/util.service`);
return {
module: AppPermissionsModule,
imports: [TypeOrmModule.forFeature([GroupPermissions, User, PageUser, PagePermission])],
controllers: [AppPermissionsController],
providers: [
AppPermissionsService,
AppPermissionsUtilService,
RolesRepository,
PageUsersRepository,
PagePermissionsRepository,
FeatureAbilityFactory,
],
exports: [AppPermissionsUtilService, AppPermissionsService],
};
}
}

View file

@ -0,0 +1,58 @@
import { PagePermission } from '@entities/page_permissions.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { PageUsersRepository } from './page-users.repository';
import { dbTransactionWrap } from '@helpers/database.helper';
import { PAGE_PERMISSION_TYPE } from '../constants';
@Injectable()
export class PagePermissionsRepository extends Repository<PagePermission> {
constructor(private dataSource: DataSource, private readonly pageUsersRepository: PageUsersRepository) {
super(PagePermission, dataSource.createEntityManager());
}
async getPagePermissions(pageId: string, manager?: EntityManager): Promise<PagePermission[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const pagePermissions = await manager.find(PagePermission, {
where: { pageId },
relations: ['users', 'users.user', 'users.permissionGroup'],
});
return pagePermissions.map((permission) => {
if (permission.type === PAGE_PERMISSION_TYPE.GROUP) {
return {
...permission,
groups: permission.users,
users: undefined,
};
}
return permission;
});
}, manager || this.manager);
}
async createPagePermissions(
pageId: string,
type: PAGE_PERMISSION_TYPE,
manager?: EntityManager
): Promise<PagePermission> {
return dbTransactionWrap(async (manager: EntityManager) => {
const existingPermission = await manager.findOne(PagePermission, { where: { pageId } });
if (existingPermission) {
throw new Error(`Page permission already exists for Page id: ${pageId}`);
}
const pagePermission = manager.create(PagePermission, {
pageId,
type,
});
return manager.save(pagePermission);
}, manager || this.manager);
}
async deletePagePermissions(pageId: string, manager?: EntityManager): Promise<void> {
return dbTransactionWrap(async (manager: EntityManager) => {
await manager.delete(PagePermission, { pageId });
}, manager || this.manager);
}
}

View file

@ -0,0 +1,91 @@
import { PageUser } from '@entities/page_users.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { dbTransactionWrap } from '@helpers/database.helper';
import { PagePermission } from '@entities/page_permissions.entity';
@Injectable()
export class PageUsersRepository extends Repository<PageUser> {
constructor(private dataSource: DataSource) {
super(PageUser, dataSource.createEntityManager());
}
async createPageUsersWithSingle(
pagePermissionsId: string,
users: string[],
manager?: EntityManager
): Promise<PageUser[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const pageUsers = users.map((userId) => {
return manager.create(PageUser, {
pagePermissionsId,
userId,
permissionGroupsId: null,
});
});
return manager.save(pageUsers);
}, manager || this.manager);
}
async createPageUsersWithGroup(
pagePermissionsId: string,
groups: string[],
manager?: EntityManager
): Promise<PageUser[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const pageUsers = groups.map((permissionGroupsId) => {
return manager.create(PageUser, {
pagePermissionsId,
permissionGroupsId,
userId: null,
});
});
return manager.save(pageUsers);
}, manager || this.manager);
}
async checkIfUserExistsInPermissionGroup(
pagePermission: PagePermission,
userId: string,
manager?: EntityManager
): Promise<PageUser> {
return dbTransactionWrap(async (manager: EntityManager) => {
const result = await manager
.createQueryBuilder(PageUser, 'page_users')
.innerJoin('page_users.permissionGroup', 'group')
.innerJoin('group.groupUsers', 'groupUser')
.where('page_users.pagePermission = :permissionId', {
permissionId: pagePermission.id,
})
.andWhere('groupUser.userId = :userId', { userId })
.getOne();
if (!result) {
return false;
}
return pagePermission;
}, manager || this.manager);
}
async checkIfUserExistsInSingleConfig(
pagePermission: PagePermission,
userId: string,
manager?: EntityManager
): Promise<PageUser> {
return dbTransactionWrap(async (manager: EntityManager) => {
const pageUser = await manager.findOne(PageUser, {
where: {
pagePermission: { id: pagePermission.id },
userId,
},
});
if (!pageUser) {
return false;
}
return pagePermission;
}, manager || this.manager);
}
}

View file

@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { IAppPermissionsService } from './interfaces/IService';
@Injectable()
export class AppPermissionsService implements IAppPermissionsService {
constructor() {}
async fetchUsers(appId, user) {
throw new Error('Method not implemented.');
}
async fetchUserGroups(appId, user) {
throw new Error('Method not implemented.');
}
async fetchPagePermissions(pageId) {
throw new Error('Method not implemented.');
}
async createPagePermissions(pageId, body) {
throw new Error('Method not implemented.');
}
async updatePagePermissions(appId, pageId, body, user) {
throw new Error('Method not implemented.');
}
async deletePagePermissions(pageId) {
throw new Error('Method not implemented.');
}
}

View file

@ -0,0 +1,16 @@
import { FEATURE_KEY } from '../constants';
import { FeatureConfig } from '@modules/app/types';
import { MODULES } from '@modules/app/constants/modules';
interface Features {
[FEATURE_KEY.FETCH_USERS]: FeatureConfig;
[FEATURE_KEY.FETCH_USER_GROUPS]: FeatureConfig;
[FEATURE_KEY.FETCH_PAGE_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.CREATE_PAGE_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.UPDATE_PAGE_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.DELETE_PAGE_PERMISSIONS]: FeatureConfig;
}
export interface FeaturesConfig {
[MODULES.APP_PERMISSIONS]: Features;
}

View file

@ -0,0 +1,26 @@
import { User } from '@entities/user.entity';
import { IUtilService } from './interfaces/IUtilService';
import { Injectable } from '@nestjs/common';
import { GroupPermissions } from '@entities/group_permissions.entity';
import { CreatePagePermissionDto } from './dto';
@Injectable()
export class AppPermissionsUtilService implements IUtilService {
constructor() {}
async getUserGroupsWithViewAccess(appId: string, organizationId: string): Promise<GroupPermissions[]> {
throw new Error('Method not implemented.');
}
async getUsersWithViewAccess(appId: string, organizationId: string): Promise<User[]> {
throw new Error('Method not implemented.');
}
async createPagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
async updatePagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
}

View file

@ -34,6 +34,7 @@ import { FEATURES as AI_FEATURES } from '@modules/ai/constants/feature';
import { getTooljetEdition } from '@helpers/utils.helper';
import { TOOLJET_EDITIONS } from '.';
import { FEATURES as WHITE_LABELLING_FEATURES } from '@modules/white-labelling/constant/feature';
import { FEATURES as APP_PERMISSIONS_FEATURES } from '@modules/app-permissions/constants/features';
const GROUP_PERMISSIONS_FEATURES =
getTooljetEdition() === TOOLJET_EDITIONS.EE ? GROUP_PERMISSIONS_FEATURES_EE : GROUP_PERMISSIONS_FEATURES_CE;
@ -73,4 +74,5 @@ export const MODULE_INFO: { [key: string]: any } = {
...ORGANIZATION_CONSTANT,
...AI_FEATURES,
...WHITE_LABELLING_FEATURES,
...APP_PERMISSIONS_FEATURES,
};

View file

@ -36,4 +36,5 @@ export enum MODULES {
IMPORT_EXPORT_RESOURCES = 'ImportExportResources',
TEMPLATES = 'Templates',
AI = 'ai',
APP_PERMISSIONS = 'AppPermissions',
}

View file

@ -41,6 +41,7 @@ import { TooljetDbModule } from '@modules/tooljet-db/module';
import { WorkflowsModule } from '@modules/workflows/module';
import { AiModule } from '@modules/ai/module';
import { CustomStylesModule } from '@modules/custom-styles/module';
import { AppPermissionsModule } from '@modules/app-permissions/module';
export class AppModule implements OnModuleInit {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -94,6 +95,7 @@ export class AppModule implements OnModuleInit {
await WorkflowsModule.register(configs),
await AiModule.register(configs),
await CustomStylesModule.register(configs),
await AppPermissionsModule.register(configs),
];
return {

View file

@ -19,6 +19,8 @@ import { FeatureAbilityFactory } from './ability';
import { DataSourcesModule } from '@modules/data-sources/module';
import { AppsSubscriber } from './subscribers/apps.subscriber';
import { AiModule } from '@modules/ai/module';
import { AppPermissionsModule } from '@modules/app-permissions/module';
import { RolesRepository } from '@modules/roles/repository';
@Module({})
export class AppsModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -36,7 +38,15 @@ export class AppsModule {
return {
module: AppsModule,
imports: [
TypeOrmModule.forFeature([App, Page, EventHandler, Organization, Component, VersionRepository]),
TypeOrmModule.forFeature([
App,
Page,
EventHandler,
Organization,
Component,
VersionRepository,
RolesRepository,
]),
await FolderAppsModule.register(configs),
await ThemesModule.register(configs),
await FoldersModule.register(configs),
@ -44,6 +54,7 @@ export class AppsModule {
await AppEnvironmentsModule.register(configs),
await DataSourcesModule.register(configs),
await AiModule.register(configs),
await AppPermissionsModule.register(configs),
],
controllers: [AppsController],
providers: [
@ -61,6 +72,7 @@ export class AppsModule {
AppsSubscriber,
DataSourcesRepository,
AppImportExportService,
RolesRepository,
],
exports: [AppsUtilService],
};

View file

@ -18,7 +18,7 @@ import {
VersionReleaseDto,
} from './dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { APP_TYPES, FEATURE_KEY } from './constants';
import { FEATURE_KEY } from './constants';
import { camelizeKeys, decamelizeKeys } from 'humps';
import { App } from '@entities/app.entity';
import { AppsUtilService } from './util.service';

View file

@ -6,7 +6,7 @@ import { DataSource } from '@entities/data_source.entity';
import { DataSourceOptions } from '@entities/data_source_options.entity';
import { EventHandler, Target } from '@entities/event_handler.entity';
import { dbTransactionWrap } from '@helpers/database.helper';
import { EntityManager } from 'typeorm';
import { EntityManager, In } from 'typeorm';
import { Credential } from 'src/entities/credential.entity';
import * as uuid from 'uuid';
import { Page } from '@entities/page.entity';
@ -22,6 +22,8 @@ import { DataSourcesRepository } from '@modules/data-sources/repository';
import { DataQueryRepository } from '@modules/data-queries/repository';
import { AppEnvironmentUtilService } from '@modules/app-environments/util.service';
import { IVersionsCreateService } from '../interfaces/services/ICreateService';
import { PagePermission } from '@entities/page_permissions.entity';
import { PageUser } from '@entities/page_users.entity';
@Injectable()
export class VersionsCreateService implements IVersionsCreateService {
@ -401,6 +403,44 @@ export class VersionsCreateService implements IVersionsCreateService {
homePageId = savedPage.id;
}
const oldPermissions = await manager.find(PagePermission, {
where: { pageId: page.id },
});
const newPermissions = oldPermissions.map((permission) => {
return manager.create(PagePermission, {
...permission,
id: undefined,
pageId: oldPageToNewPageMapping[permission.pageId],
});
});
await manager.save(PagePermission, newPermissions);
const permissionIdMap = new Map<string, string>();
oldPermissions.forEach((oldPerm, index) => {
const newPerm = newPermissions[index];
permissionIdMap.set(oldPerm.id, newPerm.id);
});
const oldPermissionIds = oldPermissions.map((p) => p.id);
const oldPageUsers = await manager.find(PageUser, {
where: {
pagePermissionsId: In(oldPermissionIds),
},
});
const newPageUsers = oldPageUsers.map((pu) =>
manager.create(PageUser, {
...pu,
id: undefined,
pagePermissionsId: permissionIdMap.get(pu.pagePermissionsId),
})
);
await manager.save(PageUser, newPageUsers);
const pageEvents = allEvents.filter((event) => event.sourceId === page.id);
pageEvents.forEach(async (event, index) => {

View file

@ -29,6 +29,8 @@ import { WorkflowSchedule } from '@entities/workflow_schedule.entity';
import { App } from '@entities/app.entity';
import { AiModule } from '@modules/ai/module';
import { DataSourcesRepository } from '@modules/data-sources/repository';
import { AppPermissionsModule } from '@modules/app-permissions/module';
import { RolesRepository } from '@modules/roles/repository';
export class WorkflowsModule {
static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
const importPath = await getImportPath(configs?.IS_GET_CONTEXT);
@ -69,6 +71,7 @@ export class WorkflowsModule {
WorkflowExecutionNode,
WorkflowExecutionNode,
WorkflowExecutionEdge,
RolesRepository,
]),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
@ -91,6 +94,7 @@ export class WorkflowsModule {
await FolderAppsModule.register(configs),
await ThemesModule.register(configs),
await AiModule.register(configs),
await AppPermissionsModule.register(configs),
],
providers: [
AppsAbilityFactory,
@ -113,6 +117,7 @@ export class WorkflowsModule {
WorkflowSchedulesService,
TemporalService,
FeatureAbilityFactory,
RolesRepository,
],
controllers: [
WorkflowsController,