Merge pull request #12658 from ToolJet/feat/page-permissoins-fe

Feature: Page permissions
This commit is contained in:
vjaris42 2025-04-22 09:33:50 +05:30 committed by GitHub
commit fa16fddccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 850 additions and 215 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

View file

@ -20,6 +20,8 @@ export const PageHandlerMenu = ({ darkMode }) => {
const toggleDeleteConfirmationModal = useStore((state) => state.toggleDeleteConfirmationModal); const toggleDeleteConfirmationModal = useStore((state) => state.toggleDeleteConfirmationModal);
const clonePage = useStore((state) => state.clonePage); const clonePage = useStore((state) => state.clonePage);
const markAsHomePage = useStore((state) => state.markAsHomePage); const markAsHomePage = useStore((state) => state.markAsHomePage);
const togglePagePermissionModal = useStore((state) => state.togglePagePermissionModal);
// const popoverTargetRef = null; // const popoverTargetRef = null;
// console.log( // console.log(
// { // {
@ -164,6 +166,16 @@ export const PageHandlerMenu = ({ darkMode }) => {
}} }}
disabled={isHomePage} disabled={isHomePage}
/> />
<Field
id={isDisabled ? 'enable-page' : 'disable-page'}
text={isDisabled ? 'Page permission' : 'Page permission'}
customClass={'delete-btn'}
iconSrc={`assets/images/icons/editor/left-sidebar/authorization.svg`}
closeMenu={closeMenu}
callback={(id) => {
togglePagePermissionModal(true);
}}
/>
<Field <Field
id="delete-page" id="delete-page"
text="Delete page" text="Delete page"

View file

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

View file

@ -0,0 +1,377 @@
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';
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 [pagePermissionType, setPagePermissionType] = useState('all');
const [showUserGroupSelect, toggleUserGroupSelect] = useState(false);
const [showUsersSelect, toggleUsersSelect] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [isLoading, setIsLoading] = useState(false);
console.log({ editingPage, showUserGroupSelect });
useEffect(() => {
if (!editingPage?.id && !showPagePermissionModal) return;
const fetchPagePermission = () => {
appPermissionService.getPagePermission(appId, editingPage?.id).then((data) => {
if (data) {
if (data[0]) {
setPagePermissionType(data[0]?.type?.toLowerCase());
setPagePermission(data);
toggleUserGroupSelect(true);
data?.length &&
setSelectedUserGroups(
data[0]?.users?.map((user) => ({
label: user?.permissionGroup?.name,
value: user?.permissionGroup?.id,
}))
);
}
}
});
};
fetchPagePermission();
}, [appId, editingPage, setPagePermission, setSelectedUserGroups, showPagePermissionModal]);
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',
},
],
[]
);
console.log({ pagePermission });
const handlePermissionTypeChange = (value) => {
console.log({ 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);
};
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) => {
console.log({ data });
})
.catch(() => {
toast.error('Permission could not be created. Please try again!');
})
.finally(() => {
setIsLoading(false);
handlePagePermissionModalClose();
toast.success('Permission successfully created!');
});
};
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) => {
console.log({ data });
})
.catch(() => {
toast.error('Permission could not be updated. Please try again!');
})
.finally(() => {
setIsLoading(false);
handlePagePermissionModalClose();
toast.success('Permission successfully updated!');
});
};
const deletePagePermission = () => {
setIsLoading(true);
appPermissionService
.deletePagePermission(appId, editingPage?.id)
.then((data) => {
console.log({ data });
})
.catch(() => {
toast.error('Permission could not be deleted. Please try again!');
})
.finally(() => {
setIsLoading(false);
setShowConfirmDelete(false);
handlePagePermissionModalClose();
toast.success('Permission successfully deleted!');
});
};
const renderPermissionTypeOptions = ({ label, icon }) => {
return (
<div className="row permission-type-select">
<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: pagePermissionType == 'all' ? true : false,
tooltipMessage: '',
}}
darkMode={darkMode}
className="page-permissions-modal"
headerAction={() =>
pagePermission && (
<span
onClick={(e) => {
togglePagePermissionModal(false);
setShowConfirmDelete(true);
}}
>
<SolidIcon fill="var(--tomato10)" width="20" name="trash" />
</span>
)
}
>
<div className="page-permission">
<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 = () => {
console.log('rendering');
const appId = useStore((state) => state.app.appId);
const editingPage = useStore((state) => state.editingPage);
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) => {
console.log({ data });
if (data?.length) {
const groups = [];
data.map((group) => {
groups.push({ value: group.id, label: group.name });
});
setUserGroups(groups);
}
});
};
fetchUserGroups();
}, []);
console.log({ selectedUserGroups, userGroups });
return (
<div>
<label className="form-label mt-3">User groups</label>
<Select
isMulti={true}
options={userGroups}
value={selectedUserGroups}
width={'100%'}
// customOption={renderPermissionTypeOptions}
useMenuPortal={false}
// menuIsOpen={true}
onChange={(groups) => setSelectedUserGroups(groups)}
/>
</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) => {
console.log({ 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' : ''}`}>
<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>
);
};
console.log({ users });
return (
<div>
<label className="form-label mt-3">Users</label>
<Select
isMulti={true}
options={users}
value={selectedUsers}
width={'100%'}
// customOption={renderUserSelectOptions}
useMenuPortal={false}
components={{ Option: CustomOption }}
// menuIsOpen={true}
onChange={(users) => {
console.log({ userstemp: users });
setSelectedUsers(users);
}}
/>
</div>
);
};

View file

@ -267,3 +267,75 @@
} }
} }
} }
.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;
}
.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: 600;
font-size: 14px;
color: var(--slate12);
}
.email {
font-size: 12px;
color: var(--slate10); // gray-500
}
}
}
}

View file

@ -142,6 +142,7 @@ const RenderPageGroup = ({
export const RenderPageAndPageGroup = ({ pages, labelStyle, computeStyles, darkMode, switchPageWrapper }) => { export const RenderPageAndPageGroup = ({ pages, labelStyle, computeStyles, darkMode, switchPageWrapper }) => {
// Don't render empty folders if displaying only icons // Don't render empty folders if displaying only icons
const tree = buildTree(pages, !!labelStyle?.label?.hidden); const tree = buildTree(pages, !!labelStyle?.label?.hidden);
const filteredPages = tree.filter((page) => !page?.isPageGroup || page.children?.length > 0);
const currentPageId = useStore((state) => state.currentPageId); const currentPageId = useStore((state) => state.currentPageId);
const currentPage = pages.find((page) => page.id === currentPageId); const currentPage = pages.find((page) => page.id === currentPageId);
@ -149,14 +150,14 @@ export const RenderPageAndPageGroup = ({ pages, labelStyle, computeStyles, darkM
return ( return (
<div className={cx('page-handler-wrapper viewer', { 'dark-theme': darkMode })}> <div className={cx('page-handler-wrapper viewer', { 'dark-theme': darkMode })}>
{/* <Accordion alwaysOpen defaultActiveKey={tree.map((page) => page.id)}> */} {/* <Accordion alwaysOpen defaultActiveKey={tree.map((page) => page.id)}> */}
{tree.map((page, index) => { {filteredPages.map((page, index) => {
if (page.isPageGroup && page.children.length === 0 && labelStyle?.label?.hidden) { if (page.isPageGroup && page.children.length === 0 && labelStyle?.label?.hidden) {
return null; return null;
} }
if (page.children && page.isPageGroup) { if (page.children && page.isPageGroup) {
// if we are only displaying icons, we don't display the groups instead display separator to separate a page groups // 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 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 ( return (
<> <>
{renderSeparatorTop && ( {renderSeparatorTop && (

View file

@ -28,6 +28,8 @@ import { baseTheme, convertAllKeysToSnakeCase } from '../_stores/utils';
import { getPreviewQueryParams } from '@/_helpers/routes'; import { getPreviewQueryParams } from '@/_helpers/routes';
import { useLocation, useMatch, useParams } from 'react-router-dom'; import { useLocation, useMatch, useParams } from 'react-router-dom';
import useThemeAccess from './useThemeAccess'; 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. * 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); // const appDataPromise = appService.fetchApp(appId);
appDataPromise.then(async (result) => { appDataPromise
let appData = { ...result }; .then(async (result) => {
let editorEnvironment = result.editorEnvironment; let appData = { ...result };
if (isPreviewForVersion) { let editorEnvironment = result.editorEnvironment;
const rawDataQueries = appData?.data_queries; if (isPreviewForVersion) {
const rawEditingVersionDataQueries = appData?.editing_version?.data_queries; const rawDataQueries = appData?.data_queries;
appData = convertAllKeysToSnakeCase(appData); const rawEditingVersionDataQueries = appData?.editing_version?.data_queries;
appData = convertAllKeysToSnakeCase(appData);
appData.data_queries = rawDataQueries; appData.data_queries = rawDataQueries;
if (appData.editing_version && rawEditingVersionDataQueries) { if (appData.editing_version && rawEditingVersionDataQueries) {
appData.editing_version.data_queries = 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 = { editorEnvironment = {
id: viewerEnvironment?.environment?.id, id: environmentId,
name: viewerEnvironment?.environment?.name, 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') { let constantsResp;
constantsResp = await orgEnvironmentConstantService.getConstantsFromEnvironment(editorEnvironment?.id); if (mode !== 'edit') {
} try {
// get the constants for specific environment const queryParams = { slug: slug };
constantsResp.constants = extractEnvironmentConstantsFromConstantsList( const viewerEnvironment = await appEnvironmentService.getEnvironment(environmentId, queryParams);
constantsResp?.constants, editorEnvironment = {
editorEnvironment?.name id: viewerEnvironment?.environment?.id,
); name: viewerEnvironment?.environment?.name,
};
setIsPublicAccess(isPublicAccess && mode !== 'edit' && appData.is_public); constantsResp =
isPublicAccess && appData.is_public
fetchAndInjectCustomStyles(isPublicAccess && mode !== 'edit' && appData.is_public); ? await orgEnvironmentConstantService.getConstantsFromPublicApp(
slug,
const pages = appData.pages.map((page) => { viewerEnvironment?.environment?.id
return page; )
}); : await orgEnvironmentConstantService.getConstantsFromApp(slug, viewerEnvironment?.environment?.id);
const conversation = appData.ai_conversation; } catch (error) {
const docsConversation = appData.ai_conversation_learn; console.error('Error fetching viewer environment:', error);
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;
} }
} }
// navigate(`/${getWorkspaceId()}/apps/${slug ?? appId}/${startingPage.handle}`); if (mode === 'edit') {
} constantsResp = await orgEnvironmentConstantService.getConstantsFromEnvironment(editorEnvironment?.id);
}
// Add page id and handle to the state on initial load // get the constants for specific environment
const currentState = window.history.state || {}; constantsResp.constants = extractEnvironmentConstantsFromConstantsList(
const pageInfo = { constantsResp?.constants,
id: startingPage.id, editorEnvironment?.name
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); setIsPublicAccess(isPublicAccess && mode !== 'edit' && appData.is_public);
initialLoadRef.current = false;
// only show if app is not created from prompt fetchAndInjectCustomStyles(isPublicAccess && mode !== 'edit' && appData.is_public);
if (showWalkthrough) initEditorWalkThrough();
checkAndSetTrueBuildSuggestionsFlag(); const pages = appData.pages.map((page) => {
return () => { return page;
document.title = retrieveWhiteLabelText(); });
}; 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 (!homePageId) {
startingPage = appData.pages[0];
}
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('/')[3];
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;
}
} else {
if (mode !== 'edit' && initialLoadPath) {
const currentUrl = window.location.href;
const replacedUrl = currentUrl.replace(initialLoadPath, startingPage.handle);
window.history.replaceState(null, null, replacedUrl);
toast.error('Access to this page is restricted. Contact admin to know more.');
}
}
// 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]); }, [setApp, setEditorLoading, currentSession]);
useEffect(() => { useEffect(() => {

View file

@ -1118,6 +1118,10 @@ export const createEventsSlice = (set, get) => ({
toast('Valid page handle is required', { toast('Valid page handle is required', {
icon: '⚠️', icon: '⚠️',
}); });
mode === 'view' &&
toast.error('Access to this page is restricted. Contact admin to know more.', {
icon: '⚠️',
});
return Promise.resolve(); return Promise.resolve();
} }

View file

@ -96,6 +96,11 @@ export const createPageMenuSlice = (set, get) => {
isPageGroup: false, isPageGroup: false,
pageSettingSelected: false, pageSettingSelected: false,
pageSettings: {}, pageSettings: {},
showPagePermissionModal: false,
permissionPage: null,
selectedUserGroups: [],
selectedUsers: [],
pagePermission: null,
toggleSearch: (show) => toggleSearch: (show) =>
set((state) => { set((state) => {
@ -117,7 +122,6 @@ export const createPageMenuSlice = (set, get) => {
closePageEditPopover: () => closePageEditPopover: () =>
set((state) => { set((state) => {
state.editingPage = null;
state.showEditingPopover = false; state.showEditingPopover = false;
state.showEditPageEventsModal = false; state.showEditPageEventsModal = false;
state.showRenamePageHandleModal = false; state.showRenamePageHandleModal = false;
@ -419,5 +423,26 @@ export const createPageMenuSlice = (set, get) => {
console.error('Error updating page:', error); 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;
}),
}; };
}; };

View file

@ -31,6 +31,7 @@ export const ON_BOARDING_ROLES = [
export const ERROR_TYPES = { export const ERROR_TYPES = {
URL_UNAVAILABLE: 'url-unavailable', URL_UNAVAILABLE: 'url-unavailable',
RESTRICTED: 'restricted', RESTRICTED: 'restricted',
NO_ACCESSIBLE_PAGES: 'no-accessible-pages',
INVALID: 'invalid-link', INVALID: 'invalid-link',
UNKNOWN: 'unknown', UNKNOWN: 'unknown',
WORKSPACE_ARCHIVED: 'Organization is Archived', WORKSPACE_ARCHIVED: 'Organization is Archived',
@ -53,6 +54,12 @@ export const ERROR_MESSAGES = {
retry: false, retry: false,
queryParams: [], 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': { 'ws-login-restricted': {
title: 'Restricted access', title: 'Restricted access',
message: 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 { try {
if (error?.data) { if (error?.data) {
const statusCode = error.data?.statusCode; const statusCode = error.data?.statusCode;
@ -63,6 +63,10 @@ const handleError = (componentType, error, redirectPath, editPermission, appSlug
switchOrganization(componentType, errorObj?.organizationId, redirectPath); switchOrganization(componentType, errorObj?.organizationId, redirectPath);
return; return;
} }
if (errorObj?.type === ERROR_TYPES.NO_ACCESSIBLE_PAGES) {
redirectToErrorPage(ERROR_TYPES.NO_ACCESSIBLE_PAGES);
return;
}
redirectToErrorPage(ERROR_TYPES.RESTRICTED); redirectToErrorPage(ERROR_TYPES.RESTRICTED);
return; 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 './session.service';
export * from './login_configs.service'; export * from './login_configs.service';
export * from './ai.service'; export * from './ai.service';
export * from './appPermission.service';

View file

@ -17,6 +17,7 @@ export default function ModalBase({
cancelDisabled, cancelDisabled,
className = '', className = '',
size = 'sm', size = 'sm',
headerAction,
}) { }) {
return ( return (
<Modal <Modal
@ -30,7 +31,8 @@ export default function ModalBase({
<Modal.Title className="font-weight-500" data-cy="modal-title"> <Modal.Title className="font-weight-500" data-cy="modal-title">
{title} {title}
</Modal.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" /> <SolidIcon name="remove" width="20" />
</div> </div>
</Modal.Header> </Modal.Header>

@ -1 +1 @@
Subproject commit 683647f83d3efeeadbe69c40b8e8dd5ba4e8ea06 Subproject commit 0f5a0bab924ee8d2b8f9a8a6071e6a8e2cb4e215

View file

@ -29,7 +29,7 @@ export class AppPermissionsModule {
PagePermissionsRepository, PagePermissionsRepository,
FeatureAbilityFactory, FeatureAbilityFactory,
], ],
exports: [AppPermissionsUtilService], exports: [AppPermissionsUtilService, AppPermissionsService],
}; };
} }
} }

View file

@ -15,7 +15,7 @@ export class PagePermissionsRepository extends Repository<PagePermission> {
return dbTransactionWrap(async (manager: EntityManager) => { return dbTransactionWrap(async (manager: EntityManager) => {
return manager.find(PagePermission, { return manager.find(PagePermission, {
where: { pageId }, where: { pageId },
relations: ['users', 'users.user'], relations: ['users', 'users.user', 'users.permissionGroup'],
}); });
}, manager || this.manager); }, manager || this.manager);
} }

View file

@ -2,6 +2,7 @@ import { PageUser } from '@entities/page_users.entity';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { dbTransactionWrap } from '@helpers/database.helper'; import { dbTransactionWrap } from '@helpers/database.helper';
import { PagePermission } from '@entities/page_permissions.entity';
@Injectable() @Injectable()
export class PageUsersRepository extends Repository<PageUser> { export class PageUsersRepository extends Repository<PageUser> {
@ -42,4 +43,49 @@ export class PageUsersRepository extends Repository<PageUser> {
return manager.save(pageUsers); return manager.save(pageUsers);
}, manager || this.manager); }, 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

@ -19,6 +19,7 @@ import { FeatureAbilityFactory } from './ability';
import { DataSourcesModule } from '@modules/data-sources/module'; import { DataSourcesModule } from '@modules/data-sources/module';
import { AppsSubscriber } from './subscribers/apps.subscriber'; import { AppsSubscriber } from './subscribers/apps.subscriber';
import { AiModule } from '@modules/ai/module'; import { AiModule } from '@modules/ai/module';
import { AppPermissionsModule } from '@modules/app-permissions/module';
@Module({}) @Module({})
export class AppsModule { export class AppsModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> { static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -44,6 +45,7 @@ export class AppsModule {
await AppEnvironmentsModule.register(configs), await AppEnvironmentsModule.register(configs),
await DataSourcesModule.register(configs), await DataSourcesModule.register(configs),
await AiModule.register(configs), await AiModule.register(configs),
await AppPermissionsModule.register(configs),
], ],
controllers: [AppsController], controllers: [AppsController],
providers: [ providers: [

View file

@ -29,6 +29,7 @@ import { WorkflowSchedule } from '@entities/workflow_schedule.entity';
import { App } from '@entities/app.entity'; import { App } from '@entities/app.entity';
import { AiModule } from '@modules/ai/module'; import { AiModule } from '@modules/ai/module';
import { DataSourcesRepository } from '@modules/data-sources/repository'; import { DataSourcesRepository } from '@modules/data-sources/repository';
import { AppPermissionsModule } from '@modules/app-permissions/module';
export class WorkflowsModule { export class WorkflowsModule {
static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> { static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
const importPath = await getImportPath(configs?.IS_GET_CONTEXT); const importPath = await getImportPath(configs?.IS_GET_CONTEXT);
@ -91,6 +92,7 @@ export class WorkflowsModule {
await FolderAppsModule.register(configs), await FolderAppsModule.register(configs),
await ThemesModule.register(configs), await ThemesModule.register(configs),
await AiModule.register(configs), await AiModule.register(configs),
await AppPermissionsModule.register(configs),
], ],
providers: [ providers: [
AppsAbilityFactory, AppsAbilityFactory,