-
- {isDisabled && (
-
+
)}
);
diff --git a/frontend/src/AppBuilder/Widgets/Form/FormUtils.js b/frontend/src/AppBuilder/Widgets/Form/FormUtils.js
index 2dea50bdf9..228ca6f36e 100644
--- a/frontend/src/AppBuilder/Widgets/Form/FormUtils.js
+++ b/frontend/src/AppBuilder/Widgets/Form/FormUtils.js
@@ -533,3 +533,22 @@ const validBooleanChecker = (input) => {
if (/^(true|false)$/i.test(input) == true) return JSON.parse(input);
return true;
};
+
+export const getBodyHeight = (height, showHeader, showFooter, headerHeight = 60, footerHeight = 60) => {
+ let modalHeight = height ? parseInt(height, 10) : 0;
+ let parsedHeaderHeight = showHeader ? parseInt(headerHeight, 10) : 0;
+ let parsedFooterHeight = showFooter ? parseInt(footerHeight, 10) : 0;
+
+ if (showHeader) {
+ // 10 is header padding
+ modalHeight = modalHeight - parsedHeaderHeight - 10;
+ }
+ if (showFooter) {
+ // 14 is footer padding
+ modalHeight = modalHeight - parsedFooterHeight - 14;
+ }
+
+ const rounded = Math.ceil(modalHeight / 10) * 10;
+
+ return `${Math.max(rounded - 20, 40)}px`;
+};
diff --git a/frontend/src/AppBuilder/Widgets/Form/form.scss b/frontend/src/AppBuilder/Widgets/Form/form.scss
index dd1c35ba6d..1e1776bef5 100644
--- a/frontend/src/AppBuilder/Widgets/Form/form.scss
+++ b/frontend/src/AppBuilder/Widgets/Form/form.scss
@@ -1,11 +1,17 @@
+.jet-form-widget {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
.wj-form-header {
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
- left: -7px;
- right: -7px;
+ left: -2px;
+ right: -2px;
height: 1px;
background-color: var(--border-weak);
}
@@ -17,8 +23,8 @@
content: "";
position: absolute;
top: 0;
- left: -7px;
- right: -7px;
+ left: -2px;
+ right: -2px;
height: 1px;
background-color: var(--border-weak);
}
@@ -39,6 +45,67 @@
padding: 4px 0;
}
+.resizable-slot {
+ position: relative;
+ height: auto;
+ box-shadow: 0 0 0 1px transparent; /* Acts as a border */
+ transition: box-shadow 0.15s ease-in-out;
+ max-height: 100%;
+
+ &.is-editing:hover {
+ box-shadow: 0 0 0 1px var(--border-weak);
+ }
+
+ &.is-editing.active {
+ box-shadow: 0 0 0 1px var(--border-accent-weak);
+ }
+
+ &.is-editing.dragging {
+ box-shadow: 0 0 0 1px var(--border-accent-strong);
+ }
+
+ .resize-handle {
+ position: absolute;
+ bottom: -4px;
+ left: 50%; /* Center horizontally */
+ transform: translateX(-50%); /* Ensure proper centering */
+ width: 24px;
+ height: 8px;
+ border-radius: 4px;
+ background-color: initial;
+ border: 1px solid var(--background-accent-strong);
+ cursor: ns-resize;
+ z-index: 1;
+ visibility: hidden;
+ transition: visibility 0.15s ease-in-out;
+ }
+
+ &.active .resize-handle {
+ visibility: visible;
+ }
+}
+
+.only-bottom {
+}
+
+.jet-form-header {
+ min-height: 10px;
+}
+
+.jet-form-body {
+ min-height: 100px;
+ background-color: inherit;
+}
+
+.jet-form-footer {
+ min-height: 10px;
+}
+
+.jet-form-footer .resize-handle {
+ top: -4px;
+ bottom: unset;
+}
+
.jet-container.jet-container-json-form {
padding: 0px;
diff --git a/frontend/src/AppBuilder/_hooks/useActiveSlot.js b/frontend/src/AppBuilder/_hooks/useActiveSlot.js
new file mode 100644
index 0000000000..14f80a366f
--- /dev/null
+++ b/frontend/src/AppBuilder/_hooks/useActiveSlot.js
@@ -0,0 +1,84 @@
+import { useState, useEffect } from 'react';
+import useStore from '@/AppBuilder/_stores/store';
+import { shallow } from 'zustand/shallow';
+
+const useIsWidgetSelected = (id) => {
+ // Get selected components from store using shallow comparison
+ const selectedComponents = useStore((state) => state.selectedComponents, shallow);
+
+ // Check if the only selected component is the provided `id`
+ return selectedComponents.length === 1 && selectedComponents[0] === id;
+};
+
+export const useActiveSlot = (widgetId) => {
+ const [activeSlot, setActiveSlot] = useState(''); // Default to widget ID
+ const isSelected = useIsWidgetSelected(widgetId); // Check if widget is selected
+
+ useEffect(() => {
+ if (!isSelected) {
+ setActiveSlot('');
+ }
+ }, [isSelected]);
+
+ useEffect(() => {
+ const handleDoubleClick = (event) => {
+ let target = event.target;
+
+ if (!widgetId) {
+ setActiveSlot(null);
+ return;
+ }
+
+ // Traverse up to find a slot with an id
+ while (target && target !== document.body) {
+ if (target.id && target.id.startsWith('canvas-')) {
+ const slotId = target.id.replace(/^canvas-/, ''); // ✅ Strip "canvas-"
+ setActiveSlot(slotId);
+ return;
+ }
+ target = target.parentElement;
+ }
+
+ // If no slot is found, reset to widget ID
+ setActiveSlot(widgetId);
+ };
+ const handleSingleClick = (event) => {
+ let target = event.target;
+
+ if (!widgetId) {
+ setActiveSlot(null);
+ return;
+ }
+
+ // Traverse up to find a valid main slot (not header/footer)
+ while (target && target !== document.body) {
+ if (
+ target.id &&
+ target.id.startsWith('canvas-') &&
+ !target.id.endsWith('-header') &&
+ !target.id.endsWith('-footer')
+ ) {
+ const slotId = target.id.replace(/^canvas-/, ''); // Strip "canvas-"
+ setActiveSlot(slotId);
+ return;
+ }
+ target = target.parentElement;
+ }
+
+ // If no main slot is found, fallback to widget ID
+ setActiveSlot(widgetId);
+ };
+
+ // Attach single click if the widget is selected, otherwise listen for double-click
+
+ document.addEventListener('dblclick', handleDoubleClick);
+ document.addEventListener('click', handleSingleClick);
+
+ return () => {
+ document.removeEventListener('dblclick', handleDoubleClick);
+ document.removeEventListener('click', handleSingleClick);
+ };
+ }, [widgetId]); // Re-run when widgetId or selection state changes
+
+ return activeSlot;
+};
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/_hooks/useMoveable.js b/frontend/src/AppBuilder/_hooks/useMoveable.js
new file mode 100644
index 0000000000..ea2687e473
--- /dev/null
+++ b/frontend/src/AppBuilder/_hooks/useMoveable.js
@@ -0,0 +1,135 @@
+import { useRef, useState } from 'react';
+
+const defaultProps = {
+ minHeight: 50,
+ maxHeight: 600,
+ minWidth: 50,
+ maxWidth: 600,
+ lockHorizontal: false,
+ lockVertical: false,
+ stepHeight: 10, // Default step size for height
+ stepWidth: 10, // Default step size for width
+ onResize: null,
+ onDragStart: null,
+ onDragEnd: null,
+ isReverseVerticalDrag: false,
+};
+
+export const useResizable = (options = {}) => {
+ const props = { ...defaultProps, ...options };
+ const parentRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false); // ✅ Track dragging state
+
+ const [height, setHeight] = useState(
+ typeof props.initialHeight === 'string' ? props.initialHeight : `${props.initialHeight || 200}px`
+ );
+ const [width, setWidth] = useState(
+ typeof props.initialWidth === 'string' ? props.initialWidth : `${props.initialWidth || 200}px`
+ );
+
+ const getRootProps = () => ({
+ ref: parentRef,
+ style: { height, width },
+ });
+
+ const getResizeState = () => ({
+ height,
+ width,
+ isDragging,
+ });
+
+ const getHandleProps = () => {
+ const handleMouseDown = (e) => {
+ // Prevent right-click drag activation (button === 2)
+ if (e.button === 2) return;
+
+ if (!parentRef.current) return;
+ e.stopPropagation();
+ e.preventDefault();
+ const startHeight = parseInt(parentRef.current.clientHeight);
+ const startWidth = parseInt(parentRef.current.clientWidth);
+ const parentWidth = parentRef.current.parentElement ? parentRef.current.parentElement.clientWidth : startWidth;
+ const startY = e.clientY;
+ const startX = e.clientX;
+ const isPercentage = typeof props.initialWidth === 'string' && props.initialWidth.includes('%');
+
+ setIsDragging(true); // ✅ Set dragging state to true
+
+ if (props.onDragStart) {
+ props.onDragStart({ newHeight: startHeight, newWidth: startWidth });
+ }
+
+ const handleMouseMove = (moveEvent) => {
+ moveEvent.stopPropagation();
+ moveEvent.preventDefault();
+ let newHeight = startHeight;
+ let newWidth = startWidth;
+
+ if (!props.lockVertical) {
+ const deltaY = props.isReverseVerticalDrag ? startY - moveEvent.clientY : moveEvent.clientY - startY;
+ newHeight = startHeight + deltaY;
+ newHeight = Math.max(props.minHeight, Math.min(props.maxHeight, newHeight));
+ newHeight = Math.round(newHeight / props.stepHeight) * props.stepHeight; // Snap to stepHeight
+ }
+
+ if (!props.lockHorizontal) {
+ newWidth = startWidth + (moveEvent.clientX - startX);
+ newWidth = Math.max(props.minWidth, Math.min(props.maxWidth, newWidth));
+ newWidth = Math.round(newWidth / props.stepWidth) * props.stepWidth; // Snap to stepWidth
+
+ if (isPercentage) {
+ newWidth = (newWidth / parentWidth) * 100; // Convert to percentage
+ newWidth = `${newWidth.toFixed(2)}%`;
+ } else {
+ newWidth = `${newWidth}px`;
+ }
+ }
+
+ setHeight(`${newHeight}px`);
+ setWidth(newWidth);
+
+ if (parentRef.current) {
+ parentRef.current.style.height = `${newHeight}px`;
+ parentRef.current.style.width = newWidth;
+ }
+
+ if (props.onResize) {
+ props.onResize({
+ newHeight,
+ newWidth,
+ heightDiff: newHeight - startHeight,
+ widthDiff: isPercentage
+ ? parseInt(newWidth) - (startWidth / parentWidth) * 100
+ : parseInt(newWidth) - startWidth,
+ });
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false); // ✅ Set dragging state to false
+
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+
+ if (props.onDragEnd) {
+ // Get the updated height and width from the DOM instead of relying on state
+ const finalHeight = parentRef.current ? parseInt(parentRef.current.clientHeight) : parseInt(height);
+ const finalWidth = parentRef.current ? parseInt(parentRef.current.clientWidth) : parseInt(width);
+
+ props.onDragEnd({ newHeight: finalHeight, newWidth: finalWidth });
+ }
+ };
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ };
+
+ return {
+ onMouseDown: handleMouseDown,
+ };
+ };
+
+ return { rootRef: parentRef, getRootProps, getHandleProps, getResizeState };
+};
+
+export default useResizable;
diff --git a/frontend/src/AppBuilder/_stores/slices/componentsSlice.js b/frontend/src/AppBuilder/_stores/slices/componentsSlice.js
index b746f1237b..b500a4d912 100644
--- a/frontend/src/AppBuilder/_stores/slices/componentsSlice.js
+++ b/frontend/src/AppBuilder/_stores/slices/componentsSlice.js
@@ -1896,4 +1896,28 @@ export const createComponentsSlice = (set, get) => ({
state.modalsOpenOnCanvas = newModalOpenOnCanvas;
});
},
+ updateContainerAutoHeight: (componentId) => {
+ if (
+ !componentId ||
+ componentId === 'canvas' ||
+ componentId.includes('-header') ||
+ componentId.includes('-footer')
+ ) {
+ return;
+ }
+ const { currentLayout, getCurrentPageComponents, setComponentProperty } = get();
+ const allComponents = getCurrentPageComponents();
+
+ const childComponents = getAllChildComponents(allComponents, componentId);
+ const maxHeight = Object.values(childComponents).reduce((max, component) => {
+ const layout = component?.layouts?.[currentLayout];
+ if (!layout) {
+ return max;
+ }
+ const sum = layout.top + layout.height;
+ return Math.max(max, sum);
+ }, 0);
+
+ setComponentProperty(componentId, `canvasHeight`, maxHeight, 'properties', 'value', false);
+ },
});
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/Editor/Components/MultiselectV2/CustomValueContainer.jsx b/frontend/src/Editor/Components/MultiselectV2/CustomValueContainer.jsx
index 9eb11ea4c6..62db4a2a0d 100644
--- a/frontend/src/Editor/Components/MultiselectV2/CustomValueContainer.jsx
+++ b/frontend/src/Editor/Components/MultiselectV2/CustomValueContainer.jsx
@@ -36,7 +36,7 @@ const CustomValueContainer = ({ children, ...props }) => {
) : (
- {isAllOptionsSelected ? 'All items are selected.' : values.join(', ')}
+ {selectProps?.showAllSelectedLabel && isAllOptionsSelected ? 'All items are selected.' : values.join(', ')}
)}
{/* Rendering children except Placeholder component to preserve the default behavior of react-select like focus
diff --git a/frontend/src/Editor/Components/MultiselectV2/MultiselectV2.jsx b/frontend/src/Editor/Components/MultiselectV2/MultiselectV2.jsx
index c3fd384421..db44bc15f8 100644
--- a/frontend/src/Editor/Components/MultiselectV2/MultiselectV2.jsx
+++ b/frontend/src/Editor/Components/MultiselectV2/MultiselectV2.jsx
@@ -37,6 +37,7 @@ export const MultiselectV2 = ({
loadingState: multiSelectLoadingState,
optionsLoadingState,
sort,
+ showAllSelectedLabel,
showClearBtn,
showSearchInput,
} = properties;
@@ -544,6 +545,7 @@ export const MultiselectV2 = ({
icon={icon}
doShowIcon={iconVisibility}
containerRef={valueContainerRef}
+ showAllSelectedLabel={showAllSelectedLabel}
iconColor={iconColor}
optionsLoadingState={optionsLoadingState && advanced}
darkMode={darkMode}
diff --git a/frontend/src/Editor/WidgetManager/configs/container.js b/frontend/src/Editor/WidgetManager/configs/container.js
index 424b9a801d..6dc9a679a4 100644
--- a/frontend/src/Editor/WidgetManager/configs/container.js
+++ b/frontend/src/Editor/WidgetManager/configs/container.js
@@ -3,7 +3,7 @@ export const containerConfig = {
displayName: 'Container',
description: 'Group components',
defaultSize: {
- width: 5,
+ width: 10,
height: 200,
},
component: 'Container',
@@ -44,13 +44,19 @@ export const containerConfig = {
displayName: 'Show header',
validation: {
schema: { type: 'boolean' },
- defaultValue: false,
+ defaultValue: true,
},
},
+ headerHeight: {
+ type: 'numberInput',
+ displayName: 'Header height',
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
},
defaultChildren: [
{
componentName: 'Text',
+ slotName: 'header',
layout: {
top: 20,
left: 1,
@@ -98,15 +104,6 @@ export const containerConfig = {
},
accordian: 'container',
},
- headerHeight: {
- type: 'numberInput',
- displayName: 'Height',
- validation: {
- schema: { type: 'number' },
- defaultValue: 80,
- },
- accordian: 'header',
- },
borderRadius: {
type: 'numberInput',
displayName: 'Border',
@@ -154,10 +151,11 @@ export const containerConfig = {
showOnMobile: { value: '{{false}}' },
},
properties: {
- showHeader: { value: `{{false}}` },
+ showHeader: { value: `{{true}}` },
loadingState: { value: `{{false}}` },
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
+ headerHeight: { value: `{{80}}` },
},
events: [],
styles: {
diff --git a/frontend/src/Editor/WidgetManager/configs/form.js b/frontend/src/Editor/WidgetManager/configs/form.js
index 2d8eb7f0a8..129322cf73 100644
--- a/frontend/src/Editor/WidgetManager/configs/form.js
+++ b/frontend/src/Editor/WidgetManager/configs/form.js
@@ -4,7 +4,7 @@ export const formConfig = {
description: 'Wrapper for multiple components',
defaultSize: {
width: 13,
- height: 480,
+ height: 450,
},
defaultChildren: [
{
@@ -19,8 +19,8 @@ export const formConfig = {
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {
- text: 'Form title',
- textSize: 20,
+ text: 'Form',
+ textSize: 16,
textColor: '#000',
},
},
@@ -34,203 +34,68 @@ export const formConfig = {
},
properties: ['text'],
defaultValue: {
- text: 'Button2',
+ text: 'Submit',
padding: 'none',
},
},
- {
- componentName: 'Text',
- layout: {
- top: 40,
- left: 10,
- height: 30,
- width: 17,
- },
- properties: ['text'],
- styles: [
- 'textSize',
- 'fontWeight',
- 'fontStyle',
- 'textColor',
- 'isScrollRequired',
- 'lineHeight',
- 'textIndent',
- 'textAlign',
- 'verticalAlignment',
- 'decoration',
- 'transformation',
- 'letterSpacing',
- 'wordSpacing',
- 'fontVariant',
- 'backgroundColor',
- 'borderColor',
- 'borderRadius',
- 'boxShadow',
- 'padding',
- ],
- defaultValue: {
- text: 'User Details',
- fontWeight: 'bold',
- textSize: 18,
- textColor: '#000',
- backgroundColor: '#fff00000',
- textAlign: 'left',
- decoration: 'none',
- transformation: 'none',
- fontStyle: 'normal',
- lineHeight: 1.5,
- textIndent: '0',
- letterSpacing: '0',
- wordSpacing: '0',
- fontVariant: 'normal',
- verticalAlignment: 'top',
- padding: 'default',
- boxShadow: '0px 0px 0px 0px #00000090',
- borderRadius: '0',
- isScrollRequired: 'enabled',
- },
- },
- {
- componentName: 'Text',
- layout: {
- top: 90,
- left: 10,
- height: 30,
- },
- properties: ['text'],
- styles: [
- 'textSize',
- 'fontWeight',
- 'fontStyle',
- 'textColor',
- 'isScrollRequired',
- 'lineHeight',
- 'textIndent',
- 'textAlign',
- 'verticalAlignment',
- 'decoration',
- 'transformation',
- 'letterSpacing',
- 'wordSpacing',
- 'fontVariant',
- 'backgroundColor',
- 'borderColor',
- 'borderRadius',
- 'boxShadow',
- 'padding',
- ],
- defaultValue: {
- text: 'Name',
- fontWeight: 'normal',
- textSize: 14,
- textColor: '#000',
- backgroundColor: '#fff00000',
- textAlign: 'left',
- decoration: 'none',
- transformation: 'none',
- fontStyle: 'normal',
- lineHeight: 1.5,
- textIndent: '0',
- letterSpacing: '0',
- wordSpacing: '0',
- fontVariant: 'normal',
- verticalAlignment: 'top',
- padding: 'default',
- boxShadow: '0px 0px 0px 0px #00000090',
- borderRadius: '0',
- isScrollRequired: 'enabled',
- },
- },
- {
- componentName: 'Text',
- layout: {
- top: 160,
- left: 10,
- height: 30,
- },
- properties: ['text'],
- styles: [
- 'textSize',
- 'fontWeight',
- 'fontStyle',
- 'textColor',
- 'isScrollRequired',
- 'lineHeight',
- 'textIndent',
- 'textAlign',
- 'verticalAlignment',
- 'decoration',
- 'transformation',
- 'letterSpacing',
- 'wordSpacing',
- 'fontVariant',
- 'backgroundColor',
- 'borderColor',
- 'borderRadius',
- 'boxShadow',
- 'padding',
- ],
- defaultValue: {
- text: 'Age',
- fontWeight: 'normal',
- textSize: 14,
- textColor: '#000',
- backgroundColor: '#fff00000',
- textAlign: 'left',
- decoration: 'none',
- transformation: 'none',
- fontStyle: 'normal',
- lineHeight: 1.5,
- textIndent: '0',
- letterSpacing: '0',
- wordSpacing: '0',
- fontVariant: 'normal',
- verticalAlignment: 'top',
- padding: 'default',
- boxShadow: '0px 0px 0px 0px #00000090',
- borderRadius: '0',
- isScrollRequired: 'enabled',
- },
- },
{
componentName: 'TextInput',
layout: {
- top: 120,
- left: 10,
- height: 30,
- width: 25,
+ top: 20,
+ left: 5,
+ height: 40,
+ width: 31,
},
properties: ['placeholder', 'label'],
+ styles: ['alignment', 'width', 'auto', 'padding', 'direction'],
defaultValue: {
placeholder: 'Enter your name',
- label: '',
+ label: 'Name',
+ width: '{{60}}',
+ direction: 'left',
+ alignment: 'side',
+ auto: '{{false}}',
+ padding: 'default',
},
},
{
componentName: 'NumberInput',
layout: {
- top: 190,
- left: 10,
- height: 30,
- width: 25,
+ top: 80,
+ left: 5,
+ height: 40,
+ width: 31,
},
- properties: ['value', 'label'],
+ properties: ['placeholder', 'label'],
+ styles: ['alignment', 'width', 'auto', 'padding', 'direction'],
defaultValue: {
- value: 24,
- label: '',
+ placeholder: 'Age',
+ label: 'Age',
+ width: '{{60}}',
+ direction: 'left',
+ alignment: 'side',
+ auto: '{{false}}',
+ padding: 'default',
},
},
{
- componentName: 'Button',
+ componentName: 'TextInput',
layout: {
- top: 240,
- left: 10,
- height: 30,
- width: 10,
+ top: 140,
+ left: 5,
+ height: 40,
+ width: 31,
},
- properties: ['text'],
+ properties: ['placeholder', 'label'],
+ styles: ['alignment', 'width', 'auto', 'padding', 'direction'],
defaultValue: {
- text: 'Submit',
+ placeholder: 'Tomy',
+ label: 'Pet name',
+ width: '{{60}}',
+ alignment: 'side',
+ direction: 'left',
+ auto: '{{false}}',
+ padding: 'default',
},
},
],
@@ -276,6 +141,24 @@ export const formConfig = {
},
showHeader: { type: 'toggle', displayName: 'Header' },
showFooter: { type: 'toggle', displayName: 'Footer' },
+ headerHeight: {
+ type: 'numberInput',
+ displayName: 'Header height',
+ isHidden: true,
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
+ canvasHeight: {
+ type: 'numberInput',
+ displayName: 'Canvas height',
+ isHidden: true,
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
+ footerHeight: {
+ type: 'numberInput',
+ displayName: 'Footer height',
+ isHidden: true,
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
visibility: {
type: 'toggle',
displayName: 'Visibility',
@@ -294,6 +177,13 @@ export const formConfig = {
defaultValue: false,
},
},
+ tooltip: {
+ type: 'code',
+ displayName: 'Tooltip',
+ validation: { schema: { type: 'string' } },
+ section: 'additionalActions',
+ placeholder: 'Enter tooltip text',
+ },
},
events: {
onSubmit: { displayName: 'On submit' },
@@ -316,24 +206,8 @@ export const formConfig = {
defaultValue: '#ffffffff',
},
},
- headerHeight: {
- type: 'code',
- displayName: 'Header height',
- validation: {
- schema: { type: 'string' },
- defaultValue: '80px',
- },
- },
- footerHeight: {
- type: 'code',
- displayName: 'Footer height',
- validation: {
- schema: { type: 'string' },
- defaultValue: '80px',
- },
- },
backgroundColor: {
- type: 'color',
+ type: 'colorSwatches',
displayName: 'Background color',
validation: {
schema: { type: 'string' },
@@ -351,7 +225,7 @@ export const formConfig = {
},
},
borderColor: {
- type: 'color',
+ type: 'colorSwatches',
displayName: 'Border color',
validation: {
schema: { type: 'string' },
@@ -403,18 +277,18 @@ export const formConfig = {
value:
"{{ {title: 'User registration form', properties: {firstname: {type: 'textinput',value: 'Maria',label:'First name', validation:{maxLength:6}, styles: {backgroundColor: '#f6f5ff',textColor: 'black'},},lastname:{type: 'textinput',value: 'Doe', label:'Last name', styles: {backgroundColor: '#f6f5ff',textColor: 'black'},},age:{type:'number', label:'Age'},}, submitButton: {value: 'Submit', styles: {backgroundColor: '#3a433b',borderColor:'#595959'}}} }}",
},
- showHeader: { value: '{{false}}' },
- showFooter: { value: '{{false}}' },
+ showHeader: { value: '{{true}}' },
+ showFooter: { value: '{{true}}' },
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
+ headerHeight: { value: 60 },
+ footerHeight: { value: 60 },
},
events: [],
styles: {
backgroundColor: { value: '#fff' },
borderRadius: { value: '0' },
borderColor: { value: '#fff' },
- headerHeight: { value: '60px' },
- footerHeight: { value: '60px' },
},
},
};
diff --git a/frontend/src/Editor/WidgetManager/configs/multiselectV2.js b/frontend/src/Editor/WidgetManager/configs/multiselectV2.js
index 0985f23d08..23618382df 100644
--- a/frontend/src/Editor/WidgetManager/configs/multiselectV2.js
+++ b/frontend/src/Editor/WidgetManager/configs/multiselectV2.js
@@ -121,6 +121,12 @@ export const multiselectV2Config = {
},
accordian: 'Options',
},
+ showAllSelectedLabel: {
+ type: 'toggle',
+ displayName: 'Show "All items are selected"',
+ validation: { schema: { type: 'boolean' }, defaultValue: true },
+ accordian: 'Options',
+ },
optionsLoadingState: {
type: 'toggle',
displayName: 'Options loading state',
@@ -339,6 +345,7 @@ export const multiselectV2Config = {
optionsLoadingState: { value: '{{false}}' },
sort: { value: 'asc' },
placeholder: { value: 'Select the options' },
+ showAllSelectedLabel: { value: '{{true}}' },
showClearBtn: { value: '{{true}}' },
showSearchInput: { value: '{{true}}' },
visibility: { value: '{{true}}' },
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}
-
+
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/1744097765065-UpdateContainerHeaderProperty.ts b/server/migrations/1744097765065-UpdateContainerHeaderProperty.ts
new file mode 100644
index 0000000000..1e7dd95b9e
--- /dev/null
+++ b/server/migrations/1744097765065-UpdateContainerHeaderProperty.ts
@@ -0,0 +1,50 @@
+import { Component } from 'src/entities/component.entity';
+
+import { processDataInBatches } from '@helpers/migration.helper';
+import { EntityManager, MigrationInterface, QueryRunner } from 'typeorm';
+
+export class UpdateContainerHeaderProperty1744097765065 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise
{
+ const componentTypes = ['Container'];
+ const batchSize = 100;
+ const entityManager = queryRunner.manager;
+
+ for (const componentType of componentTypes) {
+ await processDataInBatches(
+ entityManager,
+ async (entityManager: EntityManager) => {
+ return await entityManager.find(Component, {
+ where: { type: componentType },
+ order: { createdAt: 'ASC' },
+ });
+ },
+ async (entityManager: EntityManager, components: Component[]) => {
+ await this.processUpdates(entityManager, components);
+ },
+ batchSize
+ );
+ }
+ }
+
+ private async processUpdates(entityManager: EntityManager, components: Component[]) {
+ for (const component of components) {
+ const properties = component.properties;
+ const styles = component.styles;
+ const general = component.general;
+
+ // Update showHeader property to false for old instances
+ if (!properties.showHeader) {
+ properties.showHeader = { value: '{{false}}' };
+ }
+
+ // Update the modal component with the modified properties
+ await entityManager.update(Component, component.id, {
+ properties,
+ styles,
+ general,
+ });
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {}
+}
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/apps/services/widget-config/container.js b/server/src/modules/apps/services/widget-config/container.js
index 424b9a801d..6dc9a679a4 100644
--- a/server/src/modules/apps/services/widget-config/container.js
+++ b/server/src/modules/apps/services/widget-config/container.js
@@ -3,7 +3,7 @@ export const containerConfig = {
displayName: 'Container',
description: 'Group components',
defaultSize: {
- width: 5,
+ width: 10,
height: 200,
},
component: 'Container',
@@ -44,13 +44,19 @@ export const containerConfig = {
displayName: 'Show header',
validation: {
schema: { type: 'boolean' },
- defaultValue: false,
+ defaultValue: true,
},
},
+ headerHeight: {
+ type: 'numberInput',
+ displayName: 'Header height',
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
},
defaultChildren: [
{
componentName: 'Text',
+ slotName: 'header',
layout: {
top: 20,
left: 1,
@@ -98,15 +104,6 @@ export const containerConfig = {
},
accordian: 'container',
},
- headerHeight: {
- type: 'numberInput',
- displayName: 'Height',
- validation: {
- schema: { type: 'number' },
- defaultValue: 80,
- },
- accordian: 'header',
- },
borderRadius: {
type: 'numberInput',
displayName: 'Border',
@@ -154,10 +151,11 @@ export const containerConfig = {
showOnMobile: { value: '{{false}}' },
},
properties: {
- showHeader: { value: `{{false}}` },
+ showHeader: { value: `{{true}}` },
loadingState: { value: `{{false}}` },
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
+ headerHeight: { value: `{{80}}` },
},
events: [],
styles: {
diff --git a/server/src/modules/apps/services/widget-config/form.js b/server/src/modules/apps/services/widget-config/form.js
index 4feca316c0..129322cf73 100644
--- a/server/src/modules/apps/services/widget-config/form.js
+++ b/server/src/modules/apps/services/widget-config/form.js
@@ -4,7 +4,7 @@ export const formConfig = {
description: 'Wrapper for multiple components',
defaultSize: {
width: 13,
- height: 480,
+ height: 450,
},
defaultChildren: [
{
@@ -19,8 +19,8 @@ export const formConfig = {
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {
- text: 'Form title',
- textSize: 20,
+ text: 'Form',
+ textSize: 16,
textColor: '#000',
},
},
@@ -34,203 +34,68 @@ export const formConfig = {
},
properties: ['text'],
defaultValue: {
- text: 'Button2',
+ text: 'Submit',
padding: 'none',
},
},
- {
- componentName: 'Text',
- layout: {
- top: 40,
- left: 10,
- height: 30,
- width: 17,
- },
- properties: ['text'],
- styles: [
- 'textSize',
- 'fontWeight',
- 'fontStyle',
- 'textColor',
- 'isScrollRequired',
- 'lineHeight',
- 'textIndent',
- 'textAlign',
- 'verticalAlignment',
- 'decoration',
- 'transformation',
- 'letterSpacing',
- 'wordSpacing',
- 'fontVariant',
- 'backgroundColor',
- 'borderColor',
- 'borderRadius',
- 'boxShadow',
- 'padding',
- ],
- defaultValue: {
- text: 'User Details',
- fontWeight: 'bold',
- textSize: 18,
- textColor: '#000',
- backgroundColor: '#fff00000',
- textAlign: 'left',
- decoration: 'none',
- transformation: 'none',
- fontStyle: 'normal',
- lineHeight: 1.5,
- textIndent: '0',
- letterSpacing: '0',
- wordSpacing: '0',
- fontVariant: 'normal',
- verticalAlignment: 'top',
- padding: 'default',
- boxShadow: '0px 0px 0px 0px #00000090',
- borderRadius: '0',
- isScrollRequired: 'enabled',
- },
- },
- {
- componentName: 'Text',
- layout: {
- top: 90,
- left: 10,
- height: 30,
- },
- properties: ['text'],
- styles: [
- 'textSize',
- 'fontWeight',
- 'fontStyle',
- 'textColor',
- 'isScrollRequired',
- 'lineHeight',
- 'textIndent',
- 'textAlign',
- 'verticalAlignment',
- 'decoration',
- 'transformation',
- 'letterSpacing',
- 'wordSpacing',
- 'fontVariant',
- 'backgroundColor',
- 'borderColor',
- 'borderRadius',
- 'boxShadow',
- 'padding',
- ],
- defaultValue: {
- text: 'Name',
- fontWeight: 'normal',
- textSize: 14,
- textColor: '#000',
- backgroundColor: '#fff00000',
- textAlign: 'left',
- decoration: 'none',
- transformation: 'none',
- fontStyle: 'normal',
- lineHeight: 1.5,
- textIndent: '0',
- letterSpacing: '0',
- wordSpacing: '0',
- fontVariant: 'normal',
- verticalAlignment: 'top',
- padding: 'default',
- boxShadow: '0px 0px 0px 0px #00000090',
- borderRadius: '0',
- isScrollRequired: 'enabled',
- },
- },
- {
- componentName: 'Text',
- layout: {
- top: 160,
- left: 10,
- height: 30,
- },
- properties: ['text'],
- styles: [
- 'textSize',
- 'fontWeight',
- 'fontStyle',
- 'textColor',
- 'isScrollRequired',
- 'lineHeight',
- 'textIndent',
- 'textAlign',
- 'verticalAlignment',
- 'decoration',
- 'transformation',
- 'letterSpacing',
- 'wordSpacing',
- 'fontVariant',
- 'backgroundColor',
- 'borderColor',
- 'borderRadius',
- 'boxShadow',
- 'padding',
- ],
- defaultValue: {
- text: 'Age',
- fontWeight: 'normal',
- textSize: 14,
- textColor: '#000',
- backgroundColor: '#fff00000',
- textAlign: 'left',
- decoration: 'none',
- transformation: 'none',
- fontStyle: 'normal',
- lineHeight: 1.5,
- textIndent: '0',
- letterSpacing: '0',
- wordSpacing: '0',
- fontVariant: 'normal',
- verticalAlignment: 'top',
- padding: 'default',
- boxShadow: '0px 0px 0px 0px #00000090',
- borderRadius: '0',
- isScrollRequired: 'enabled',
- },
- },
{
componentName: 'TextInput',
layout: {
- top: 120,
- left: 10,
- height: 30,
- width: 25,
+ top: 20,
+ left: 5,
+ height: 40,
+ width: 31,
},
properties: ['placeholder', 'label'],
+ styles: ['alignment', 'width', 'auto', 'padding', 'direction'],
defaultValue: {
placeholder: 'Enter your name',
- label: '',
+ label: 'Name',
+ width: '{{60}}',
+ direction: 'left',
+ alignment: 'side',
+ auto: '{{false}}',
+ padding: 'default',
},
},
{
componentName: 'NumberInput',
layout: {
- top: 190,
- left: 10,
- height: 30,
- width: 25,
+ top: 80,
+ left: 5,
+ height: 40,
+ width: 31,
},
- properties: ['value', 'label'],
+ properties: ['placeholder', 'label'],
+ styles: ['alignment', 'width', 'auto', 'padding', 'direction'],
defaultValue: {
- value: 24,
- label: '',
+ placeholder: 'Age',
+ label: 'Age',
+ width: '{{60}}',
+ direction: 'left',
+ alignment: 'side',
+ auto: '{{false}}',
+ padding: 'default',
},
},
{
- componentName: 'Button',
+ componentName: 'TextInput',
layout: {
- top: 240,
- left: 10,
- height: 30,
- width: 10,
+ top: 140,
+ left: 5,
+ height: 40,
+ width: 31,
},
- properties: ['text'],
+ properties: ['placeholder', 'label'],
+ styles: ['alignment', 'width', 'auto', 'padding', 'direction'],
defaultValue: {
- text: 'Submit',
+ placeholder: 'Tomy',
+ label: 'Pet name',
+ width: '{{60}}',
+ alignment: 'side',
+ direction: 'left',
+ auto: '{{false}}',
+ padding: 'default',
},
},
],
@@ -276,6 +141,24 @@ export const formConfig = {
},
showHeader: { type: 'toggle', displayName: 'Header' },
showFooter: { type: 'toggle', displayName: 'Footer' },
+ headerHeight: {
+ type: 'numberInput',
+ displayName: 'Header height',
+ isHidden: true,
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
+ canvasHeight: {
+ type: 'numberInput',
+ displayName: 'Canvas height',
+ isHidden: true,
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
+ footerHeight: {
+ type: 'numberInput',
+ displayName: 'Footer height',
+ isHidden: true,
+ validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
+ },
visibility: {
type: 'toggle',
displayName: 'Visibility',
@@ -294,6 +177,13 @@ export const formConfig = {
defaultValue: false,
},
},
+ tooltip: {
+ type: 'code',
+ displayName: 'Tooltip',
+ validation: { schema: { type: 'string' } },
+ section: 'additionalActions',
+ placeholder: 'Enter tooltip text',
+ },
},
events: {
onSubmit: { displayName: 'On submit' },
@@ -316,22 +206,6 @@ export const formConfig = {
defaultValue: '#ffffffff',
},
},
- headerHeight: {
- type: 'code',
- displayName: 'Header height',
- validation: {
- schema: { type: 'string' },
- defaultValue: '80px',
- },
- },
- footerHeight: {
- type: 'code',
- displayName: 'Footer height',
- validation: {
- schema: { type: 'string' },
- defaultValue: '80px',
- },
- },
backgroundColor: {
type: 'colorSwatches',
displayName: 'Background color',
@@ -403,19 +277,18 @@ export const formConfig = {
value:
"{{ {title: 'User registration form', properties: {firstname: {type: 'textinput',value: 'Maria',label:'First name', validation:{maxLength:6}, styles: {backgroundColor: '#f6f5ff',textColor: 'black'},},lastname:{type: 'textinput',value: 'Doe', label:'Last name', styles: {backgroundColor: '#f6f5ff',textColor: 'black'},},age:{type:'number', label:'Age'},}, submitButton: {value: 'Submit', styles: {backgroundColor: '#3a433b',borderColor:'#595959'}}} }}",
},
- buttonToSubmit: { value: '{{"none"}}' },
- showHeader: { value: '{{false}}' },
- showFooter: { value: '{{false}}' },
+ showHeader: { value: '{{true}}' },
+ showFooter: { value: '{{true}}' },
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
+ headerHeight: { value: 60 },
+ footerHeight: { value: 60 },
},
events: [],
styles: {
backgroundColor: { value: '#fff' },
borderRadius: { value: '0' },
borderColor: { value: '#fff' },
- headerHeight: { value: '60px' },
- footerHeight: { value: '60px' },
},
},
};
diff --git a/server/src/modules/apps/services/widget-config/icon.js b/server/src/modules/apps/services/widget-config/icon.js
index 9f8c178def..bf373e526b 100644
--- a/server/src/modules/apps/services/widget-config/icon.js
+++ b/server/src/modules/apps/services/widget-config/icon.js
@@ -142,4 +142,5 @@ export const iconConfig = {
padding: { value: 'default' },
boxShadow: { value: '0px 0px 0px 0px #00000040' },
},
-};
+ }
+};
\ No newline at end of file
diff --git a/server/src/modules/apps/services/widget-config/multiselectV2.js b/server/src/modules/apps/services/widget-config/multiselectV2.js
index 294423abac..c9d89045aa 100644
--- a/server/src/modules/apps/services/widget-config/multiselectV2.js
+++ b/server/src/modules/apps/services/widget-config/multiselectV2.js
@@ -121,6 +121,12 @@ export const multiselectV2Config = {
},
accordian: 'Options',
},
+ showAllSelectedLabel: {
+ type: 'toggle',
+ displayName: 'Show "All items are selected"',
+ validation: { schema: { type: 'boolean' }, defaultValue: true },
+ accordian: 'Options',
+ },
optionsLoadingState: {
type: 'toggle',
displayName: 'Options loading state',
@@ -339,6 +345,7 @@ export const multiselectV2Config = {
optionsLoadingState: { value: '{{false}}' },
sort: { value: 'asc' },
placeholder: { value: 'Select the options' },
+ showAllSelectedLabel: { value: '{{true}}' },
showClearBtn: { value: '{{true}}' },
showSearchInput: { value: '{{true}}' },
visibility: { value: '{{true}}' },
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,