diff --git a/frontend/assets/images/icons/editor/left-sidebar/authorization.svg b/frontend/assets/images/icons/editor/left-sidebar/authorization.svg new file mode 100644 index 0000000000..609f7a5910 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/authorization.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/ee b/frontend/ee index a1435b3b0e..dcd948d284 160000 --- a/frontend/ee +++ b/frontend/ee @@ -1 +1 @@ -Subproject commit a1435b3b0e66a0c731812256d3d495d5bf48d5bb +Subproject commit dcd948d284b5f14a868480830e09b90496db8572 diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageHandlerMenu.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageHandlerMenu.jsx index e2a395ecab..1c5c3124f0 100644 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageHandlerMenu.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageHandlerMenu.jsx @@ -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 && ( { disabled={isHomePage} /> )} - { clonePage(editingPage.id); }} /> - { }} disabled={isHomePage} /> + + { + return ( + + + Page permission + {!licenseValid && } + + + ); + }} + customClass={'delete-btn'} + iconSrc={`assets/images/icons/editor/left-sidebar/authorization.svg`} + closeMenu={closeMenu} + callback={(id) => { + togglePagePermissionModal(true); + }} + /> { ); }; -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 ( - + diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenu.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenu.jsx index 0669f4928b..d4b8573c7d 100644 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenu.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenu.jsx @@ -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 }) => { > + {isLicensed ? : <>>} diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx index ca193084fb..0f3b5d21a7 100644 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx @@ -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'} - {!shouldFreeze && ( - + + {licenseValid && restricted && } + + + {!shouldFreeze && ( - - )} + )} + > )} diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PagePermission.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PagePermission.jsx new file mode 100644 index 0000000000..6a4a1c516a --- /dev/null +++ b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PagePermission.jsx @@ -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 ( + + + + + + {label} + + + ); + }; + + return ( + <> + + Page permission + + } + 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 && ( + { + setPageToDelete(editingPage?.id); + togglePagePermissionModal(false); + setShowConfirmDelete(true); + }} + > + + + ) + } + > + + {isPermissionsLoading ? ( + + + + ) : ( + <> + + + + + + + + Only selected users will be allowed to access this page. Read docs to know more. + + + + + Type + + {showUserGroupSelect && } + {showUsersSelect && } + > + )} + + + {showConfirmDelete && ( + 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 ( + + + + + {data.label} + {data.count} users + + + + ); + }; + + return ( + + User groups + setSelectedUserGroups(groups)} + info="Only user groups with access to this application can be selected" + /> + + ); +}; + +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 ( + + + + {data.initials} + + {data.label} + {data.email} + + + + ); + }; + + const selectStyles = { + option: (base) => ({ + ...base, + padding: '8px 0px', + }), + }; + return ( + + Users + { + setSelectedUsers(users); + }} + /> + + ); +}; + +const CustomMenuList = (props) => { + const { info } = props.selectProps; + return ( + + + + + + + + {info} + + + + {props.children} + + ); +}; diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss b/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss index 7b39fdc938..968218b106 100644 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss +++ b/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss @@ -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; + } } \ No newline at end of file diff --git a/frontend/src/AppBuilder/Viewer/PageGroup.jsx b/frontend/src/AppBuilder/Viewer/PageGroup.jsx index 94a55d85a3..0311115b09 100644 --- a/frontend/src/AppBuilder/Viewer/PageGroup.jsx +++ b/frontend/src/AppBuilder/Viewer/PageGroup.jsx @@ -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 : ( 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 ( {/* 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 ( switchPageWrapper(page?.id)} diff --git a/frontend/src/AppBuilder/_hooks/useAppData.js b/frontend/src/AppBuilder/_hooks/useAppData.js index 176951921c..f131b4eb59 100644 --- a/frontend/src/AppBuilder/_hooks/useAppData.js +++ b/frontend/src/AppBuilder/_hooks/useAppData.js @@ -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(() => { diff --git a/frontend/src/AppBuilder/_stores/slices/eventsSlice.js b/frontend/src/AppBuilder/_stores/slices/eventsSlice.js index 3d2d461518..f93f64b1c5 100644 --- a/frontend/src/AppBuilder/_stores/slices/eventsSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/eventsSlice.js @@ -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([ diff --git a/frontend/src/AppBuilder/_stores/slices/pageMenuSlice.js b/frontend/src/AppBuilder/_stores/slices/pageMenuSlice.js index 9403142033..45a5b86428 100644 --- a/frontend/src/AppBuilder/_stores/slices/pageMenuSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/pageMenuSlice.js @@ -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; + }), }; }; diff --git a/frontend/src/_helpers/constants.js b/frontend/src/_helpers/constants.js index 79275fc6df..6493505229 100644 --- a/frontend/src/_helpers/constants.js +++ b/frontend/src/_helpers/constants.js @@ -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 don’t have access to any page in this app. Kindly contact admin to know more.', + retry: false, + queryParams: [], + }, 'ws-login-restricted': { title: 'Restricted access', message: diff --git a/frontend/src/_helpers/handleAppAccess.js b/frontend/src/_helpers/handleAppAccess.js index ccf69ad753..d93cbdc96e 100644 --- a/frontend/src/_helpers/handleAppAccess.js +++ b/frontend/src/_helpers/handleAppAccess.js @@ -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; } diff --git a/frontend/src/_services/appPermission.service.js b/frontend/src/_services/appPermission.service.js new file mode 100644 index 0000000000..85cb6ee004 --- /dev/null +++ b/frontend/src/_services/appPermission.service.js @@ -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); +} diff --git a/frontend/src/_services/index.js b/frontend/src/_services/index.js index 8565fa1551..9185bee4cd 100644 --- a/frontend/src/_services/index.js +++ b/frontend/src/_services/index.js @@ -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'; diff --git a/frontend/src/_styles/components.scss b/frontend/src/_styles/components.scss index e84756dca7..074338602e 100644 --- a/frontend/src/_styles/components.scss +++ b/frontend/src/_styles/components.scss @@ -237,6 +237,16 @@ $btn-dark-color: #FFFFFF; } } } + + .page-permission-btn { + display: flex; + align-items: baseline; + gap: 5px; + + &.disabled { + opacity: 1 !important; + } + } } .notification-dot { diff --git a/frontend/src/_ui/Modal/index.jsx b/frontend/src/_ui/Modal/index.jsx index 08dbb9e39e..b9d48f0d88 100644 --- a/frontend/src/_ui/Modal/index.jsx +++ b/frontend/src/_ui/Modal/index.jsx @@ -17,6 +17,7 @@ export default function ModalBase({ cancelDisabled, className = '', size = 'sm', + headerAction, }) { return ( {title} - + + {headerAction && headerAction()} diff --git a/server/ee b/server/ee index 8a6b2e586c..12599a28b1 160000 --- a/server/ee +++ b/server/ee @@ -1 +1 @@ -Subproject commit 8a6b2e586cb92ecc1cd14859f665206324152586 +Subproject commit 12599a28b17d84e30b0ea4897a239ed89c011425 diff --git a/server/migrations/1744610362161-CreatePagePermissions.ts b/server/migrations/1744610362161-CreatePagePermissions.ts new file mode 100644 index 0000000000..ca4afbac66 --- /dev/null +++ b/server/migrations/1744610362161-CreatePagePermissions.ts @@ -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 { + 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 { + await queryRunner.dropTable('page_permissions'); + } +} diff --git a/server/migrations/1744611380594-CreatePageUsers.ts b/server/migrations/1744611380594-CreatePageUsers.ts new file mode 100644 index 0000000000..5fe4d126c7 --- /dev/null +++ b/server/migrations/1744611380594-CreatePageUsers.ts @@ -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 { + 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 { + await queryRunner.dropTable('page_users'); + } +} diff --git a/server/src/entities/group_permissions.entity.ts b/server/src/entities/group_permissions.entity.ts index 089b7ff7d9..92868d7510 100644 --- a/server/src/entities/group_permissions.entity.ts +++ b/server/src/entities/group_permissions.entity.ts @@ -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; } diff --git a/server/src/entities/group_users.entity.ts b/server/src/entities/group_users.entity.ts index 03ac55386b..29771a5557 100644 --- a/server/src/entities/group_users.entity.ts +++ b/server/src/entities/group_users.entity.ts @@ -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; diff --git a/server/src/entities/page.entity.ts b/server/src/entities/page.entity.ts index ca4e06333e..4b3dc5466e 100644 --- a/server/src/entities/page.entity.ts +++ b/server/src/entities/page.entity.ts @@ -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[]; } diff --git a/server/src/entities/page_permissions.entity.ts b/server/src/entities/page_permissions.entity.ts new file mode 100644 index 0000000000..7d265b696b --- /dev/null +++ b/server/src/entities/page_permissions.entity.ts @@ -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[]; +} diff --git a/server/src/entities/page_users.entity.ts b/server/src/entities/page_users.entity.ts new file mode 100644 index 0000000000..ca3ef77c65 --- /dev/null +++ b/server/src/entities/page_users.entity.ts @@ -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; +} diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 5bdecb2b43..e052e11245 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -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; diff --git a/server/src/modules/app-permissions/ability/guard.ts b/server/src/modules/app-permissions/ability/guard.ts new file mode 100644 index 0000000000..1011d7985b --- /dev/null +++ b/server/src/modules/app-permissions/ability/guard.ts @@ -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; + } +} diff --git a/server/src/modules/app-permissions/ability/index.ts b/server/src/modules/app-permissions/ability/index.ts new file mode 100644 index 0000000000..d2e8c263b2 --- /dev/null +++ b/server/src/modules/app-permissions/ability/index.ts @@ -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 | 'all'; +export type FeatureAbility = Ability<[FEATURE_KEY, Subjects]>; + +@Injectable() +export class FeatureAbilityFactory extends AbilityFactory { + protected getSubjectType() { + return App; + } + + protected defineAbilityFor( + can: AbilityBuilder['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); + } + } +} diff --git a/server/src/modules/app-permissions/constants/features.ts b/server/src/modules/app-permissions/constants/features.ts new file mode 100644 index 0000000000..6d77625ec5 --- /dev/null +++ b/server/src/modules/app-permissions/constants/features.ts @@ -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]: {}, + }, +}; diff --git a/server/src/modules/app-permissions/constants/index.ts b/server/src/modules/app-permissions/constants/index.ts new file mode 100644 index 0000000000..c1d2afe78b --- /dev/null +++ b/server/src/modules/app-permissions/constants/index.ts @@ -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', +} diff --git a/server/src/modules/app-permissions/controller.ts b/server/src/modules/app-permissions/controller.ts new file mode 100644 index 0000000000..2d0ccea9ce --- /dev/null +++ b/server/src/modules/app-permissions/controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + throw new NotFoundException(); + } +} diff --git a/server/src/modules/app-permissions/dto/index.ts b/server/src/modules/app-permissions/dto/index.ts new file mode 100644 index 0000000000..20a1bd98b8 --- /dev/null +++ b/server/src/modules/app-permissions/dto/index.ts @@ -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[]; +} diff --git a/server/src/modules/app-permissions/interfaces/IController.ts b/server/src/modules/app-permissions/interfaces/IController.ts new file mode 100644 index 0000000000..bfa35aa730 --- /dev/null +++ b/server/src/modules/app-permissions/interfaces/IController.ts @@ -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; + + fetchUserGroups(user: User, appId: string, response: Response): Promise; + + fetchPagePermissions(user: User, appId: string, pageId: string, response: Response): Promise; + + createPagePermissions( + user: User, + appId: string, + pageId: string, + body: CreatePagePermissionDto, + response: Response + ): Promise; + + updatePagePermissions( + user: User, + appId: string, + pageId: string, + body: CreatePagePermissionDto, + response: Response + ): Promise; + + deletePagePermissions(user: User, appId: string, pageId: string, response: Response): Promise; +} diff --git a/server/src/modules/app-permissions/interfaces/IService.ts b/server/src/modules/app-permissions/interfaces/IService.ts new file mode 100644 index 0000000000..cad5fef726 --- /dev/null +++ b/server/src/modules/app-permissions/interfaces/IService.ts @@ -0,0 +1,16 @@ +import { User } from '@entities/user.entity'; +import { CreatePagePermissionDto } from '../dto'; + +export interface IAppPermissionsService { + fetchUsers(appId: string, user: User): Promise; + + fetchUserGroups(appId: string, user: User): Promise; + + fetchPagePermissions(pageId: string): Promise; + + createPagePermissions(pageId: string, body: CreatePagePermissionDto): Promise; + + updatePagePermissions(appId: string, pageId: string, body: CreatePagePermissionDto, user: User): Promise; + + deletePagePermissions(pageId: string): Promise; +} diff --git a/server/src/modules/app-permissions/interfaces/IUtilService.ts b/server/src/modules/app-permissions/interfaces/IUtilService.ts new file mode 100644 index 0000000000..06654ed9e9 --- /dev/null +++ b/server/src/modules/app-permissions/interfaces/IUtilService.ts @@ -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; + + getUserGroupsWithViewAccess(appId: string, organizationId: string): Promise; + + createPagePermission(pageId: string, body: CreatePagePermissionDto): Promise; + + updatePagePermission(pageId: string, body: CreatePagePermissionDto): Promise; +} diff --git a/server/src/modules/app-permissions/module.ts b/server/src/modules/app-permissions/module.ts new file mode 100644 index 0000000000..704c6c1374 --- /dev/null +++ b/server/src/modules/app-permissions/module.ts @@ -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 { + 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], + }; + } +} diff --git a/server/src/modules/app-permissions/repositories/page-permissions.repository.ts b/server/src/modules/app-permissions/repositories/page-permissions.repository.ts new file mode 100644 index 0000000000..7caa5f4318 --- /dev/null +++ b/server/src/modules/app-permissions/repositories/page-permissions.repository.ts @@ -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 { + constructor(private dataSource: DataSource, private readonly pageUsersRepository: PageUsersRepository) { + super(PagePermission, dataSource.createEntityManager()); + } + + async getPagePermissions(pageId: string, manager?: EntityManager): Promise { + 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 { + 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 { + return dbTransactionWrap(async (manager: EntityManager) => { + await manager.delete(PagePermission, { pageId }); + }, manager || this.manager); + } +} diff --git a/server/src/modules/app-permissions/repositories/page-users.repository.ts b/server/src/modules/app-permissions/repositories/page-users.repository.ts new file mode 100644 index 0000000000..e038bd7ead --- /dev/null +++ b/server/src/modules/app-permissions/repositories/page-users.repository.ts @@ -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 { + constructor(private dataSource: DataSource) { + super(PageUser, dataSource.createEntityManager()); + } + + async createPageUsersWithSingle( + pagePermissionsId: string, + users: string[], + manager?: EntityManager + ): Promise { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/server/src/modules/app-permissions/service.ts b/server/src/modules/app-permissions/service.ts new file mode 100644 index 0000000000..c6b5bc640d --- /dev/null +++ b/server/src/modules/app-permissions/service.ts @@ -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.'); + } +} diff --git a/server/src/modules/app-permissions/types/index.ts b/server/src/modules/app-permissions/types/index.ts new file mode 100644 index 0000000000..86a41afba1 --- /dev/null +++ b/server/src/modules/app-permissions/types/index.ts @@ -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; +} diff --git a/server/src/modules/app-permissions/util.service.ts b/server/src/modules/app-permissions/util.service.ts new file mode 100644 index 0000000000..71432a0e4b --- /dev/null +++ b/server/src/modules/app-permissions/util.service.ts @@ -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 { + throw new Error('Method not implemented.'); + } + + async getUsersWithViewAccess(appId: string, organizationId: string): Promise { + throw new Error('Method not implemented.'); + } + + async createPagePermission(pageId: string, body: CreatePagePermissionDto): Promise { + throw new Error('Method not implemented.'); + } + + async updatePagePermission(pageId: string, body: CreatePagePermissionDto): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/server/src/modules/app/constants/module-info.ts b/server/src/modules/app/constants/module-info.ts index 27dceb7b3c..9131a8ba7a 100644 --- a/server/src/modules/app/constants/module-info.ts +++ b/server/src/modules/app/constants/module-info.ts @@ -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, }; diff --git a/server/src/modules/app/constants/modules.ts b/server/src/modules/app/constants/modules.ts index d3a04367ab..62af8b6ab0 100644 --- a/server/src/modules/app/constants/modules.ts +++ b/server/src/modules/app/constants/modules.ts @@ -36,4 +36,5 @@ export enum MODULES { IMPORT_EXPORT_RESOURCES = 'ImportExportResources', TEMPLATES = 'Templates', AI = 'ai', + APP_PERMISSIONS = 'AppPermissions', } diff --git a/server/src/modules/app/module.ts b/server/src/modules/app/module.ts index c0ca97e4be..00cf27f6a2 100644 --- a/server/src/modules/app/module.ts +++ b/server/src/modules/app/module.ts @@ -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 { @@ -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 { diff --git a/server/src/modules/apps/module.ts b/server/src/modules/apps/module.ts index 93f7223e54..15b5903fb2 100644 --- a/server/src/modules/apps/module.ts +++ b/server/src/modules/apps/module.ts @@ -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 { @@ -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], }; diff --git a/server/src/modules/apps/service.ts b/server/src/modules/apps/service.ts index 60c6f8bc15..81708667f9 100644 --- a/server/src/modules/apps/service.ts +++ b/server/src/modules/apps/service.ts @@ -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'; diff --git a/server/src/modules/versions/services/create.service.ts b/server/src/modules/versions/services/create.service.ts index 12a24ac7c6..94b7f9783c 100644 --- a/server/src/modules/versions/services/create.service.ts +++ b/server/src/modules/versions/services/create.service.ts @@ -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(); + 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) => { diff --git a/server/src/modules/workflows/module.ts b/server/src/modules/workflows/module.ts index e71c52df50..77dfbd0af3 100644 --- a/server/src/modules/workflows/module.ts +++ b/server/src/modules/workflows/module.ts @@ -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 { 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,
+ Only selected users will be allowed to access this page. Read docs to know more. +
{info}