Merge pull request #12960 from ToolJet/feat/component-permissions

Feat: Component level permissions
This commit is contained in:
Johnson Cherian 2025-06-26 18:08:32 +05:30 committed by GitHub
commit a0f2115c30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 810 additions and 63 deletions

@ -1 +1 @@
Subproject commit 9458c8d66f29f8334765b5757dd096139a8d53d2
Subproject commit 51d0a7fbe974919786c938304e2214d46396c033

View file

@ -4,6 +4,7 @@ import './configHandle.scss';
import useStore from '@/AppBuilder/_stores/store';
import { findHighestLevelofSelection } from '../Grid/gridUtils';
import SolidIcon from '@/_ui/Icon/solidIcons/index';
import { ToolTip } from '@/_components/ToolTip';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
import { DROPPABLE_PARENTS } from '../appCanvasConstants';
@ -52,7 +53,40 @@ export const ConfigHandle = ({
);
}, shallow);
const currentPageIndex = useStore((state) => state.modules.canvas.currentPageIndex);
const component = useStore((state) => state.modules.canvas.pages[currentPageIndex].components[id]);
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const isRestricted = component.permissions && component.permissions.length !== 0;
const draggingComponentId = useStore((state) => state.draggingComponentId);
let height = visibility === false ? 10 : widgetHeight;
const getTooltip = () => {
const permission = component.permissions?.[0];
if (!permission) return null;
const users = permission.groups || permission.users || [];
if (users.length === 0) return null;
const isSingle = permission.type === 'SINGLE';
const isGroup = permission.type === 'GROUP';
if (isSingle) {
return users.length === 1
? `Access restricted to ${users[0].user.email}`
: `Access restricted to ${users.length} users`;
}
if (isGroup) {
return users.length === 1
? `Access restricted to ${users[0].permission_group?.name || users[0].permissionGroup?.name} group`
: `Access restricted to ${users.length} user groups`;
}
return null;
};
return (
<div
className={`config-handle ${customClassName}`}
@ -78,6 +112,22 @@ export const ConfigHandle = ({
}
}}
>
{licenseValid && isRestricted && (
<ToolTip message={getTooltip()} show={licenseValid && isRestricted && !draggingComponentId}>
<span
style={{
background:
visibility === false ? '#c6cad0' : componentType === 'Modal' && isModalOpen ? '#c6cad0' : '#4D72FA',
border: position === 'bottom' ? '1px solid white' : 'none',
color: visibility === false && 'var(--text-placeholder)',
marginRight: '4px',
}}
className="badge handle-content"
>
<SolidIcon width="12" name="lock" fill="var(--icon-on-solid)" />
</span>
</ToolTip>
)}
<span
style={{
background:

View file

@ -33,6 +33,7 @@ const CreateVersionModal = ({
appId,
setCurrentVersionId,
selectedVersion,
currentMode,
} = useStore(
(state) => ({
createNewVersionAction: state.createNewVersionAction,
@ -45,6 +46,7 @@ const CreateVersionModal = ({
currentVersionId: state.currentVersionId,
setCurrentVersionId: state.setCurrentVersionId,
selectedVersion: state.selectedVersion,
currentMode: state.currentMode,
}),
shallow
);
@ -94,7 +96,7 @@ const CreateVersionModal = ({
setIsCreatingVersion(false);
setShowCreateAppVersion(false);
appVersionService
.getAppVersionData(appId, newVersion.id)
.getAppVersionData(appId, newVersion.id, currentMode)
.then((data) => {
setCurrentVersionId(newVersion.id);
handleCommitOnVersionCreation(data);

View file

@ -43,6 +43,9 @@ import useStore from '@/AppBuilder/_stores/store';
import { componentTypes } from '@/AppBuilder/WidgetManager/componentTypes';
import { copyComponents } from '@/AppBuilder/AppCanvas/appCanvasUtils.js';
import DatetimePickerV2 from './Components/DatetimePickerV2.jsx';
import { ToolTip } from '@/_components/ToolTip';
import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal';
import { appPermissionService } from '@/_services';
import { ModuleContainerInspector, ModuleViewerInspector, ModuleEditorBanner } from '@/modules/Modules/components';
const INSPECTOR_HEADER_OPTIONS = [
@ -61,6 +64,19 @@ const INSPECTOR_HEADER_OPTIONS = [
value: 'duplicate',
icon: <Copy width={16} />,
},
{
label: 'Component permission',
value: 'permission',
icon: (
<img
alt="permission-icon"
src="assets/images/icons/editor/left-sidebar/authorization.svg"
width="16"
height="16"
/>
),
trailingIcon: <SolidIcon width={16} name="enterprisecrown" className="mx-1" />,
},
{
label: 'Delete',
value: 'delete',
@ -104,6 +120,11 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
const isVersionReleased = useStore((state) => state.isVersionReleased);
const setWidgetDeleteConfirmation = useStore((state) => state.setWidgetDeleteConfirmation);
const setComponentToInspect = useStore((state) => state.setComponentToInspect);
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const showComponentPermissionModal = useStore((state) => state.showComponentPermissionModal);
const toggleComponentPermissionModal = useStore((state) => state.toggleComponentPermissionModal);
const setComponentPermission = useStore((state) => state.setComponentPermission);
const dataQueries = useDataQueries();
const currentState = useCurrentState();
@ -378,9 +399,14 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
if (value === 'delete') {
setWidgetDeleteConfirmation(true);
}
if (value === 'permission') {
if (!licenseValid) return;
toggleComponentPermissionModal(true);
}
if (value === 'duplicate') {
copyComponents({ isCloning: true });
}
setShowHeaderActionsMenu(false);
};
const buildGeneralStyle = () => {
if (!componentMeta?.definition?.generalStyles) {
@ -446,7 +472,7 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
React.useEffect(() => {
const handleClickOutside = (event) => {
if (showHeaderActionsMenu && event.target.closest('.list-menu') === null) {
if (showHeaderActionsMenu && event.target.closest('#list-menu') === null) {
setShowHeaderActionsMenu(false);
}
};
@ -504,44 +530,79 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
</div>
<div className={`col-9 p-0 ${shouldFreeze && 'disabled'}`}>{renderAppNameInput()}</div>
{!isModuleContainer && (
<div className="col-2" data-cy={'component-inspector-options'}>
<OverlayTrigger
trigger={'click'}
placement={'bottom-end'}
rootClose={false}
show={showHeaderActionsMenu}
overlay={
<Popover id="list-menu" className={darkMode && 'dark-theme'}>
<Popover.Body bsPrefix="list-item-popover-body">
{INSPECTOR_HEADER_OPTIONS.map((option) => (
<div
data-cy={`component-inspector-${String(option?.value).toLowerCase()}-button`}
className="list-item-popover-option"
key={option?.value}
onClick={(e) => {
e.stopPropagation();
handleInspectorHeaderActions(option.value);
}}
>
<div className="list-item-popover-menu-option-icon">{option.icon}</div>
<div
className={classNames('list-item-option-menu-label', {
'color-tomato9': option.value === 'delete',
})}
>
{option?.label}
</div>
</div>
))}
</Popover.Body>
</Popover>
}
>
<span className="cursor-pointer" onClick={() => setShowHeaderActionsMenu(true)}>
<SolidIcon data-cy={'menu-icon'} name="morevertical" width="24" fill={'var(--slate12)'} />
</span>
</OverlayTrigger>
</div>
<>
<div className="col-2" data-cy={'component-inspector-options'}>
<OverlayTrigger
trigger={'click'}
placement={'bottom-end'}
rootClose={false}
show={showHeaderActionsMenu}
overlay={
<Popover id="list-menu" className={darkMode && 'dark-theme'}>
<Popover.Body bsPrefix="list-item-popover-body">
{INSPECTOR_HEADER_OPTIONS.map((option) => {
const optionBody = (
<div
data-cy={`component-inspector-${String(option?.value).toLowerCase()}-button`}
className="list-item-popover-option"
key={option?.value}
onClick={(e) => {
e.stopPropagation();
handleInspectorHeaderActions(option.value);
}}
>
<div className="list-item-popover-menu-option-icon">{option.icon}</div>
<div
className={classNames('list-item-option-menu-label', {
'color-tomato9': option.value === 'delete',
'color-disabled': option.value === 'permission' && !licenseValid,
})}
>
{option?.label}
</div>
{option.value === 'permission' &&
!licenseValid &&
option.trailingIcon &&
option.trailingIcon}
</div>
);
return option.value === 'permission' ? (
<ToolTip
key={option.value}
message={'Component permissions are available only in paid plans'}
placement="left"
show={!licenseValid}
>
{optionBody}
</ToolTip>
) : (
optionBody
);
})}
</Popover.Body>
</Popover>
}
>
<span className="cursor-pointer" onClick={() => setShowHeaderActionsMenu(true)}>
<SolidIcon data-cy={'menu-icon'} name="morevertical" width="24" fill={'var(--slate12)'} />
</span>
</OverlayTrigger>
</div>
<AppPermissionsModal
modalType="component"
resourceId={selectedComponentId}
resourceName={allComponents[selectedComponentId]?.component?.name}
showModal={showComponentPermissionModal}
toggleModal={toggleComponentPermissionModal}
darkMode={darkMode}
fetchPermission={(id, appId) => appPermissionService.getComponentPermission(appId, id)}
createPermission={(id, appId, body) => appPermissionService.createComponentPermission(appId, id, body)}
updatePermission={(id, appId, body) => appPermissionService.updateComponentPermission(appId, id, body)}
deletePermission={(id, appId) => appPermissionService.deleteComponentPermission(appId, id)}
onSuccess={(data) => setComponentPermission(selectedComponentId, data)}
/>
</>
)}
</div>
@ -557,8 +618,8 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
componentMeta.displayName === 'Toggle Switch (Legacy)'
? 'Toggle (Legacy)'
: componentMeta.displayName === 'Toggle Switch'
? 'Toggle Switch'
: componentMeta.component,
? 'Toggle Switch'
: componentMeta.component,
})}
</small>
</span>

View file

@ -252,7 +252,7 @@ const useAppData = (
appDataPromise = appService.fetchAppBySlug(slug);
} else {
appDataPromise = isPreviewForVersion
? appVersionService.getAppVersionData(appId, versionId)
? appVersionService.getAppVersionData(appId, versionId, mode)
: appService.fetchApp(appId);
}
}
@ -573,7 +573,7 @@ const useAppData = (
if (isEnvChanged) {
setEnvironmentLoadingState('loading');
}
appVersionService.getAppVersionData(appId, selectedVersion?.id).then(async (appData) => {
appVersionService.getAppVersionData(appId, selectedVersion?.id, mode).then(async (appData) => {
cleanUpStore(false);
const { should_freeze_editor } = appData;
setIsEditorFreezed(should_freeze_editor);

View file

@ -46,6 +46,7 @@ const initialState = {
showWidgetDeleteConfirmation: false,
focusedParentId: null,
modalsOpenOnCanvas: [],
showComponentPermissionModal: false,
};
export const createComponentsSlice = (set, get) => ({
@ -1989,4 +1990,25 @@ export const createComponentsSlice = (set, get) => ({
setComponentProperty(componentId, `canvasHeight`, maxHeight, 'properties', 'value', false);
},
toggleComponentPermissionModal: (show) => {
set((state) => {
state.showComponentPermissionModal = show;
});
},
setComponentPermission: (componentId, data) => {
const { modules } = get();
const currentPageIndex = modules.canvas.currentPageIndex;
const component = modules.canvas.pages[currentPageIndex]?.components?.[componentId];
if (component) {
const updatedComponent = {
...component,
permissions: data.length === 0 || data.length === undefined ? [] : [data[0]],
};
set((state) => {
state.modules.canvas.pages[currentPageIndex].components[componentId] = updatedComponent;
});
}
},
});

View file

@ -196,7 +196,7 @@ export const createEnvironmentsAndVersionsSlice = (set, get) => ({
},
changeEditorVersionAction: async (appId, versionId, onSuccess, onFailure) => {
try {
const data = await appVersionService.getAppVersionData(appId, versionId);
const data = await appVersionService.getAppVersionData(appId, versionId, get().currentMode);
const selectedVersion = {
id: data.editing_version.id,
name: data.editing_version.name,

View file

@ -11,6 +11,10 @@ export const appPermissionService = {
createQueryPermission,
updateQueryPermission,
deleteQueryPermission,
getComponentPermission,
createComponentPermission,
updateComponentPermission,
deleteComponentPermission,
};
function getPagePermission(appId, pageId) {
@ -89,3 +93,49 @@ function deleteQueryPermission(appId, queryId) {
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/queries/${queryId}`, requestOptions).then(handleResponse);
}
function getComponentPermission(appId, componentId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
credentials: 'include',
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/components/${componentId}`, requestOptions).then(
handleResponse
);
}
function createComponentPermission(appId, componentId, body) {
const requestOptions = {
method: 'POST',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/components/${componentId}`, requestOptions).then(
handleResponse
);
}
function updateComponentPermission(appId, componentId, body) {
const requestOptions = {
method: 'PUT',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/components/${componentId}`, requestOptions).then(
handleResponse
);
}
function deleteComponentPermission(appId, componentId) {
const requestOptions = {
method: 'DELETE',
headers: authHeader(),
credentials: 'include',
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/components/${componentId}`, requestOptions).then(
handleResponse
);
}

View file

@ -36,9 +36,9 @@ function promoteEnvironment(appId, versionId, currentEnvironmentId) {
};
return fetch(`${config.apiUrl}/v2/apps/${appId}/versions/${versionId}/promote`, requestOptions).then(handleResponse);
}
function getAppVersionData(appId, versionId) {
function getAppVersionData(appId, versionId, mode) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/v2/apps/${appId}/versions/${versionId}`, requestOptions).then(handleResponse);
return fetch(`${config.apiUrl}/v2/apps/${appId}/versions/${versionId}?mode=${mode}`, requestOptions).then(handleResponse);
}
function create(appId, versionName, versionFromId, currentEnvironmentId) {

View file

@ -2800,7 +2800,7 @@ hr {
}
.config-handle {
display: block;
display: flex;
}
.apps-table {

@ -1 +1 @@
Subproject commit e76477d30eb21df5d188ca204c520df75fddd529
Subproject commit 213bba98018d82fe2fee0689e5b7bf1a19a85ade

View file

@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class CreateComponentPermissions1748509644056 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'component_permissions',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'component_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(
'component_permissions',
new TableForeignKey({
columnNames: ['component_id'],
referencedColumnNames: ['id'],
referencedTableName: 'components',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('component_permissions');
}
}

View file

@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class CreateComponentUsers1748509665915 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'component_users',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'component_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(
'component_users',
new TableForeignKey({
columnNames: ['component_permissions_id'],
referencedColumnNames: ['id'],
referencedTableName: 'component_permissions',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'component_users',
new TableForeignKey({
columnNames: ['user_id'],
referencedColumnNames: ['id'],
referencedTableName: 'users',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'component_users',
new TableForeignKey({
columnNames: ['permission_groups_id'],
referencedColumnNames: ['id'],
referencedTableName: 'permission_groups',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('component_users');
}
}

View file

@ -11,6 +11,7 @@ import {
} from 'typeorm';
import { Page } from './page.entity';
import { Layout } from './layout.entity';
import { ComponentPermission } from './component_permissions.entity';
@Entity({ name: 'components' })
@Index('idx_component_page_id', ['pageId'])
@ -60,4 +61,7 @@ export class Component {
@OneToMany(() => Layout, (layout) => layout.component)
layouts: Layout[];
@OneToMany(() => ComponentPermission, (permission) => permission.component)
permissions: ComponentPermission[];
}

View file

@ -0,0 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, OneToMany } from 'typeorm';
import { Component } from './component.entity';
import { PAGE_PERMISSION_TYPE } from '@modules/app-permissions/constants';
import { ComponentUser } from './component_users.entity';
@Entity('component_permissions')
export class ComponentPermission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'component_id', type: 'uuid', nullable: false })
componentId: string;
@Column({
type: 'enum',
enum: PAGE_PERMISSION_TYPE,
})
type: PAGE_PERMISSION_TYPE;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => Component, (component) => component.permissions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'component_id' })
component: Component;
@OneToMany(() => ComponentUser, (componentUser) => componentUser.componentPermission)
users: ComponentUser[];
}

View file

@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { User } from './user.entity';
import { ComponentPermission } from './component_permissions.entity';
import { GroupPermissions } from './group_permissions.entity';
@Entity('component_users')
export class ComponentUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'component_permissions_id', type: 'uuid' })
componentPermissionsId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({ name: 'permission_groups_id', type: 'uuid', nullable: true })
permissionGroupsId: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => ComponentPermission, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'component_permissions_id' })
componentPermission: ComponentPermission;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => GroupPermissions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'permission_groups_id' })
permissionGroup: GroupPermissions;
}

View file

@ -15,6 +15,7 @@ import { GranularPermissions } from './granular_permissions.entity';
import { GROUP_PERMISSIONS_TYPE } from '@modules/group-permissions/constants';
import { PageUser } from './page_users.entity';
import { QueryUser } from './query_users.entity';
import { ComponentUser } from './component_users.entity';
@Entity({ name: 'permission_groups' })
export class GroupPermissions extends BaseEntity {
@ -76,5 +77,8 @@ export class GroupPermissions extends BaseEntity {
@OneToMany(() => QueryUser, (queryUser) => queryUser.permissionGroup)
queryUsers: QueryUser[];
@OneToMany(() => ComponentUser, (componentUser) => componentUser.permissionGroup)
componentUsers: ComponentUser[];
disabled?: boolean;
}

View file

@ -31,6 +31,7 @@ import { AiResponseVote } from './ai_response_vote.entity';
import { USER_ROLE } from '@modules/group-permissions/constants';
import { PageUser } from './page_users.entity';
import { QueryUser } from './query_users.entity';
import { ComponentUser } from './component_users.entity';
@Entity({ name: 'users' })
export class User extends BaseEntity {
@ -192,6 +193,9 @@ export class User extends BaseEntity {
@OneToMany(() => QueryUser, (queryUser) => queryUser.user)
queryUsers: QueryUser[];
@OneToMany(() => ComponentUser, (componentUser) => componentUser.user)
componentUsers: ComponentUser[];
organizationId: string;
invitedOrganizationId: string;
organizationIds?: Array<string>;

View file

@ -42,6 +42,10 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
FEATURE_KEY.CREATE_QUERY_PERMISSIONS,
FEATURE_KEY.UPDATE_QUERY_PERMISSIONS,
FEATURE_KEY.DELETE_QUERY_PERMISSIONS,
FEATURE_KEY.FETCH_COMPONENT_PERMISSIONS,
FEATURE_KEY.CREATE_COMPONENT_PERMISSIONS,
FEATURE_KEY.UPDATE_COMPONENT_PERMISSIONS,
FEATURE_KEY.DELETE_COMPONENT_PERMISSIONS,
],
App
);
@ -64,6 +68,10 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
FEATURE_KEY.CREATE_QUERY_PERMISSIONS,
FEATURE_KEY.UPDATE_QUERY_PERMISSIONS,
FEATURE_KEY.DELETE_QUERY_PERMISSIONS,
FEATURE_KEY.FETCH_COMPONENT_PERMISSIONS,
FEATURE_KEY.CREATE_COMPONENT_PERMISSIONS,
FEATURE_KEY.UPDATE_COMPONENT_PERMISSIONS,
FEATURE_KEY.DELETE_COMPONENT_PERMISSIONS,
],
App
);
@ -80,6 +88,7 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
FEATURE_KEY.FETCH_USER_GROUPS,
FEATURE_KEY.FETCH_PAGE_PERMISSIONS,
FEATURE_KEY.FETCH_QUERY_PERMISSIONS,
FEATURE_KEY.FETCH_COMPONENT_PERMISSIONS,
],
App
);

View file

@ -14,5 +14,9 @@ export const FEATURES: FeaturesConfig = {
[FEATURE_KEY.CREATE_QUERY_PERMISSIONS]: {},
[FEATURE_KEY.UPDATE_QUERY_PERMISSIONS]: {},
[FEATURE_KEY.DELETE_QUERY_PERMISSIONS]: {},
[FEATURE_KEY.FETCH_COMPONENT_PERMISSIONS]: {},
[FEATURE_KEY.CREATE_COMPONENT_PERMISSIONS]: {},
[FEATURE_KEY.UPDATE_COMPONENT_PERMISSIONS]: {},
[FEATURE_KEY.DELETE_COMPONENT_PERMISSIONS]: {},
},
};

View file

@ -21,4 +21,8 @@ export enum FEATURE_KEY {
CREATE_QUERY_PERMISSIONS = 'create_query_permissions',
UPDATE_QUERY_PERMISSIONS = 'update_query_permissions',
DELETE_QUERY_PERMISSIONS = 'delete_query_permissions',
FETCH_COMPONENT_PERMISSIONS = 'fetch_component_permissions',
CREATE_COMPONENT_PERMISSIONS = 'create_component_permissions',
UPDATE_COMPONENT_PERMISSIONS = 'update_component_permissions',
DELETE_COMPONENT_PERMISSIONS = 'delete_component_permissions',
}

View file

@ -127,4 +127,50 @@ export class AppPermissionsController implements IAppPermissionsController {
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.FETCH_COMPONENT_PERMISSIONS)
@Get(':appId/components/:componentId')
async fetchComponentPermissions(
@User() user,
@Param('appId') appId: string,
@Param('componentId') componentId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.CREATE_COMPONENT_PERMISSIONS)
@Post(':appId/components/:componentId')
async createComponentPermissions(
@User() user,
@Param('appId') appId: string,
@Param('componentId') componentId: string,
@Body() body: CreatePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.UPDATE_COMPONENT_PERMISSIONS)
@Put(':appId/components/:componentId')
async updateComponentPermissions(
@User() user,
@Param('appId') appId: string,
@Param('componentId') componentId: string,
@Body() body: CreatePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.DELETE_COMPONENT_PERMISSIONS)
@Delete(':appId/components/:componentId')
async deleteComponentPermissions(
@User() user,
@Param('appId') appId: string,
@Param('componentId') componentId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
}

View file

@ -46,4 +46,24 @@ export interface IAppPermissionsController {
): Promise<any>;
deleteQueryPermissions(user: User, appId: string, queryId: string, response: Response): Promise<any>;
fetchComponentPermissions(user: User, appId: string, componentId: string, response: Response): Promise<any>;
createComponentPermissions(
user: User,
appId: string,
componentId: string,
body: CreatePermissionDto,
response: Response
): Promise<any>;
updateComponentPermissions(
user: User,
appId: string,
componentId: string,
body: CreatePermissionDto,
response: Response
): Promise<any>;
deleteComponentPermissions(user: User, appId: string, componentId: string, response: Response): Promise<any>;
}

View file

@ -14,4 +14,8 @@ export interface IUtilService {
createQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any>;
updateQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any>;
createComponentPermission(componentId: string, body: CreatePermissionDto): Promise<any>;
updateComponentPermission(componentId: string, body: CreatePermissionDto): Promise<any>;
}

View file

@ -9,10 +9,14 @@ import { PageUsersRepository } from './repositories/page-users.repository';
import { PagePermissionsRepository } from './repositories/page-permissions.repository';
import { QueryUsersRepository } from './repositories/query-users.repository';
import { QueryPermissionsRepository } from './repositories/query-permissions.repository';
import { ComponentUsersRepository } from './repositories/component-users.repository';
import { ComponentPermissionsRepository } from './repositories/component-permissions.repository';
import { PageUser } from '@entities/page_users.entity';
import { PagePermission } from '@entities/page_permissions.entity';
import { QueryUser } from '@entities/query_users.entity';
import { QueryPermission } from '@entities/query_permissions.entity';
import { ComponentUser } from '@entities/component_users.entity';
import { ComponentPermission } from '@entities/component_permissions.entity';
export class AppPermissionsModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -24,7 +28,16 @@ export class AppPermissionsModule {
return {
module: AppPermissionsModule,
imports: [
TypeOrmModule.forFeature([GroupPermissions, User, PageUser, PagePermission, QueryUser, QueryPermission]),
TypeOrmModule.forFeature([
GroupPermissions,
User,
PageUser,
PagePermission,
QueryUser,
QueryPermission,
ComponentUser,
ComponentPermission,
]),
],
controllers: [AppPermissionsController],
providers: [
@ -35,6 +48,8 @@ export class AppPermissionsModule {
PagePermissionsRepository,
QueryUsersRepository,
QueryPermissionsRepository,
ComponentUsersRepository,
ComponentPermissionsRepository,
FeatureAbilityFactory,
],
exports: [AppPermissionsUtilService, AppPermissionsService],

View file

@ -0,0 +1,58 @@
import { ComponentPermission } from '@entities/component_permissions.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { ComponentUsersRepository } from './component-users.repository';
import { dbTransactionWrap } from '@helpers/database.helper';
import { PAGE_PERMISSION_TYPE } from '../constants';
@Injectable()
export class ComponentPermissionsRepository extends Repository<ComponentPermission> {
constructor(private dataSource: DataSource, private readonly componentUsersRepository: ComponentUsersRepository) {
super(ComponentPermission, dataSource.createEntityManager());
}
async getComponentPermissions(componentId: string, manager?: EntityManager): Promise<ComponentPermission[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const componentPermissions = await manager.find(ComponentPermission, {
where: { componentId },
relations: ['users', 'users.user', 'users.permissionGroup'],
});
return componentPermissions.map((permission) => {
if (permission.type === PAGE_PERMISSION_TYPE.GROUP) {
return {
...permission,
groups: permission.users,
users: undefined,
};
}
return permission;
});
}, manager || this.manager);
}
async createComponentPermissions(
componentId: string,
type: PAGE_PERMISSION_TYPE,
manager?: EntityManager
): Promise<ComponentPermission> {
return dbTransactionWrap(async (manager: EntityManager) => {
const existingPermission = await manager.findOne(ComponentPermission, { where: { componentId } });
if (existingPermission) {
throw new Error(`Component permission already exists for Component id: ${componentId}`);
}
const componentPermission = manager.create(ComponentPermission, {
componentId,
type,
});
return manager.save(componentPermission);
}, manager || this.manager);
}
async deleteComponentPermissions(componentId: string, manager?: EntityManager): Promise<void> {
return dbTransactionWrap(async (manager: EntityManager) => {
await manager.delete(ComponentPermission, { componentId });
}, manager || this.manager);
}
}

View file

@ -0,0 +1,83 @@
import { ComponentUser } from '@entities/component_users.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { dbTransactionWrap } from '@helpers/database.helper';
import { ComponentPermission } from '@entities/component_permissions.entity';
@Injectable()
export class ComponentUsersRepository extends Repository<ComponentUser> {
constructor(private dataSource: DataSource) {
super(ComponentUser, dataSource.createEntityManager());
}
async createComponentUsersWithSingle(
componentPermissionsId: string,
users: string[],
manager?: EntityManager
): Promise<ComponentUser[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const componentUsers = users.map((userId) => {
return manager.create(ComponentUser, {
componentPermissionsId,
userId,
permissionGroupsId: null,
});
});
return manager.save(componentUsers);
}, manager || this.manager);
}
async createComponentUsersWithGroup(
componentPermissionsId: string,
groups: string[],
manager?: EntityManager
): Promise<ComponentUser[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const componentUsers = groups.map((permissionGroupsId) => {
return manager.create(ComponentUser, {
componentPermissionsId,
permissionGroupsId,
userId: null,
});
});
return manager.save(componentUsers);
}, manager || this.manager);
}
async checkComponentUserWithGroup(
componentPermission: ComponentPermission,
userId: string,
manager?: EntityManager
): Promise<boolean> {
return dbTransactionWrap(async (manager: EntityManager) => {
const result = await manager
.createQueryBuilder(ComponentUser, 'component_users')
.innerJoin('component_users.permissionGroup', 'group')
.innerJoin('group.groupUsers', 'groupUser')
.where('component_users.componentPermission = :permissionId', {
permissionId: componentPermission.id,
})
.andWhere('groupUser.userId = :userId', { userId })
.getOne();
return !!result;
}, manager || this.manager);
}
async checkComponentUserWithSingle(
componentPermission: ComponentPermission,
userId: string,
manager?: EntityManager
): Promise<boolean> {
return dbTransactionWrap(async (manager: EntityManager) => {
const componentUser = await manager.findOne(ComponentUser, {
where: {
componentPermission: { id: componentPermission.id },
userId,
},
});
return !!componentUser;
}, manager || this.manager);
}
}

View file

@ -13,6 +13,10 @@ interface Features {
[FEATURE_KEY.CREATE_QUERY_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.UPDATE_QUERY_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.DELETE_QUERY_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.FETCH_COMPONENT_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.CREATE_COMPONENT_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.UPDATE_COMPONENT_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.DELETE_COMPONENT_PERMISSIONS]: FeatureConfig;
}
export interface FeaturesConfig {

View file

@ -31,4 +31,12 @@ export class AppPermissionsUtilService implements IUtilService {
async updateQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
async createComponentPermission(componentId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
async updateComponentPermission(componentId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
}

View file

@ -4,7 +4,7 @@ import { EventHandler } from 'src/entities/event_handler.entity';
import { CreatePageDto, UpdatePageDto } from '@modules/apps/dto/page';
export interface IPageService {
findPagesForVersion(appVersionId: string): Promise<Page[]>;
findPagesForVersion(appVersionId: string, mode?: string): Promise<Page[]>;
findOne(id: string): Promise<Page>;
createPage(page: CreatePageDto, appVersionId: string): Promise<Page>;
clonePage(pageId: string, appVersionId: string): Promise<{ pages: Page[]; events: EventHandler[] }>;

View file

@ -41,6 +41,8 @@ import { PageUser } from '@entities/page_users.entity';
import { UsersUtilService } from '@modules/users/util.service';
import { QueryPermission } from '@entities/query_permissions.entity';
import { QueryUser } from '@entities/query_users.entity';
import { ComponentPermission } from '@entities/component_permissions.entity';
import { ComponentUser } from '@entities/component_users.entity';
interface AppResourceMappings {
defaultDataSourceIdMapping: Record<string, string>;
dataQueryMapping: Record<string, string>;
@ -238,6 +240,9 @@ export class AppImportExportService {
? await manager
.createQueryBuilder(Component, 'components')
.leftJoinAndSelect('components.layouts', 'layouts')
.leftJoinAndSelect('components.permissions', 'permission')
.leftJoinAndSelect('permission.users', 'componentUser')
.leftJoinAndSelect('componentUser.permissionGroup', 'permissionGroup')
.where('components.pageId IN(:...pageId)', {
pageId: pages.map((v) => v.id),
})
@ -245,6 +250,21 @@ export class AppImportExportService {
.getMany()
: [];
const componentsWithPermissionGroups = components.map((component) => {
const groupPermission = component.permissions.find((perm) => perm.type === 'GROUP');
return {
...component,
permissions: groupPermission
? {
permissionGroup: groupPermission.users
.map((user) => user.permissionGroup?.name)
.filter((name): name is string => Boolean(name)),
}
: undefined,
};
});
const events = await manager
.createQueryBuilder(EventHandler, 'event_handlers')
.where('event_handlers.appVersionId IN(:...versionId)', {
@ -253,7 +273,7 @@ export class AppImportExportService {
.orderBy('event_handlers.created_at', 'ASC')
.getMany();
appToExport['components'] = components;
appToExport['components'] = componentsWithPermissionGroups;
appToExport['pages'] = pagesWithPermissionGroups;
appToExport['events'] = events;
appToExport['dataQueries'] = queriesWithPermissionGroups;
@ -953,6 +973,13 @@ export class AppImportExportService {
await manager.save(newLayout);
}));
if (component.permissions) {
savedComponent.permissions = component.permissions;
}
//create component permissions of component if flag enabled in dto
await this.createComponentPermissionsForGroups(savedComponent, user.organizationId, manager);
const componentEvents = importingEvents.filter((event) => event.sourceId === component.id);
if (componentEvents.length > 0) {
@ -1383,7 +1410,7 @@ export class AppImportExportService {
return pageSettings;
}
async checkIfGroupPermissionsExist(pages, queries, organizationId) {
async checkIfGroupPermissionsExist(pages, queries, components, organizationId) {
const allGroupNames = new Set<string>();
for (const page of pages) {
@ -1402,6 +1429,15 @@ export class AppImportExportService {
}
}
for (const component of components) {
const groupNames = component.permissions?.permissionGroup || [];
for (const name of groupNames) {
if (!allGroupNames.has(name)) {
allGroupNames.add(name);
}
}
}
if (!allGroupNames.size) return;
return await dbTransactionWrap(async (manager: EntityManager) => {
@ -1497,6 +1533,41 @@ export class AppImportExportService {
await manager.save(queryUsers);
}
async createComponentPermissionsForGroups(component, organizationId: string, manager: EntityManager) {
const groupNames = component.permissions?.permissionGroup || [];
if (!groupNames.length) return;
const existingGroups = await manager
.createQueryBuilder(GroupPermissions, 'gp')
.where('gp.name IN (:...names)', { names: groupNames })
.andWhere('gp.organizationId = :organizationId', { organizationId })
.getMany();
const groupMap = new Map(existingGroups.map((g) => [g.name, g]));
// Filter to only existing group names
const validGroupNames = groupNames.filter((name) => groupMap.has(name));
// If no valid group names exist, do not create permissions
if (!validGroupNames.length) return;
const permission = manager.create(ComponentPermission, {
componentId: component.id,
type: PAGE_PERMISSION_TYPE.GROUP,
});
const savedPermission = await manager.save(permission);
const componentUsers = validGroupNames.map((name) =>
manager.create(ComponentUser, {
componentPermissionsId: savedPermission.id,
permissionGroupsId: groupMap.get(name).id,
})
);
await manager.save(componentUsers);
}
async createAppVersionsForImportedApp(
manager: EntityManager,
user: User,

View file

@ -23,7 +23,7 @@ export class PageService implements IPageService {
protected eventHandlerService: EventsService
) {}
async findPagesForVersion(appVersionId: string): Promise<Page[]> {
async findPagesForVersion(appVersionId: string, mode?: string): Promise<Page[]> {
// const allPages = await this.pageRepository.find({ where: { appVersionId }, order: { index: 'ASC' } });
const allPages = await this.pageHelperService.fetchPages(appVersionId);
const pagesWithComponents = await Promise.all(

View file

@ -79,8 +79,14 @@ export class ImportExportResourcesService {
appParams = { ...appParams.appV2 };
const pages = appParams?.pages;
const queries = appParams?.dataQueries;
(pages?.length || queries?.length) &&
(await this.appImportExportService.checkIfGroupPermissionsExist(pages, queries, user.organizationId));
const components = appParams?.components;
(pages?.length || queries?.length || components?.length) &&
(await this.appImportExportService.checkIfGroupPermissionsExist(
pages,
queries,
components,
user.organizationId
));
}
}
}

View file

@ -1,4 +1,4 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Put, Query, UseGuards } from '@nestjs/common';
import { VersionService } from './service';
import { InitModule } from '@modules/app/decorators/init-module';
import { MODULES } from '@modules/app/constants/modules';
@ -26,8 +26,8 @@ export class VersionControllerV2 implements IVersionControllerV2 {
@InitFeature(FEATURE_KEY.GET_ONE)
@UseGuards(JwtAuthGuard, ValidAppGuard, FeatureAbilityGuard)
@Get(':id/versions/:versionId')
getVersion(@User() user: UserEntity, @App() app: AppEntity) {
return this.versionService.getVersion(app, user);
getVersion(@User() user: UserEntity, @App() app: AppEntity, @Query('mode') mode?: string) {
return this.versionService.getVersion(app, user, mode);
}
@InitFeature(FEATURE_KEY.UPDATE)

View file

@ -4,7 +4,7 @@ import { App as AppEntity } from '@entities/app.entity';
import { PromoteVersionDto } from '../dto';
export interface IVersionControllerV2 {
getVersion(user: UserEntity, app: AppEntity): Promise<any>;
getVersion(user: UserEntity, app: AppEntity, mode?: string): Promise<any>;
updateVersion(user: UserEntity, app: AppEntity, appVersionUpdateDto: AppVersionUpdateDto): Promise<any>;
updateGlobalSettings(user: UserEntity, app: AppEntity, appVersionUpdateDto: AppVersionUpdateDto): Promise<any>;
promoteVersion(user: UserEntity, app: AppEntity, promoteVersionDto: PromoteVersionDto): Promise<any>;

View file

@ -11,7 +11,7 @@ export interface IVersionService {
deleteVersion(app: App, user: User): Promise<void>;
getVersion(app: App, user: User): Promise<any>;
getVersion(app: App, user: User, mode?: string): Promise<any>;
update(app: App, user: User, appVersionUpdateDto: AppVersionUpdateDto): Promise<void>;

View file

@ -147,6 +147,34 @@ export class VersionRepository extends Repository<AppVersion> {
}, manager || this.manager);
}
async findVersionWithQueryPermissions(id: string, manager?: EntityManager): Promise<AppVersion> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const appVersion = await manager
.createQueryBuilder(AppVersion, 'appVersion')
.where('appVersion.id = :id', { id })
.leftJoinAndSelect('appVersion.app', 'app')
.leftJoinAndSelect('appVersion.dataQueries', 'dataQueries')
.leftJoinAndSelect('dataQueries.dataSource', 'dataSource')
.leftJoinAndSelect('dataQueries.plugins', 'plugins')
.leftJoinAndSelect('plugins.manifestFile', 'manifestFile')
.leftJoinAndSelect('dataQueries.permissions', 'permission')
.leftJoinAndSelect('permission.users', 'queryUser')
.leftJoinAndSelect('queryUser.user', 'user')
.leftJoinAndSelect('queryUser.permissionGroup', 'group')
.getOneOrFail();
if (appVersion?.dataQueries) {
for (const query of appVersion?.dataQueries) {
if (query?.plugin) {
query.plugin.manifestFile.data = JSON.parse(decode(query.plugin.manifestFile.data.toString('utf8')));
}
}
}
return appVersion;
}, manager || this.manager);
}
getVersionsInApp(appId: string, manager?: EntityManager): Promise<AppVersion[]> {
return dbTransactionWrap((manager: EntityManager) => {
return manager.find(AppVersion, {

View file

@ -94,7 +94,7 @@ export class VersionService implements IVersionService {
return await this.versionsUtilService.deleteVersion(app, user, manager);
}
async getVersion(app: App, user: User): Promise<any> {
async getVersion(app: App, user: User, mode?: string): Promise<any> {
const prepareResponse = async (app: App, versionId: string) => {
let appVersion,
updatedVersionId = versionId;