mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-23 00:48:25 +00:00
Merge branch 'release/v0.8.1' into main
This commit is contained in:
commit
71c313d585
82 changed files with 1225 additions and 479 deletions
2
.version
2
.version
|
|
@ -1 +1 @@
|
|||
0.8.0
|
||||
0.8.1
|
||||
|
|
@ -63,10 +63,10 @@ Documentation is available at https://docs.tooljet.io.
|
|||
- [Widget Reference](https://docs.tooljet.io/docs/widgets/button)
|
||||
|
||||
## Branching model
|
||||
We use git-flow branching model. The base branch is `develop`. If you are looking for a stable version, please use the main branch or tags labeled as v1.x.x.
|
||||
We use the git-flow branching model. The base branch is `develop`. If you are looking for a stable version, please use the main branch or tags labeled as v1.x.x.
|
||||
|
||||
## Contributing
|
||||
Kindly read our [Contributing Guide](CONTRIBUTING.md) to learn and understand about our development process, how to propose bugfixes and improvements, and how to build and test your changes to ToolJet. <br>
|
||||
Kindly read our [Contributing Guide](CONTRIBUTING.md) to learn and understand about our development process, how to propose bug fixes and improvements, and how to build and test your changes to ToolJet. <br>
|
||||
|
||||
## Contributors
|
||||
<a href="https://github.com/tooljet/tooljet/graphs/contributors">
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ sudo openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
|
|||
# Setup nginx config
|
||||
export SERVER_HOST="${SERVER_HOST:=localhost}"
|
||||
export SERVER_USER="${SERVER_USER:=www-data}"
|
||||
VARS_TO_SUBSTITUTE="$SERVER_HOST:$SERVER_USER"
|
||||
VARS_TO_SUBSTITUTE='$SERVER_HOST:$SERVER_USER'
|
||||
envsubst "${VARS_TO_SUBSTITUTE}" < /tmp/nginx.conf > /tmp/nginx-substituted.conf
|
||||
sudo cp /tmp/nginx-substituted.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||
|
||||
|
|
|
|||
5
docs/docs/contributing-guide/tutorials/_category_.json
Normal file
5
docs/docs/contributing-guide/tutorials/_category_.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"label": "Tutorials",
|
||||
"position": 2,
|
||||
"collapsed": true
|
||||
}
|
||||
25
docs/docs/contributing-guide/tutorials/create-widget.md
Normal file
25
docs/docs/contributing-guide/tutorials/create-widget.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Creating Widgets
|
||||
These are some of the most useful properties and functions passed to the widget
|
||||
|
||||
### properties
|
||||
|
||||
The `properties` object will contain the configurable properties of a widget, initially obtained from its definition on `components.js`.
|
||||
The values inside `properties` changes whenever the developer changes it from the inspector panel of ToolJet editor.
|
||||
|
||||
### exposedVariables
|
||||
|
||||
The `exposedVariables` object will contain the values of all exposed variables as configured in `components.js`.
|
||||
|
||||
### setExposedVariable('exposedVariableName', newValue)
|
||||
|
||||
This function allows you to update the value of an exposed variable to `newValue`.
|
||||
|
||||
### validate(value)
|
||||
|
||||
This function validates the `value` passed based on the validation settings configured on the inspector panel for the widget.
|
||||
It returns an array `[isValid, validationError]`, which represents respectively, whether the `value` passed is valid,
|
||||
and the error message if there is one.
|
||||
|
|
@ -129,3 +129,13 @@ This is required when client is built separately.
|
|||
| variable | description |
|
||||
| ------------------ | ----------------------------------------------------------- |
|
||||
| TOOLJET_SERVER_URL | the URL of ToolJet server ( eg: https://server.tooljet.io ) |
|
||||
|
||||
|
||||
#### Asset path ( optionally required )
|
||||
|
||||
This is required when the assets for the client are to be loaded from elsewhere (eg: CDN).
|
||||
This can be an absolute path, or relative to main HTML file.
|
||||
|
||||
| variable | description |
|
||||
| ------------------ | ----------------------------------------------------------- |
|
||||
| ASSET_PATH | the asset path for the website ( eg: https://app.tooljet.io/) |
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Date format should be followed as ISO 8601 as mentioned in the [moment documenta
|
|||
| ----------- | ----------- |
|
||||
| Format | The format of the date selected by the date picker |
|
||||
| Enable time selection | Allows to select time if enabled. Time selection is disabled by default |
|
||||
| Enable date selection | Allos to select date if enabled. Date selection is enabled by default |
|
||||
| Enable date selection | Allows to select date if enabled. Date selection is enabled by default |
|
||||
|
||||
|
||||
<img class="screenshot-full" src="/img/widgets/datepicker/datepicker-format.gif" alt="ToolJet - Widget Reference - Datepicker" height="420"/>
|
||||
19
docs/docs/widgets/divider.md
Normal file
19
docs/docs/widgets/divider.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
sidebar_position: 20
|
||||
---
|
||||
|
||||
# Divider
|
||||
|
||||
Divider widget is used to add separator between components.
|
||||
|
||||
<img class="screenshot-full" src="/img/widgets/divider/divider.gif" alt="ToolJet - Widget Reference - Divider" height="420"/>
|
||||
|
||||
|
||||
#### Properties
|
||||
|
||||
| properties | description |
|
||||
| ----------- | ----------- |
|
||||
| Divider Color | It is used to set the color of the divider. Use hex code to set the background color. |
|
||||
| Visibility | This property is used to set the visibility of the divider. The property accepts Boolean value. |
|
||||
| Show on Desktop | This property have toggle switch. If enabled, the divider will display in the desktop view else it will not appear. |
|
||||
| Show on Mobile | This property have toggle switch. If enabled, the divider will display in the mobile view else it will not appear. |
|
||||
|
|
@ -20,7 +20,7 @@ This event is triggered when an star is clicked.
|
|||
| Label | The text to be used as the label for the star rating. |
|
||||
| Number of stars | Initial number of stars in the list on initial load. `default: 5`|
|
||||
| Default no of selected stars | This property specifies the default count of stars that are selected on the initial load. `default: 5` (integer)|
|
||||
| Enable half star | Allos selection of half stars if enabled. `default: false` (bool)|
|
||||
| Enable half star | Allows selection of half stars if enabled. `default: false` (bool)|
|
||||
| Tooltips |This is used for displaying informative tooltips on each star, and it is mapped to the index of the star. `default: []` (array of strings ) |
|
||||
| Star Color | Display color of the star. `default: #ffb400` (color hex) |
|
||||
|
||||
|
|
|
|||
|
|
@ -137,3 +137,9 @@ If the data of a cell is changed, "save changes" button will be shown at the bot
|
|||
| Show search box | It can be used to show or hide Table Search box. |
|
||||
| Show download button | Show or hide download button at the Table footer. |
|
||||
| Show filter button | Show or hide filter button at the Table footer. |
|
||||
|
||||
#### Styles
|
||||
|
||||
| Style | Description |
|
||||
| ----------- | ----------- |
|
||||
| Cell size | This decides the size of table cells. You can choose between a `Compact` size for table cells or a `Spacious` size |
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
module.exports = {
|
||||
title: 'ToolJet - Documentation',
|
||||
tagline: 'Build and deploy internal tools.',
|
||||
url: 'https://tooljet.io',
|
||||
url: 'https://docs.tooljet.io',
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'ignore',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
|
|
|||
BIN
docs/static/img/datasource-reference/mo-connect.png
vendored
BIN
docs/static/img/datasource-reference/mo-connect.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 136 KiB |
BIN
docs/static/img/datasource-reference/pg-connect.png
vendored
BIN
docs/static/img/datasource-reference/pg-connect.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 134 KiB |
BIN
docs/static/img/widgets/divider/divider.gif
vendored
Normal file
BIN
docs/static/img/widgets/divider/divider.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
9
frontend/assets/images/icons/widgets/passwordInput.svg
Normal file
9
frontend/assets/images/icons/widgets/passwordInput.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-forms" width="24" height="24" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 3a3 3 0 0 0 -3 3v12a3 3 0 0 0 3 3"></path>
|
||||
<path d="M6 3a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3"></path>
|
||||
<path d="M13 7h7a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-7"></path>
|
||||
<path d="M5 7h-1a1 1 0 0 0 -1 1v8a1 1 0 0 0 1 1h1"></path>
|
||||
<path d="M17 12h.01"></path>
|
||||
<path d="M13 12h.01"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
|
|
@ -3,7 +3,7 @@ set -eu
|
|||
|
||||
export SERVER_HOST="${SERVER_HOST:=server}"
|
||||
export SERVER_USER="${SERVER_USER:=root}"
|
||||
VARS_TO_SUBSTITUTE="$SERVER_HOST:$SERVER_USER"
|
||||
VARS_TO_SUBSTITUTE='$SERVER_HOST:$SERVER_USER'
|
||||
|
||||
envsubst "${VARS_TO_SUBSTITUTE}" < /etc/openresty/nginx.conf.template > /etc/openresty/nginx.conf
|
||||
|
||||
|
|
|
|||
|
|
@ -37,4 +37,12 @@ export const ActionTypes = [
|
|||
id: 'copy-to-clipboard',
|
||||
options: [{ name: 'copy-to-clipboard', type: 'text', default: '' }],
|
||||
},
|
||||
{
|
||||
name: 'Set local storage',
|
||||
id: 'set-localstorage-value',
|
||||
options: [
|
||||
{ name: 'key', type: 'code', default: '' },
|
||||
{ name: 'value', type: 'code', default: '' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,9 +21,12 @@ import { ToggleSwitch } from './Components/Toggle';
|
|||
import { RadioButton } from './Components/RadioButton';
|
||||
import { StarRating } from './Components/StarRating';
|
||||
import { Divider } from './Components/Divider';
|
||||
import { PasswordInput } from './Components/PasswordInput';
|
||||
import { renderTooltip } from '../_helpers/appUtils';
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
||||
import '@/_styles/custom.scss';
|
||||
import { resolveProperties, resolveStyles } from './component-properties-resolution';
|
||||
import { validateWidget } from '@/_helpers/utils';
|
||||
|
||||
const AllComponents = {
|
||||
Button,
|
||||
|
|
@ -48,6 +51,7 @@ const AllComponents = {
|
|||
RadioButton,
|
||||
StarRating,
|
||||
Divider,
|
||||
PasswordInput,
|
||||
};
|
||||
|
||||
export const Box = function Box({
|
||||
|
|
@ -83,6 +87,16 @@ export const Box = function Box({
|
|||
}
|
||||
|
||||
const ComponentToRender = AllComponents[component.component];
|
||||
const resolvedProperties = resolveProperties(component, currentState);
|
||||
const resolvedStyles = resolveStyles(component, currentState);
|
||||
const exposedVariables = currentState?.components[component.name] ?? {};
|
||||
|
||||
const fireEvent = (eventName, options) => onEvent(eventName, { ...options, component });
|
||||
const validate = (value) =>
|
||||
validateWidget({
|
||||
...{ widgetValue: value },
|
||||
...{ validationObject: component.definition.validation, currentState },
|
||||
});
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
|
|
@ -108,6 +122,12 @@ export const Box = function Box({
|
|||
containerProps={containerProps}
|
||||
darkMode={darkMode}
|
||||
removeComponent={removeComponent}
|
||||
properties={resolvedProperties}
|
||||
exposedVariables={exposedVariables}
|
||||
styles={resolvedStyles}
|
||||
setExposedVariable={(variable, value) => onComponentOptionChanged(component, variable, value)}
|
||||
fireEvent={fireEvent}
|
||||
validate={validate}
|
||||
></ComponentToRender>
|
||||
) : (
|
||||
<div className="m-1" style={{ height: '100%' }}>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||
import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
|
||||
var tinycolor = require('tinycolor2');
|
||||
|
||||
export const Button = function Button({ id, width, height, component, onComponentClick, currentState }) {
|
||||
export const Button = function Button({ width, height, component, currentState, fireEvent }) {
|
||||
const [loadingState, setLoadingState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -17,11 +17,13 @@ export const Button = function Button({ id, width, height, component, onComponen
|
|||
const text = component.definition.properties.text.value;
|
||||
const backgroundColor = component.definition.styles.backgroundColor.value;
|
||||
const color = component.definition.styles.textColor.value;
|
||||
const borderRadius = component.definition.styles.borderRadius?.value ?? 3; // using 2 for backward compatibility
|
||||
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
|
||||
const disabledState = component.definition.styles?.disabledState?.value ?? false;
|
||||
|
||||
const parsedDisabledState =
|
||||
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
|
||||
const parsedBorderRadius = typeof borderRadius !== 'number' ? resolveWidgetFieldValue(borderRadius, currentState) : borderRadius;
|
||||
let parsedWidgetVisibility = widgetVisibility;
|
||||
|
||||
try {
|
||||
|
|
@ -33,6 +35,7 @@ export const Button = function Button({ id, width, height, component, onComponen
|
|||
const computedStyles = {
|
||||
backgroundColor,
|
||||
color,
|
||||
borderRadius: `${parsedBorderRadius}px`,
|
||||
width,
|
||||
height,
|
||||
display: parsedWidgetVisibility ? '' : 'none',
|
||||
|
|
@ -46,7 +49,7 @@ export const Button = function Button({ id, width, height, component, onComponen
|
|||
style={computedStyles}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onComponentClick(id, component);
|
||||
fireEvent('onClick');
|
||||
}}
|
||||
>
|
||||
{resolveReferences(text, currentState)}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export const Datepicker = function Datepicker({
|
|||
}
|
||||
|
||||
function onDateChange(event) {
|
||||
const value = event._isAMomentObject ? event.format(dateFormat.value) : event;
|
||||
const selectedDateFormat = enableTime ? `${dateFormat.value} LT` : dateFormat.value;
|
||||
const value = event._isAMomentObject ? event.format(selectedDateFormat) : event;
|
||||
|
||||
setDateText(value);
|
||||
onComponentOptionChanged(component, 'value', value);
|
||||
}
|
||||
|
|
@ -99,7 +101,7 @@ export const Datepicker = function Datepicker({
|
|||
<input
|
||||
{...props}
|
||||
value={dateText}
|
||||
className={`input-field form-control ${!isValid ? 'is-invalid' : ''} validation-without-icon`}
|
||||
className={`input-field form-control ${!isValid ? 'is-invalid' : ''} validation-without-icon px-2`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,16 @@ class StyleButton extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
const BLOCK_TYPES = [
|
||||
const HEADINGS = [
|
||||
{ label: 'H1', style: 'header-one' },
|
||||
{ label: 'H2', style: 'header-two' },
|
||||
{ label: 'H3', style: 'header-three' },
|
||||
{ label: 'H4', style: 'header-four' },
|
||||
{ label: 'H5', style: 'header-five' },
|
||||
{ label: 'H6', style: 'header-six' },
|
||||
]
|
||||
|
||||
const BLOCK_TYPES = [
|
||||
{
|
||||
label: <img src="/assets/images/icons/rich-text-editor/blockquote.svg" style={{ height: '16px' }} />,
|
||||
style: 'blockquote',
|
||||
|
|
@ -78,6 +81,26 @@ const BlockStyleControls = (props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="dropmenu">
|
||||
<button className="dropdownbtn px-2" type="button">
|
||||
Heading
|
||||
</button>
|
||||
<div className="dropdown-content bg-white">
|
||||
{
|
||||
HEADINGS.map((type) => (
|
||||
<a className="dropitem m-0 p-0" href="#" key={type.label}>
|
||||
<StyleButton
|
||||
key={type.label}
|
||||
active={type.style === blockType}
|
||||
label={type.label}
|
||||
onToggle={props.onToggle}
|
||||
style={type.style}
|
||||
/>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{BLOCK_TYPES.map((type) => (
|
||||
<StyleButton
|
||||
key={type.label}
|
||||
|
|
@ -201,7 +224,7 @@ class DraftEditor extends React.Component {
|
|||
<BlockStyleControls editorState={editorState} onToggle={this.toggleBlockType} />
|
||||
<InlineStyleControls editorState={editorState} onToggle={this.toggleInlineStyle} />
|
||||
</div>
|
||||
<div className={className} onClick={this.focus}>
|
||||
<div className={className} style={{height: `${this.props.height-60}px`}} onClick={this.focus}>
|
||||
<Editor
|
||||
blockStyleFn={getBlockStyle}
|
||||
customStyleMap={styleMap}
|
||||
|
|
|
|||
|
|
@ -100,12 +100,12 @@ export const DropDown = function DropDown({
|
|||
onComponentClick(id, component);
|
||||
}}
|
||||
>
|
||||
<div className="col-auto">
|
||||
<div className="col-auto my-auto">
|
||||
<label style={{ marginRight: label !== '' ? '1rem' : '0.001rem' }} className="form-label py-1">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col px-0">
|
||||
<div className="col px-0 h-100">
|
||||
<SelectSearch
|
||||
disabled={parsedDisabledState}
|
||||
options={selectOptions}
|
||||
|
|
|
|||
|
|
@ -61,12 +61,12 @@ export const Multiselect = function Multiselect({
|
|||
onComponentClick(id, component);
|
||||
}}
|
||||
>
|
||||
<div className="col-auto">
|
||||
<div className="col-auto my-auto">
|
||||
<label style={{ marginRight: '1rem' }} className="form-label py-1">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col px-0">
|
||||
<div className="col px-0 h-100">
|
||||
<SelectSearch
|
||||
disabled={parsedDisabledState}
|
||||
options={selectOptions}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const NumberInput = function NumberInput({
|
|||
onComponentOptionChanged(component, 'value', parseInt(e.target.value));
|
||||
}}
|
||||
type="number"
|
||||
className="form-control"
|
||||
className="form-control rounded-0"
|
||||
placeholder={placeholder}
|
||||
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
|
||||
value={number}
|
||||
|
|
|
|||
59
frontend/src/Editor/Components/PasswordInput.jsx
Normal file
59
frontend/src/Editor/Components/PasswordInput.jsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React, { useState } from 'react';
|
||||
import { resolveWidgetFieldValue } from '@/_helpers/utils';
|
||||
|
||||
export const PasswordInput = ({
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
component,
|
||||
onComponentClick,
|
||||
currentState,
|
||||
onComponentOptionChanged,
|
||||
validate,
|
||||
}) => {
|
||||
const value = currentState?.components[component?.name]?.value;
|
||||
const [text, setText] = useState(() => value ?? '');
|
||||
|
||||
const placeholder = component.definition.properties.placeholder.value;
|
||||
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
|
||||
const disabledState = component.definition.styles?.disabledState?.value ?? false;
|
||||
|
||||
const parsedDisabledState =
|
||||
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
|
||||
|
||||
const parsedWidgetVisibility =
|
||||
typeof widgetVisibility !== 'boolean' ? resolveWidgetFieldValue(widgetVisibility, currentState) : widgetVisibility;
|
||||
|
||||
const currentValidState = currentState?.components[component?.name]?.isValid;
|
||||
|
||||
const validationData = validate(value);
|
||||
|
||||
const { isValid, validationError } = validationData;
|
||||
|
||||
if (currentValidState !== isValid) {
|
||||
onComponentOptionChanged(component, 'isValid', isValid);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
disabled={parsedDisabledState}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onComponentClick(id, component);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
onComponentOptionChanged(component, 'value', e.target.value);
|
||||
}}
|
||||
type={'password'}
|
||||
className={`form-control ${!isValid ? 'is-invalid' : ''} validation-without-icon rounded-0`}
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
|
||||
/>
|
||||
|
||||
<div className="invalid-feedback">{validationError}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,7 +21,10 @@ export const Pagination = function Pagination({
|
|||
setPageCount(lastActivePageIndex);
|
||||
} else if (serverSide || lastActivePageIndex === 0) {
|
||||
setPageIndex(1);
|
||||
} else {
|
||||
gotoPage(lastActivePageIndex + 1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverSide, lastActivePageIndex]);
|
||||
|
||||
function gotoPage(page) {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export function Table({
|
|||
let tableType = tableTypeProperty ? tableTypeProperty.value : 'table-bordered';
|
||||
tableType = tableType === '' ? 'table-bordered' : tableType;
|
||||
|
||||
const cellSizeType = component.definition.styles.cellSize?.value;
|
||||
|
||||
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
|
||||
const disabledState = component.definition.styles?.disabledState?.value ?? false;
|
||||
|
||||
|
|
@ -148,7 +150,7 @@ export function Table({
|
|||
let newFilters = filters;
|
||||
newFilters.splice(index, 1);
|
||||
setFilters(newFilters);
|
||||
setAllFilters(newFilters);
|
||||
setAllFilters(newFilters.filter((filter) => filter.id !== ''));
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
|
|
@ -754,6 +756,10 @@ export function Table({
|
|||
}
|
||||
}, [state.columnResizing.isResizingColumn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageCount <= pageIndex) gotoPage(pageCount - 1);
|
||||
}, [pageCount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-disabled={parsedDisabledState}
|
||||
|
|
@ -840,8 +846,8 @@ export function Table({
|
|||
undefined
|
||||
) {
|
||||
console.log('componentState.changeSet', componentState.changeSet);
|
||||
cellProps.style.backgroundColor = '#ffffde';
|
||||
cellProps.style['--tblr-table-accent-bg'] = '#ffffde';
|
||||
cellProps.style.backgroundColor = darkMode ? '#1c252f' : '#ffffde';
|
||||
cellProps.style['--tblr-table-accent-bg'] = darkMode ? '#1c252f' : '#ffffde';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -857,6 +863,7 @@ export function Table({
|
|||
'has-multiselect': cell.column.columnType === 'multiselect',
|
||||
'has-datepicker': cell.column.columnType === 'datepicker',
|
||||
'align-items-center flex-column': cell.column.columnType === 'selector',
|
||||
[cellSizeType]: true,
|
||||
})}
|
||||
{...cellProps}
|
||||
>
|
||||
|
|
@ -888,7 +895,7 @@ export function Table({
|
|||
<div className="col">
|
||||
{(clientSidePagination || serverSidePagination) && (
|
||||
<Pagination
|
||||
lastActivePageIndex={currentState.components[component.name]?.pageIndex ?? 1}
|
||||
lastActivePageIndex={pageIndex}
|
||||
serverSide={serverSidePagination}
|
||||
autoGotoPage={gotoPage}
|
||||
autoCanNextPage={canNextPage}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,22 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
|
||||
|
||||
export const TextArea = function TextArea({
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
component,
|
||||
onComponentClick,
|
||||
currentState,
|
||||
onComponentOptionChanged,
|
||||
}) {
|
||||
const value = component.definition.properties.value ? component.definition.properties.value.value : '';
|
||||
const [text, setText] = useState(value);
|
||||
|
||||
const textProperty = component.definition.properties.value;
|
||||
let newText = value;
|
||||
if (textProperty && currentState) {
|
||||
newText = resolveReferences(textProperty.value, currentState, '');
|
||||
}
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export const TextArea = function TextArea({ width, height, properties, exposedVariables, styles, setExposedVariable }) {
|
||||
useEffect(() => {
|
||||
setText(newText);
|
||||
onComponentOptionChanged(component, 'value', newText);
|
||||
setExposedVariable('value', properties.value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newText]);
|
||||
|
||||
const placeholder = component.definition.properties.placeholder.value;
|
||||
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
|
||||
const disabledState = component.definition.styles?.disabledState?.value ?? false;
|
||||
|
||||
const parsedDisabledState =
|
||||
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
|
||||
|
||||
let parsedWidgetVisibility = widgetVisibility;
|
||||
|
||||
try {
|
||||
parsedWidgetVisibility = resolveReferences(parsedWidgetVisibility, currentState, []);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}, [properties.value]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
disabled={parsedDisabledState}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onComponentClick(id, component);
|
||||
}}
|
||||
disabled={styles.disabledState}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
onComponentOptionChanged(component, 'value', e.target.value);
|
||||
setExposedVariable('value', e.target.value);
|
||||
}}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={placeholder}
|
||||
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
|
||||
value={text}
|
||||
placeholder={properties.placeholder}
|
||||
style={{ width, height, resize:'none', display: styles.visibility ? '' : 'none' }}
|
||||
value={exposedVariables.value}
|
||||
></textarea>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { resolveReferences, resolveWidgetFieldValue, validateWidget } from '@/_helpers/utils';
|
||||
import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
|
||||
|
||||
export const TextInput = function TextInput({
|
||||
id,
|
||||
|
|
@ -9,6 +9,7 @@ export const TextInput = function TextInput({
|
|||
onComponentClick,
|
||||
currentState,
|
||||
onComponentOptionChanged,
|
||||
validate,
|
||||
}) {
|
||||
const placeholder = component.definition.properties.placeholder.value;
|
||||
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
|
||||
|
|
@ -35,11 +36,7 @@ export const TextInput = function TextInput({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newText]);
|
||||
|
||||
const validationData = validateWidget({
|
||||
validationObject: component.definition.validation,
|
||||
widgetValue: value,
|
||||
currentState,
|
||||
});
|
||||
const validationData = validate(value);
|
||||
|
||||
const { isValid, validationError } = validationData;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ export const ToggleSwitch = ({
|
|||
const toggleSwitchColorProperty = component.definition.styles.toggleSwitchColor;
|
||||
const toggleSwitchColor = toggleSwitchColorProperty ? toggleSwitchColorProperty.value : '#3c92dc';
|
||||
const textColor = textColorProperty ? textColorProperty.value : '#000';
|
||||
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
|
||||
const disabledState = component.definition.styles?.disabledState?.value ?? false;
|
||||
|
||||
const widgetVisibility = component.definition.styles.visibility?.value ?? true;
|
||||
const disabledState = component.definition.styles.disabledState?.value ?? false;
|
||||
|
||||
const parsedDisabledState =
|
||||
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ export const componentTypes = [
|
|||
{ name: 'Striped & bordered', value: 'table-striped table-bordered' },
|
||||
],
|
||||
},
|
||||
cellSize: {
|
||||
type: 'select',
|
||||
displayName: 'Cell size',
|
||||
options: [
|
||||
{ name: 'Compact', value: 'compact' },
|
||||
{ name: 'Spacious', value: 'spacious' },
|
||||
],
|
||||
},
|
||||
visibility: { type: 'code', displayName: 'Visibility' },
|
||||
disabledState: { type: 'code', displayName: 'Disable' },
|
||||
},
|
||||
|
|
@ -93,6 +101,7 @@ export const componentTypes = [
|
|||
textColor: { value: undefined },
|
||||
visibility: { value: '{{true}}' },
|
||||
disabledState: { value: '{{false}}' },
|
||||
cellSize: { value: 'compact' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -121,6 +130,7 @@ export const componentTypes = [
|
|||
textColor: { type: 'color', displayName: 'Text color' },
|
||||
visibility: { type: 'code', displayName: 'Visibility' },
|
||||
disabledState: { type: 'code', displayName: 'Disable' },
|
||||
borderRadius: { type: 'code', displayName: 'Border radius' },
|
||||
},
|
||||
exposedVariables: {},
|
||||
definition: {
|
||||
|
|
@ -138,6 +148,7 @@ export const componentTypes = [
|
|||
backgroundColor: { value: '#3c92dc' },
|
||||
textColor: { value: '#fff' },
|
||||
visibility: { value: '{{true}}' },
|
||||
borderRadius: { value: '{{0}}' },
|
||||
disabledState: { value: '{{false}}' },
|
||||
},
|
||||
},
|
||||
|
|
@ -346,6 +357,57 @@ export const componentTypes = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PasswordInput',
|
||||
displayName: 'Password Input',
|
||||
description: 'Password input field for forms',
|
||||
component: 'PasswordInput',
|
||||
defaultSize: {
|
||||
width: 210,
|
||||
height: 30,
|
||||
},
|
||||
others: {
|
||||
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
|
||||
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
|
||||
},
|
||||
properties: {
|
||||
placeholder: { type: 'code', displayName: 'Placeholder' },
|
||||
},
|
||||
validation: {
|
||||
regex: { type: 'code', displayName: 'Regex' },
|
||||
minLength: { type: 'code', displayName: 'Min length' },
|
||||
maxLength: { type: 'code', displayName: 'Max length' },
|
||||
customRule: { type: 'code', displayName: 'Custom validation' },
|
||||
},
|
||||
events: {},
|
||||
styles: {
|
||||
visibility: { type: 'code', displayName: 'Visibility' },
|
||||
disabledState: { type: 'code', displayName: 'Disable' },
|
||||
},
|
||||
exposedVariables: {
|
||||
value: '',
|
||||
},
|
||||
definition: {
|
||||
others: {
|
||||
showOnDesktop: { value: true },
|
||||
showOnMobile: { value: false },
|
||||
},
|
||||
properties: {
|
||||
placeholder: { value: 'password' },
|
||||
},
|
||||
validation: {
|
||||
regex: { value: '' },
|
||||
minLength: { value: null },
|
||||
maxLength: { value: null },
|
||||
customRule: { value: null },
|
||||
},
|
||||
events: [],
|
||||
styles: {
|
||||
visibility: { value: '{{true}}' },
|
||||
disabledState: { value: '{{false}}' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Datepicker',
|
||||
displayName: 'Date Picker',
|
||||
|
|
@ -777,7 +839,7 @@ export const componentTypes = [
|
|||
},
|
||||
properties: {
|
||||
label: { value: 'Select' },
|
||||
value: { value: '' },
|
||||
value: { value: '{{2}}' },
|
||||
values: { value: '{{[1,2,3]}}' },
|
||||
display_values: { value: '{{["one", "two", "three"]}}' },
|
||||
visible: { value: true },
|
||||
|
|
@ -938,6 +1000,9 @@ export const componentTypes = [
|
|||
defaultMarkers: {
|
||||
value: `{{ [{"lat": 40.7128, "lng": -73.935242}] }}`,
|
||||
},
|
||||
canSearch: {
|
||||
value: `{{true}}` ,
|
||||
},
|
||||
},
|
||||
addNewMarkers: { value: '{{false}}' },
|
||||
events: [],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const ConfigHandle = function ConfigHandle({ id, component, configHandleC
|
|||
src="/assets/images/icons/menu.svg"
|
||||
width="8"
|
||||
height="8"
|
||||
draggable="false"
|
||||
/>
|
||||
{component.name}
|
||||
</span>
|
||||
|
|
@ -28,6 +29,7 @@ export const ConfigHandle = function ConfigHandle({ id, component, configHandleC
|
|||
role="button"
|
||||
className="mx-2"
|
||||
height="12"
|
||||
draggable="false"
|
||||
onClick={() => removeComponent({ id })}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ class DataSourceManager extends React.Component {
|
|||
{!selectedDataSource && (
|
||||
<div>
|
||||
<div className="row row-deck">
|
||||
<h4 className="text-muted mb-2">DATABASES</h4>
|
||||
<h4 className="mb-2">DATABASES</h4>
|
||||
{DataBaseSources.map((dataSource) => (
|
||||
<div className="col-md-2" key={dataSource.name}>
|
||||
<div className="card mb-3" role="button" onClick={() => this.selectDataSource(dataSource)}>
|
||||
|
|
@ -225,7 +225,7 @@ class DataSourceManager extends React.Component {
|
|||
))}
|
||||
</div>
|
||||
<div className="row row-deck mt-2">
|
||||
<h4 className="text-muted mb-2">APIS</h4>
|
||||
<h4 className="mb-2">APIS</h4>
|
||||
{ApiSources.map((dataSource) => (
|
||||
<div className="col-md-2" key={dataSource.name}>
|
||||
<div className="card" role="button" onClick={() => this.selectDataSource(dataSource)}>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@
|
|||
"rawData": []
|
||||
},
|
||||
"options": {
|
||||
"gcp_key": { "type": "string", "encrypted": true }
|
||||
"gcp_key": {
|
||||
"type": "string",
|
||||
"encrypted": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
|
@ -21,9 +24,12 @@
|
|||
"$label": "Private key",
|
||||
"$key": "gcp_key",
|
||||
"$rows": 15,
|
||||
"$encrypted": true,
|
||||
"type": "textarea",
|
||||
"description": "Enter private key"
|
||||
}
|
||||
},
|
||||
"required": ["gcp_key"]
|
||||
}
|
||||
"required": [
|
||||
"gcp_key"
|
||||
]
|
||||
}
|
||||
|
|
@ -309,6 +309,22 @@ class Editor extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
saveAppName = (id, name, notify = false) => {
|
||||
if (!name.trim()) {
|
||||
toast.warn("App name can't be empty or whitespace", {
|
||||
hideProgressBar: true,
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
this.setState({
|
||||
app: { ...this.state.app, name: this.state.oldName },
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
this.saveApp(id, { name }, notify);
|
||||
};
|
||||
|
||||
renderDataSource = (dataSource) => {
|
||||
const sourceMeta = DataSourceTypes.find((source) => source.kind === dataSource.kind);
|
||||
return (
|
||||
|
|
@ -445,7 +461,7 @@ class Editor extends React.Component {
|
|||
};
|
||||
|
||||
toggleQueryEditor = () => {
|
||||
this.setState({ showQueryEditor: !this.state.showQueryEditor });
|
||||
this.setState((prev) => ({ showQueryEditor: !prev.showQueryEditor }));
|
||||
this.toolTipRefHide.current.style.display = this.state.showQueryEditor ? 'none' : 'flex';
|
||||
this.toolTipRefShow.current.style.display = this.state.showQueryEditor ? 'flex' : 'none';
|
||||
};
|
||||
|
|
@ -473,7 +489,7 @@ class Editor extends React.Component {
|
|||
};
|
||||
|
||||
toggleQuerySearch = () => {
|
||||
this.setState({ showQuerySearchField: !this.state.showQuerySearchField });
|
||||
this.setState((prev) => ({ showQuerySearchField: !prev.showQuerySearchField }));
|
||||
};
|
||||
|
||||
onVersionDeploy = (versionId) => {
|
||||
|
|
@ -491,8 +507,8 @@ class Editor extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
toolTipRefHide = createRef(null);
|
||||
toolTipRefShow = createRef(null);
|
||||
toolTipRefHide = createRef();
|
||||
toolTipRefShow = createRef();
|
||||
|
||||
render() {
|
||||
const {
|
||||
|
|
@ -569,8 +585,9 @@ class Editor extends React.Component {
|
|||
<input
|
||||
type="text"
|
||||
style={{ width: '200px', left: '80px', position: 'absolute' }}
|
||||
onFocus={(e) => this.setState({ oldName: e.target.value })}
|
||||
onChange={(e) => this.onNameChanged(e.target.value)}
|
||||
onBlur={(e) => this.saveApp(this.state.app.id, { name: e.target.value })}
|
||||
onBlur={(e) => this.saveAppName(this.state.app.id, e.target.value)}
|
||||
className="form-control-plaintext form-control-plaintext-sm"
|
||||
value={this.state.app.name}
|
||||
/>
|
||||
|
|
@ -592,12 +609,12 @@ class Editor extends React.Component {
|
|||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`btn btn-default mx-2`}
|
||||
className={`btn btn-light mx-2`}
|
||||
onClick={this.toggleQueryEditor}
|
||||
data-tip="Show query editor"
|
||||
data-class="py-1 px-2"
|
||||
ref={this.toolTipRefShow}
|
||||
style={{ display: 'none' }}
|
||||
style={{ display: 'none', opacity: 0.5 }}
|
||||
>
|
||||
<img
|
||||
style={{ transform: 'rotate(-90deg)' }}
|
||||
|
|
@ -612,6 +629,7 @@ class Editor extends React.Component {
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-light"
|
||||
data-tip="Desktop view"
|
||||
onClick={() => this.setState({ currentLayout: 'desktop' })}
|
||||
disabled={currentLayout === 'desktop'}
|
||||
>
|
||||
|
|
@ -620,6 +638,7 @@ class Editor extends React.Component {
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-light"
|
||||
data-tip="Mobile view"
|
||||
onClick={() => this.setState({ currentLayout: 'mobile' })}
|
||||
disabled={currentLayout === 'mobile'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ class Chart extends React.Component {
|
|||
|
||||
const data = this.state.component.component.definition.properties.data;
|
||||
|
||||
const chartType = this.state.component.component.definition.properties.type.value;
|
||||
|
||||
return (
|
||||
<div className="properties-container p-2">
|
||||
{renderElement(
|
||||
|
|
@ -99,17 +101,19 @@ class Chart extends React.Component {
|
|||
|
||||
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'loadingState', 'properties', currentState)}
|
||||
|
||||
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'markerColor', 'properties', currentState)}
|
||||
{chartType !== 'pie' &&
|
||||
renderElement(component, componentMeta, paramUpdated, dataQueries, 'markerColor', 'properties', currentState)}
|
||||
|
||||
{renderElement(
|
||||
component,
|
||||
componentMeta,
|
||||
paramUpdated,
|
||||
dataQueries,
|
||||
'showGridLines',
|
||||
'properties',
|
||||
currentState
|
||||
)}
|
||||
{chartType !== 'pie' &&
|
||||
renderElement(
|
||||
component,
|
||||
componentMeta,
|
||||
paramUpdated,
|
||||
dataQueries,
|
||||
'showGridLines',
|
||||
'properties',
|
||||
currentState
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,6 +266,33 @@ export const EventManager = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.actionId === 'set-localstorage-value' && (
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="col-3 p-2">Key</div>
|
||||
<div className="col-9">
|
||||
<CodeHinter
|
||||
currentState={currentState}
|
||||
initialValue={event.key}
|
||||
onChange={(value) => handlerChanged(index, 'key', value)}
|
||||
enablePreview={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row mt-3">
|
||||
<div className="col-3 p-2">Value</div>
|
||||
<div className="col-9">
|
||||
<CodeHinter
|
||||
currentState={currentState}
|
||||
initialValue={event.value}
|
||||
onChange={(value) => handlerChanged(index, 'value', value)}
|
||||
enablePreview={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const LeftSidebar = ({
|
|||
<div className="left-sidebar-stack-bottom">
|
||||
<LeftSidebarZoom onZoomChanged={onZoomChanged} />
|
||||
<div className="left-sidebar-item no-border">
|
||||
<DarkModeToggle switchDarkMode={switchDarkMode} darkMode={darkMode} />
|
||||
<DarkModeToggle switchDarkMode={switchDarkMode} darkMode={darkMode} tooltipPlacement="right" />
|
||||
</div>
|
||||
{/* <LeftSidebarItem icon='support' className='left-sidebar-item' /> */}
|
||||
</div>
|
||||
|
|
|
|||
29
frontend/src/Editor/component-properties-resolution.js
Normal file
29
frontend/src/Editor/component-properties-resolution.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { resolveReferences } from '@/_helpers/utils';
|
||||
|
||||
export const resolveProperties = (component, currentState) => {
|
||||
if (currentState && currentState.components[component.name]) {
|
||||
return Object.entries(component.definition.properties).reduce(
|
||||
(properties, entry) => ({
|
||||
...properties,
|
||||
...{ [entry[0]]: resolveReferences(entry[1].value, currentState) },
|
||||
}),
|
||||
{}
|
||||
);
|
||||
} else return {};
|
||||
};
|
||||
|
||||
export const resolveStyles = (component, currentState) => {
|
||||
if (currentState && currentState.components[component.name]) {
|
||||
const styles = component.definition.styles;
|
||||
return Object.entries(styles).reduce((resolvedStyles, entry) => {
|
||||
const key = entry[0];
|
||||
const value = resolveReferences(entry[1].value, currentState);
|
||||
return {
|
||||
...resolvedStyles,
|
||||
...{ [key]: value },
|
||||
};
|
||||
}, {});
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
|
@ -6,7 +6,16 @@ import Fuse from 'fuse.js';
|
|||
import { folderService } from '@/_services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteApp, cloneApp, exportApp }) {
|
||||
export const AppMenu = function AppMenu({
|
||||
app,
|
||||
folders,
|
||||
foldersChanged,
|
||||
deleteApp,
|
||||
cloneApp,
|
||||
exportApp,
|
||||
canCreateApp,
|
||||
canDeleteApp,
|
||||
}) {
|
||||
const [addToFolder, setAddToFolder] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
|
|
@ -68,26 +77,32 @@ export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteAp
|
|||
<Popover.Content>
|
||||
{!addToFolder && (
|
||||
<div>
|
||||
<div className="field mb-2">
|
||||
<span role="button" onClick={() => setAddToFolder(true)}>
|
||||
Add to folder
|
||||
</span>
|
||||
</div>
|
||||
<div className="field mb-2">
|
||||
<span className="field mb-2" role="button" onClick={() => cloneApp()}>
|
||||
Clone app
|
||||
</span>
|
||||
</div>
|
||||
<div className="field mb-2">
|
||||
<span className="field mb-2" role="button" onClick={() => exportApp()}>
|
||||
Export app
|
||||
</span>
|
||||
</div>
|
||||
<div className="field mb-2">
|
||||
<span className="my-3 text-danger" role="button" onClick={() => deleteApp()}>
|
||||
Delete app
|
||||
</span>
|
||||
</div>
|
||||
{canCreateApp && (
|
||||
<>
|
||||
<div className="field mb-2">
|
||||
<span role="button" onClick={() => setAddToFolder(true)}>
|
||||
Add to folder
|
||||
</span>
|
||||
</div>
|
||||
<div className="field mb-2">
|
||||
<span className="field mb-2" role="button" onClick={() => cloneApp()}>
|
||||
Clone app
|
||||
</span>
|
||||
</div>
|
||||
<div className="field mb-2">
|
||||
<span className="field mb-2" role="button" onClick={() => exportApp()}>
|
||||
Export app
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{canDeleteApp && (
|
||||
<div className="field mb-2">
|
||||
<span className="my-3 text-danger" role="button" onClick={() => deleteApp()}>
|
||||
Delete app
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
export const BlankPage = function BlankPage({ createApp }) {
|
||||
export const BlankPage = function BlankPage({ createApp, handleImportApp, isImportingApp, fileInput }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-wrapper">
|
||||
|
|
@ -13,7 +13,7 @@ export const BlankPage = function BlankPage({ createApp }) {
|
|||
</div>
|
||||
<p className="empty-title">You haven't created any apps yet.</p>
|
||||
<div className="empty-action">
|
||||
<a onClick={createApp} className="btn btn-primary text-light">
|
||||
<a onClick={createApp} className="btn btn-primary text-light mx-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon"
|
||||
|
|
@ -35,7 +35,20 @@ export const BlankPage = function BlankPage({ createApp }) {
|
|||
<a
|
||||
href="https://docs.tooljet.io"
|
||||
target="_blank"
|
||||
className="btn btn-primary text-light mx-2"
|
||||
className="btn btn-primary text-light mx-1"
|
||||
rel="noreferrer"
|
||||
onChange={handleImportApp}
|
||||
>
|
||||
<label>
|
||||
{isImportingApp && <span className="spinner-border spinner-border-sm me-2" role="status"></span>}
|
||||
Import an app
|
||||
<input type="file" ref={fileInput} style={{ display: 'none' }} />
|
||||
</label>
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.tooljet.io"
|
||||
target="_blank"
|
||||
className="btn btn-primary text-light mx-1"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Read documentation
|
||||
|
|
|
|||
|
|
@ -88,11 +88,11 @@ class HomePage extends React.Component {
|
|||
appService
|
||||
.createApp()
|
||||
.then((data) => {
|
||||
console.log(data);
|
||||
_self.props.history.push(`/apps/${data.id}`);
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error, { hideProgressBar: true, position: 'top-center' });
|
||||
_self.setState({ creatingApp: false });
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -139,17 +139,16 @@ class HomePage extends React.Component {
|
|||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
this.fileInput.value = '';
|
||||
this.setState({ isExportingApp: false });
|
||||
})
|
||||
.catch(({ _error }) => {
|
||||
.catch((error) => {
|
||||
toast.error('Could not export the app.', {
|
||||
hideProgressBar: true,
|
||||
position: 'top-center',
|
||||
});
|
||||
this.fileInput.value = '';
|
||||
|
||||
this.setState({ isExportingApp: false });
|
||||
console.log(_error);
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -172,6 +171,7 @@ class HomePage extends React.Component {
|
|||
isImportingApp: false,
|
||||
});
|
||||
this.fetchApps(this.state.currentPage, this.state.currentFolder.id);
|
||||
this.fetchFolders();
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(`Could not import the app: ${error}`, {
|
||||
|
|
@ -191,15 +191,69 @@ class HomePage extends React.Component {
|
|||
isImportingApp: false,
|
||||
});
|
||||
}
|
||||
// set file input as null to handle same file upload
|
||||
event.target.value = null;
|
||||
};
|
||||
};
|
||||
|
||||
isAppEditable = (app) => {
|
||||
return app.app_group_permissions.some((p) => p.update);
|
||||
canUserPerform(user, action, app) {
|
||||
let permissionGrant;
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
permissionGrant = this.canAnyGroupPerformAction('app_create', user.group_permissions);
|
||||
break;
|
||||
case 'read':
|
||||
case 'update':
|
||||
permissionGrant =
|
||||
this.canAnyGroupPerformActionOnApp(action, user.app_group_permissions, app) ||
|
||||
this.isUserOwnerOfApp(user, app);
|
||||
break;
|
||||
case 'delete':
|
||||
permissionGrant =
|
||||
this.canAnyGroupPerformActionOnApp('delete', user.app_group_permissions, app) ||
|
||||
this.canAnyGroupPerformAction('app_delete', user.group_permissions) ||
|
||||
this.isUserOwnerOfApp(user, app);
|
||||
break;
|
||||
default:
|
||||
permissionGrant = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return permissionGrant;
|
||||
}
|
||||
|
||||
canAnyGroupPerformActionOnApp(action, appGroupPermissions, app) {
|
||||
if (!appGroupPermissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionsToCheck = appGroupPermissions.filter((permission) => permission.app_id == app.id);
|
||||
return this.canAnyGroupPerformAction(action, permissionsToCheck);
|
||||
}
|
||||
|
||||
canAnyGroupPerformAction(action, permissions) {
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return permissions.some((p) => p[action]);
|
||||
}
|
||||
|
||||
isUserOwnerOfApp(user, app) {
|
||||
return user.id == app.user_id;
|
||||
}
|
||||
|
||||
canCreateApp = () => {
|
||||
return this.canUserPerform(this.state.currentUser, 'create');
|
||||
};
|
||||
|
||||
isAppDeletable = (app) => {
|
||||
return app.app_group_permissions.some((p) => p.delete);
|
||||
canUpdateApp = (app) => {
|
||||
return this.canUserPerform(this.state.currentUser, 'update', app);
|
||||
};
|
||||
|
||||
canDeleteApp = (app) => {
|
||||
return this.canUserPerform(this.state.currentUser, 'delete', app);
|
||||
};
|
||||
|
||||
executeAppDeletion = () => {
|
||||
|
|
@ -260,7 +314,14 @@ class HomePage extends React.Component {
|
|||
/>
|
||||
|
||||
<Header switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode} />
|
||||
{!isLoading && meta.total_count === 0 && !currentFolder.id && <BlankPage createApp={this.createApp} />}
|
||||
{!isLoading && meta.total_count === 0 && !currentFolder.id && (
|
||||
<BlankPage
|
||||
createApp={this.createApp}
|
||||
isImportingApp={isImportingApp}
|
||||
fileInput={this.fileInput}
|
||||
handleImportApp={this.handleImportApp}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLoading || meta.total_count > 0) && (
|
||||
<div className="page-body homepage-body">
|
||||
|
|
@ -286,38 +347,43 @@ class HomePage extends React.Component {
|
|||
{currentFolder.id ? `Folder: ${currentFolder.name}` : 'All applications'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="w-100 ">
|
||||
<button
|
||||
className={`btn btn-default d-none d-lg-inline mb-3 ${isImportingApp ? 'btn-loading' : ''}`}
|
||||
onChange={this.handleImportApp}
|
||||
>
|
||||
<label>
|
||||
Import
|
||||
<input type="file" ref={this.fileInput} style={{ display: 'none' }} />
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{this.canCreateApp() && (
|
||||
<>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="w-100 ">
|
||||
<button
|
||||
className={'btn btn-default d-none d-lg-inline mb-3'}
|
||||
onChange={this.handleImportApp}
|
||||
>
|
||||
<label>
|
||||
{isImportingApp && (
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
)}
|
||||
Import
|
||||
<input type="file" ref={this.fileInput} style={{ display: 'none' }} />
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="w-100 ">
|
||||
<button
|
||||
className={`btn btn-primary d-none d-lg-inline mb-3 ${creatingApp ? 'btn-loading' : ''}`}
|
||||
onClick={this.createApp}
|
||||
>
|
||||
Create new application
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="w-100 ">
|
||||
<button
|
||||
className={`btn btn-primary d-none d-lg-inline mb-3 ${
|
||||
creatingApp ? 'btn-loading' : ''
|
||||
}`}
|
||||
onClick={this.createApp}
|
||||
>
|
||||
Create new application
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
currentFolder.count === 0
|
||||
? 'table-responsive w-100 apps-table mt-3 d-flex align-items-center'
|
||||
: 'table-responsive w-100 apps-table mt-3'
|
||||
}
|
||||
className='table-responsive w-100 apps-table'
|
||||
style={{ minHeight: '600px' }}
|
||||
>
|
||||
<table
|
||||
|
|
@ -357,7 +423,7 @@ class HomePage extends React.Component {
|
|||
</small>
|
||||
</td>
|
||||
<td className="text-muted col-auto pt-4">
|
||||
{!isLoading && this.isAppEditable(app) && (
|
||||
{!isLoading && this.canUpdateApp(app) && (
|
||||
<Link to={`/apps/${app.id}`} className="d-none d-lg-inline">
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
|
|
@ -436,9 +502,11 @@ class HomePage extends React.Component {
|
|||
)}
|
||||
</Link>
|
||||
|
||||
{this.isAppDeletable(app) && (
|
||||
{(this.canCreateApp(app) || this.canDeleteApp(app)) && (
|
||||
<AppMenu
|
||||
app={app}
|
||||
canCreateApp={this.canCreateApp()}
|
||||
canDeleteApp={this.canDeleteApp(app)}
|
||||
folders={this.state.folders}
|
||||
foldersChanged={this.foldersChanged}
|
||||
deleteApp={() => this.deleteApp(app)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class LoginPage extends React.Component {
|
|||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
showPassword: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,10 @@ class LoginPage extends React.Component {
|
|||
this.setState({ [event.target.name]: event.target.value });
|
||||
};
|
||||
|
||||
handleOnCheck = () => {
|
||||
this.setState((prev) => ({ showPassword: !prev.showPassword }));
|
||||
};
|
||||
|
||||
authUser = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -85,7 +90,7 @@ class LoginPage extends React.Component {
|
|||
<input
|
||||
onChange={this.handleChange}
|
||||
name="password"
|
||||
type="password"
|
||||
type={this.state.showPassword ? 'text' : 'password'}
|
||||
className="form-control"
|
||||
placeholder="Password"
|
||||
autoComplete="off"
|
||||
|
|
@ -94,6 +99,18 @@ class LoginPage extends React.Component {
|
|||
<span className="input-group-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="check-input"
|
||||
name="check-input"
|
||||
onChange={this.handleOnCheck}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="check-input">
|
||||
show password
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button
|
||||
data-testid="loginButton"
|
||||
|
|
|
|||
|
|
@ -45,21 +45,25 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
fetchGroupAndResources = (groupPermissionId) => {
|
||||
fetchGroupPermission = (groupPermissionId) => {
|
||||
groupPermissionService.getGroup(groupPermissionId).then((data) => {
|
||||
this.setState({
|
||||
groupPermission: data,
|
||||
isLoadingGroup: false,
|
||||
});
|
||||
|
||||
this.fetchUsersNotInGroup(groupPermissionId);
|
||||
this.fetchUsersInGroup(groupPermissionId);
|
||||
|
||||
this.fetchAppsNotInGroup(groupPermissionId);
|
||||
this.fetchAppsInGroup(groupPermissionId);
|
||||
});
|
||||
};
|
||||
|
||||
fetchGroupAndResources = (groupPermissionId) => {
|
||||
this.setState({ isLoadingGroup: true });
|
||||
|
||||
this.fetchGroupPermission(groupPermissionId);
|
||||
this.fetchUsersNotInGroup(groupPermissionId);
|
||||
this.fetchUsersInGroup(groupPermissionId);
|
||||
this.fetchAppsNotInGroup(groupPermissionId);
|
||||
this.fetchAppsInGroup(groupPermissionId);
|
||||
};
|
||||
|
||||
fetchUsersNotInGroup = (groupPermissionId) => {
|
||||
groupPermissionService.getUsersNotInGroup(groupPermissionId).then((data) => {
|
||||
this.setState({
|
||||
|
|
@ -94,6 +98,22 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
updateGroupPermission = (groupPermissionId, params) => {
|
||||
groupPermissionService
|
||||
.update(groupPermissionId, params)
|
||||
.then(() => {
|
||||
toast.success('Group permissions updated', {
|
||||
hideProgressBar: true,
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
this.fetchGroupPermission(groupPermissionId);
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error, { hideProgressBar: true, position: 'top-center' });
|
||||
});
|
||||
};
|
||||
|
||||
updateAppGroupPermission = (app, groupPermissionId, action) => {
|
||||
const appGroupPermission = app.app_group_permissions.find(
|
||||
(permission) => permission.group_permission_id === groupPermissionId
|
||||
|
|
@ -154,7 +174,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
addSelectedAppsToGroup = (groupPermissionId, selectedAppIds) => {
|
||||
this.setState({ isAddingApps: true });
|
||||
const updateParams = {
|
||||
selectedAppIds,
|
||||
add_apps: selectedAppIds,
|
||||
};
|
||||
groupPermissionService
|
||||
.update(groupPermissionId, updateParams)
|
||||
|
|
@ -180,7 +200,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
|
||||
removeAppFromGroup = (groupPermissionId, appId) => {
|
||||
const updateParams = {
|
||||
removeAppIds: [appId],
|
||||
remove_apps: [appId],
|
||||
};
|
||||
groupPermissionService
|
||||
.update(groupPermissionId, updateParams)
|
||||
|
|
@ -203,7 +223,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
addSelectedUsersToGroup = (groupPermissionId, selectedUserIds) => {
|
||||
this.setState({ isAddingUsers: true });
|
||||
const updateParams = {
|
||||
selectedUserIds,
|
||||
add_users: selectedUserIds,
|
||||
};
|
||||
groupPermissionService
|
||||
.update(groupPermissionId, updateParams)
|
||||
|
|
@ -229,7 +249,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
|
||||
removeUserFromGroup = (groupPermissionId, userId) => {
|
||||
const updateParams = {
|
||||
removeUserIds: [userId],
|
||||
remove_users: [userId],
|
||||
};
|
||||
groupPermissionService
|
||||
.update(groupPermissionId, updateParams)
|
||||
|
|
@ -324,9 +344,18 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a
|
||||
className={`nav-link ${currentTab === 'permissions' ? 'active' : ''}`}
|
||||
onClick={() => this.setState({ currentTab: 'permissions' })}
|
||||
>
|
||||
Permissions
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="card-body">
|
||||
<div className="tab-content">
|
||||
{/* Apps Tab */}
|
||||
<div className={`tab-pane ${currentTab === 'apps' ? 'active show' : ''}`}>
|
||||
<div className="row">
|
||||
<div className="col-5">
|
||||
|
|
@ -365,7 +394,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoadingApps ? (
|
||||
{isLoadingGroup || isLoadingApps ? (
|
||||
<tr>
|
||||
<td className="col-auto">
|
||||
<div className="row">
|
||||
|
|
@ -383,7 +412,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
appsInGroup.map((app) => (
|
||||
<tr key={app.id}>
|
||||
<td>{app.name}</td>
|
||||
<td className="text-muted">
|
||||
<td className="text-secondary">
|
||||
<div>
|
||||
<label className="form-check form-check-inline">
|
||||
<input
|
||||
|
|
@ -431,6 +460,8 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Tab */}
|
||||
<div className={`tab-pane ${currentTab === 'users' ? 'active show' : ''}`}>
|
||||
<div className="row">
|
||||
<div className="col-5">
|
||||
|
|
@ -469,7 +500,7 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoadingUsers ? (
|
||||
{isLoadingGroup || isLoadingUsers ? (
|
||||
<tr>
|
||||
<td className="col-auto">
|
||||
<div className="row">
|
||||
|
|
@ -508,6 +539,77 @@ class ManageGroupPermissionResources extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Tab */}
|
||||
<div className={`tab-pane ${currentTab === 'permissions' ? 'active show' : ''}`}>
|
||||
<div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resource</th>
|
||||
<th>Permissions</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoadingGroup ? (
|
||||
<tr>
|
||||
<td className="col-auto">
|
||||
<div className="row">
|
||||
<div className="skeleton-line w-10 col mx-3"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="col-auto">
|
||||
<div className="skeleton-line w-10"></div>
|
||||
</td>
|
||||
<td className="col-auto">
|
||||
<div className="skeleton-line w-10"></div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td>Apps</td>
|
||||
<td className="text-muted">
|
||||
<div>
|
||||
<label className="form-check form-check-inline">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
this.updateGroupPermission(groupPermission.id, {
|
||||
app_create: !groupPermission.app_create,
|
||||
});
|
||||
}}
|
||||
checked={groupPermission.app_create}
|
||||
disabled={groupPermission.group === 'admin'}
|
||||
/>
|
||||
<span className="form-check-label">Create</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
this.updateGroupPermission(groupPermission.id, {
|
||||
app_delete: !groupPermission.app_delete,
|
||||
});
|
||||
}}
|
||||
checked={groupPermission.app_delete}
|
||||
disabled={groupPermission.group === 'admin'}
|
||||
/>
|
||||
<span className="form-check-label">Delete</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -106,13 +106,12 @@ class ManageOrgUsers extends React.Component {
|
|||
|
||||
if (this.handleValidation()) {
|
||||
let fields = {};
|
||||
Object.keys(fields).forEach((key) => {
|
||||
Object.keys(this.state.fields).map((key) => {
|
||||
fields[key] = '';
|
||||
});
|
||||
|
||||
this.setState({
|
||||
creatingUser: true,
|
||||
fields: fields,
|
||||
});
|
||||
|
||||
organizationUserService
|
||||
|
|
@ -125,10 +124,11 @@ class ManageOrgUsers extends React.Component {
|
|||
.then(() => {
|
||||
toast.success('User has been created', { hideProgressBar: true, position: 'top-center' });
|
||||
this.fetchUsers();
|
||||
this.setState({ creatingUser: false, showNewUserForm: false });
|
||||
this.setState({ creatingUser: false, showNewUserForm: false, fields: fields });
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error, { hideProgressBar: true, position: 'top-center' });
|
||||
this.setState({ creatingUser: false });
|
||||
});
|
||||
} else {
|
||||
this.setState({ creatingUser: false, showNewUserForm: true });
|
||||
|
|
@ -259,7 +259,7 @@ class ManageOrgUsers extends React.Component {
|
|||
</thead>
|
||||
{isLoading ? (
|
||||
<tbody className="w-100" style={{ minHeight: '300px' }}>
|
||||
{Array.from(Array(4)).map((index) => (
|
||||
{Array.from(Array(4)).map((_item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="col-2 p-3">
|
||||
<div className="row">
|
||||
|
|
@ -299,7 +299,7 @@ class ManageOrgUsers extends React.Component {
|
|||
</span>
|
||||
</td>
|
||||
<td className="text-muted">
|
||||
<a href="#" className="text-reset user-email">
|
||||
<a className="text-reset user-email">
|
||||
{user.email}
|
||||
</a>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { useSpring, animated } from 'react-spring';
|
|||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
||||
import Tooltip from 'react-bootstrap/Tooltip';
|
||||
|
||||
export const DarkModeToggle = function DarkModeToggle({ darkMode = false, switchDarkMode }) {
|
||||
export const DarkModeToggle = function DarkModeToggle({
|
||||
darkMode = false,
|
||||
switchDarkMode,
|
||||
tooltipPlacement = 'bottom',
|
||||
}) {
|
||||
const toggleDarkMode = () => {
|
||||
switchDarkMode(!darkMode);
|
||||
};
|
||||
|
|
@ -43,7 +47,7 @@ export const DarkModeToggle = function DarkModeToggle({ darkMode = false, switch
|
|||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
placement={tooltipPlacement}
|
||||
delay={{ show: 250, hide: 400 }}
|
||||
overlay={<Tooltip id="button-tooltip">{darkMode ? 'Activate light mode' : 'Activate dark mode'}</Tooltip>}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import Input from '@/_ui/Input';
|
||||
import Textarea from '@/_ui/Textarea';
|
||||
import Select from '@/_ui/Select';
|
||||
import Headers from '@/_ui/HttpHeaders';
|
||||
import OAuth from '@/_ui/OAuth';
|
||||
|
|
@ -17,8 +18,9 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
|
|||
switch (type) {
|
||||
case 'password':
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return Input;
|
||||
case 'textarea':
|
||||
return Textarea;
|
||||
case 'dropdown':
|
||||
return Select;
|
||||
case 'toggle':
|
||||
|
|
@ -96,7 +98,7 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
|
|||
return (
|
||||
<div className="row">
|
||||
{Object.keys(obj).map((key) => {
|
||||
const { $label, type } = obj[key];
|
||||
const { $label, type, $encrypted } = obj[key];
|
||||
|
||||
const Element = getElement(type);
|
||||
|
||||
|
|
@ -105,7 +107,7 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
|
|||
{$label && (
|
||||
<label className="form-label">
|
||||
{$label}
|
||||
{type === 'password' && (
|
||||
{(type === 'password' || $encrypted) && (
|
||||
<small className="text-green mx-2">
|
||||
<img
|
||||
className="mx-2 encrypted-icon"
|
||||
|
|
|
|||
|
|
@ -188,6 +188,12 @@ function executeAction(_ref, event, mode) {
|
|||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
case 'set-localstorage-value': {
|
||||
const key = resolveReferences(event.key, _ref.state.currentState);
|
||||
const value = resolveReferences(event.value, _ref.state.currentState);
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,6 +268,7 @@ export async function onEvent(_ref, eventName, options, mode = 'edit') {
|
|||
'onChange',
|
||||
'onSelectionChange',
|
||||
'onSelect',
|
||||
'onClick',
|
||||
].includes(eventName)
|
||||
) {
|
||||
const { component } = options;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export function resolve(data, state) {
|
|||
}
|
||||
|
||||
export function resolveReferences(object, state, defaultValue, customObjects = {}, withError = false) {
|
||||
|
||||
object = _.clone(object)
|
||||
|
||||
const objectType = typeof object;
|
||||
let error;
|
||||
switch (objectType) {
|
||||
|
|
|
|||
|
|
@ -27,14 +27,7 @@ function create(group) {
|
|||
return fetch(`${config.apiUrl}/group_permissions`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function update(groupPermissionId, params) {
|
||||
const body = {
|
||||
add_apps: params.selectedAppIds,
|
||||
remove_apps: params.removeAppIds,
|
||||
add_users: params.selectedUserIds,
|
||||
remove_users: params.removeUserIds,
|
||||
};
|
||||
|
||||
function update(groupPermissionId, body) {
|
||||
const requestOptions = {
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
.left-sidebar-stack-bottom {
|
||||
width: 3%;
|
||||
position: fixed;
|
||||
bottom: 12vw;
|
||||
bottom: 4vw;
|
||||
height: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,7 +318,6 @@ body {
|
|||
height: 350px;
|
||||
width: 79%;
|
||||
position: fixed;
|
||||
// z-index: 1;
|
||||
left: 3%;
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
|
|
@ -422,10 +421,6 @@ body {
|
|||
--tblr-gutter-x: 0rem;
|
||||
}
|
||||
|
||||
.query-list {
|
||||
// padding-top: 40px;
|
||||
}
|
||||
|
||||
.query-list::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
|
|
@ -489,7 +484,6 @@ body {
|
|||
background: #edeff5;
|
||||
margin: 0px auto;
|
||||
background-size: 80px 80px;
|
||||
// background-image: url(/public/images/tile.png);
|
||||
background-repeat: repeat;
|
||||
}
|
||||
}
|
||||
|
|
@ -768,11 +762,6 @@ body {
|
|||
z-index: 2;
|
||||
}
|
||||
|
||||
.markdown > table > :not(caption) > * > *,
|
||||
.table > :not(caption) > * > * {
|
||||
//padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.table-row:hover,
|
||||
.table-row:focus {
|
||||
background: rgba(lightBlue, 0.25);
|
||||
|
|
@ -798,6 +787,14 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
td.spacious {
|
||||
min-height: 47px;
|
||||
}
|
||||
|
||||
td.compact {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.has-dropdown,
|
||||
.has-multiselect,
|
||||
.has-text,
|
||||
|
|
@ -1217,6 +1214,7 @@ body {
|
|||
|
||||
.RichEditor-editor .public-DraftEditor-content {
|
||||
min-height: 100px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root {
|
||||
|
|
@ -1246,6 +1244,60 @@ body {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.dropmenu{
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 16px;
|
||||
|
||||
.dropdownbtn{
|
||||
color: #999;
|
||||
background:none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 6px 2px rgba(47, 54, 59, 0.15);
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 3px 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.dropmenu .dropdown-content a:hover{
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dropmenu:hover {
|
||||
.dropdownbtn{
|
||||
color: #5890ff;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dropdown-content{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.RichEditor-styleButton {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
|
|
@ -1378,9 +1430,6 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.CodeMirror-focused {
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar,
|
||||
.CodeMirror-hscrollbar {
|
||||
background: transparent;
|
||||
|
|
@ -1503,11 +1552,9 @@ hr {
|
|||
}
|
||||
}
|
||||
|
||||
// .draggable-box:hover {
|
||||
.config-handle {
|
||||
display: block;
|
||||
}
|
||||
// }
|
||||
|
||||
.apps-table {
|
||||
.app-title {
|
||||
|
|
@ -1760,6 +1807,11 @@ input:focus-visible {
|
|||
}
|
||||
}
|
||||
|
||||
.user-email:hover {
|
||||
text-decoration: none;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
.navbar .navbar-nav .active > .nav-link,
|
||||
.theme-dark .navbar .navbar-nav .nav-link.active,
|
||||
|
|
@ -1942,7 +1994,7 @@ input:focus-visible {
|
|||
.btn-light,
|
||||
.btn-outline-light {
|
||||
background-color: #42546a !important;
|
||||
|
||||
--tblr-btn-color-text: #ffffff;
|
||||
img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
|
@ -1992,6 +2044,10 @@ input:focus-visible {
|
|||
background-color: #1f2936 !important;
|
||||
}
|
||||
|
||||
.input-icon .input-icon-addon img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
|
@ -2014,21 +2070,15 @@ input:focus-visible {
|
|||
.editor .editor-sidebar .inspector .header {
|
||||
border: solid rgba(255, 255, 255, 0.09) !important;
|
||||
border-width: 0px 0px 1px 0px !important;
|
||||
.input-icon .input-icon-addon img {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
.editor .editor-sidebar .inspector .hr-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
.skeleton-line::after {
|
||||
// background-image: linear-gradient(to right, #232e3c 0, #4c5b79 40%, #4c5b79 80%);
|
||||
background-image: linear-gradient(to right, #566177 0, #5a6170 40%, #4c5b79 80%);
|
||||
}
|
||||
|
||||
// .btn {
|
||||
// filter: brightness(1) invert(1);
|
||||
// }
|
||||
.hr-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.skeleton-line::after {
|
||||
background-image: linear-gradient(to right, #566177 0, #5a6170 40%, #4c5b79 80%);
|
||||
}
|
||||
|
||||
.select-search__input {
|
||||
color: rgb(224, 224, 224);
|
||||
|
|
@ -2142,21 +2192,11 @@ input:focus-visible {
|
|||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
// background: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #206bc4;
|
||||
font-weight: 400;
|
||||
// color: #fff;
|
||||
}
|
||||
|
||||
// .nav-tabs .nav-link.active:hover {
|
||||
// border-bottom: 1px solid #206bc4;
|
||||
// }
|
||||
|
||||
// .nav-tabs .nav-link:hover {
|
||||
// border: 0;
|
||||
// }
|
||||
|
||||
.table-no-divider {
|
||||
td {
|
||||
border-bottom-width: 0px;
|
||||
|
|
@ -2191,8 +2231,21 @@ input[type='text'] {
|
|||
|
||||
.dropdown-widget,
|
||||
.multiselect-widget {
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.select-search__input {
|
||||
padding: 0.1375rem 0.75rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.select-search__value {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.select-search {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.select-search__options {
|
||||
|
|
|
|||
10
frontend/src/_ui/Textarea/index.js
Normal file
10
frontend/src/_ui/Textarea/index.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
const Input = ({ helpText, ...props }) => (
|
||||
<>
|
||||
<textarea {...props} />
|
||||
{helpText && <small className="text-muted" dangerouslySetInnerHTML={{ __html: helpText }} />}
|
||||
</>
|
||||
);
|
||||
|
||||
export default Input;
|
||||
|
|
@ -9,11 +9,6 @@ const API_URL = {
|
|||
development: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
const ASSET_PATH = {
|
||||
production: 'https://app.tooljet.io/',
|
||||
development: '/public/',
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
target: 'web',
|
||||
|
|
@ -93,14 +88,13 @@ module.exports = {
|
|||
historyApiFallback: true,
|
||||
},
|
||||
output: {
|
||||
publicPath: '/',
|
||||
publicPath: process.env.ASSET_PATH || '/',
|
||||
path: path.resolve(__dirname, 'build'),
|
||||
},
|
||||
externals: {
|
||||
// global app config object
|
||||
config: JSON.stringify({
|
||||
apiUrl: `${API_URL[environment] || ''}/api`,
|
||||
assetPath: ASSET_PATH[environment],
|
||||
SERVER_IP: process.env.SERVER_IP,
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
0.8.0
|
||||
0.8.1
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||
|
||||
export class AddAppCreateAndAppDeleteToGroupPermissions1634724636255
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.addColumn(
|
||||
"group_permissions",
|
||||
new TableColumn({
|
||||
name: "app_create",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
})
|
||||
);
|
||||
|
||||
await queryRunner.addColumn(
|
||||
"group_permissions",
|
||||
new TableColumn({
|
||||
name: "app_delete",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropColumn("group_permissions", "app_create");
|
||||
await queryRunner.dropColumn("group_permissions", "app_delete");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { EntityManager, In, MigrationInterface, QueryRunner } from "typeorm";
|
||||
import { GroupPermission } from "../src/entities/group_permission.entity";
|
||||
|
||||
export class BackfillAppCreatePermissionsAsTruthyForAdminGroup1634729050892
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const entityManager = queryRunner.manager;
|
||||
const GroupPermissionRepostory =
|
||||
entityManager.getRepository(GroupPermission);
|
||||
|
||||
await GroupPermissionRepostory.update(
|
||||
{ group: "admin" },
|
||||
{ appCreate: true, appDelete: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const entityManager = queryRunner.manager;
|
||||
const GroupPermissionRepostory =
|
||||
entityManager.getRepository(GroupPermission);
|
||||
|
||||
await GroupPermissionRepostory.update(
|
||||
{ group: "admin" },
|
||||
{ appCreate: false, appDelete: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { AppVersion } from '../src/entities/app_version.entity';
|
||||
|
||||
export class SetTablecellSizeToCompact1634848932643 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const entityManager = queryRunner.manager;
|
||||
const queryBuilder = queryRunner.connection.createQueryBuilder();
|
||||
const appVersionRepository = entityManager.getRepository(AppVersion);
|
||||
|
||||
const appVersions = await appVersionRepository.find();
|
||||
|
||||
for (const version of appVersions) {
|
||||
const definition = version['definition'];
|
||||
|
||||
if (definition) {
|
||||
const components = definition['components'];
|
||||
|
||||
for (const componentId of Object.keys(components)) {
|
||||
const component = components[componentId];
|
||||
|
||||
if (component.component.component === 'Table') {
|
||||
component.component.definition.styles.cellSize = { value: 'compact' };
|
||||
components[componentId] = {
|
||||
...component,
|
||||
component: {
|
||||
...component.component,
|
||||
definition: {
|
||||
...component.component.definition,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
definition['components'] = components;
|
||||
version.definition = definition;
|
||||
|
||||
await queryBuilder.update(AppVersion).set({ definition }).where('id = :id', { id: version.id }).execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {}
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@ export class App extends BaseEntity {
|
|||
@Column({ name: 'current_version_id' })
|
||||
currentVersionId: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'now()', name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export class GroupPermission extends BaseEntity {
|
|||
@Column()
|
||||
group: string;
|
||||
|
||||
@Column({ name: 'app_create', default: false })
|
||||
appCreate: boolean;
|
||||
|
||||
@Column({ name: 'app_delete', default: false })
|
||||
appDelete: boolean;
|
||||
|
||||
@CreateDateColumn({ default: () => 'now()', name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ import { FoldersService } from '@services/folders.service';
|
|||
import { Folder } from 'src/entities/folder.entity';
|
||||
import { FolderApp } from 'src/entities/folder_app.entity';
|
||||
import { DataSource } from 'src/entities/data_source.entity';
|
||||
import { AppCloneService } from '@services/app_clone.service';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
import { UserGroupPermission } from 'src/entities/user_group_permission.entity';
|
||||
import { AppImportExportService } from '@services/app_import_export.service';
|
||||
import { DataSourcesService } from '@services/data_sources.service';
|
||||
import { CredentialsService } from '@services/credentials.service';
|
||||
import { EncryptionService } from '@services/encryption.service';
|
||||
import { Credential } from 'src/entities/credential.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -39,10 +42,20 @@ import { AppImportExportService } from '@services/app_import_export.service';
|
|||
GroupPermission,
|
||||
AppGroupPermission,
|
||||
UserGroupPermission,
|
||||
Credential,
|
||||
]),
|
||||
CaslModule,
|
||||
],
|
||||
providers: [AppsService, AppUsersService, UsersService, FoldersService, AppCloneService, AppImportExportService],
|
||||
providers: [
|
||||
AppsService,
|
||||
AppUsersService,
|
||||
UsersService,
|
||||
FoldersService,
|
||||
AppImportExportService,
|
||||
DataSourcesService,
|
||||
CredentialsService,
|
||||
EncryptionService,
|
||||
],
|
||||
controllers: [AppsController, AppUsersController],
|
||||
})
|
||||
export class AppsModule {}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ import { OrganizationUsersService } from 'src/services/organization_users.servic
|
|||
import { ConfigService } from '@nestjs/config';
|
||||
import { EmailService } from '@services/email.service';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
TypeOrmModule.forFeature([User, Organization, OrganizationUser, GroupPermission]),
|
||||
TypeOrmModule.forFeature([User, Organization, OrganizationUser, GroupPermission, App]),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ export class AppsAbilityFactory {
|
|||
async appsActions(user: User, params: any) {
|
||||
const { can, build } = new AbilityBuilder<Ability<[Actions, Subjects]>>(Ability as AbilityClass<AppsAbility>);
|
||||
|
||||
if (await this.usersService.userCan(user, 'create', 'App')) {
|
||||
if (await this.usersService.userCan(user, 'create', 'User')) {
|
||||
can('createUsers', App, { organizationId: user.organizationId });
|
||||
}
|
||||
|
||||
if (await this.usersService.userCan(user, 'create', 'App')) {
|
||||
can('createApp', App);
|
||||
can('cloneApp', App, { organizationId: user.organizationId });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||
import { EmailService } from '@services/email.service';
|
||||
import { OrganizationUsersService } from '@services/organization_users.service';
|
||||
import { UsersService } from '@services/users.service';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
import { Organization } from 'src/entities/organization.entity';
|
||||
import { OrganizationUser } from 'src/entities/organization_user.entity';
|
||||
import { User } from 'src/entities/user.entity';
|
||||
|
|
@ -10,7 +11,7 @@ import { AppsAbilityFactory } from './abilities/apps-ability.factory';
|
|||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, Organization, OrganizationUser])],
|
||||
imports: [TypeOrmModule.forFeature([User, Organization, OrganizationUser, App])],
|
||||
providers: [CaslAbilityFactory, OrganizationUsersService, UsersService, EmailService, AppsAbilityFactory],
|
||||
exports: [CaslAbilityFactory, AppsAbilityFactory],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import { App } from 'src/entities/app.entity';
|
|||
import { AppVersion } from 'src/entities/app_version.entity';
|
||||
import { AppUser } from 'src/entities/app_user.entity';
|
||||
import { FolderApp } from 'src/entities/folder_app.entity';
|
||||
import { AppCloneService } from '@services/app_clone.service';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
import { UsersService } from '@services/users.service';
|
||||
import { User } from 'src/entities/user.entity';
|
||||
import { OrganizationUser } from 'src/entities/organization_user.entity';
|
||||
import { Organization } from 'src/entities/organization.entity';
|
||||
import { AppImportExportService } from '@services/app_import_export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -46,8 +46,8 @@ import { Organization } from 'src/entities/organization.entity';
|
|||
EncryptionService,
|
||||
DataSourcesService,
|
||||
AppsService,
|
||||
AppCloneService,
|
||||
UsersService,
|
||||
AppImportExportService,
|
||||
],
|
||||
controllers: [DataQueriesController],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import { CaslModule } from '../casl/casl.module';
|
|||
import { DataQueriesService } from '@services/data_queries.service';
|
||||
import { DataQuery } from 'src/entities/data_query.entity';
|
||||
import { FolderApp } from 'src/entities/folder_app.entity';
|
||||
import { AppCloneService } from '@services/app_clone.service';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
import { UsersService } from '@services/users.service';
|
||||
import { User } from 'src/entities/user.entity';
|
||||
import { OrganizationUser } from 'src/entities/organization_user.entity';
|
||||
import { Organization } from 'src/entities/organization.entity';
|
||||
import { AppImportExportService } from '@services/app_import_export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -46,8 +46,8 @@ import { Organization } from 'src/entities/organization.entity';
|
|||
EncryptionService,
|
||||
AppsService,
|
||||
DataQueriesService,
|
||||
AppCloneService,
|
||||
UsersService,
|
||||
AppImportExportService,
|
||||
],
|
||||
controllers: [DataSourcesController],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import { UsersService } from 'src/services/users.service';
|
|||
import { CaslModule } from '../casl/casl.module';
|
||||
import { EmailService } from '@services/email.service';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User, GroupPermission]), CaslModule],
|
||||
imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User, GroupPermission, App]), CaslModule],
|
||||
providers: [OrganizationsService, OrganizationUsersService, UsersService, EmailService],
|
||||
controllers: [OrganizationsController, OrganizationUsersController],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import { Organization } from '../../entities/organization.entity';
|
|||
import { User } from '../../entities/user.entity';
|
||||
import { UsersController } from 'src/controllers/users.controller';
|
||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
|
||||
@Module({
|
||||
imports: [OrganizationsModule, TypeOrmModule.forFeature([User, Organization, OrganizationUser])],
|
||||
imports: [OrganizationsModule, TypeOrmModule.forFeature([User, Organization, OrganizationUser, App])],
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm/entity-manager/EntityManager';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
import { User } from 'src/entities/user.entity';
|
||||
import { AppUser } from 'src/entities/app_user.entity';
|
||||
import { AppVersion } from 'src/entities/app_version.entity';
|
||||
import { DataSource } from 'src/entities/data_source.entity';
|
||||
import { DataQuery } from 'src/entities/data_query.entity';
|
||||
import { Credential } from 'src/entities/credential.entity';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AppCloneService {
|
||||
constructor(private readonly entityManager: EntityManager) {}
|
||||
|
||||
async perform(existingApp: App, user: User): Promise<App> {
|
||||
let clonedApp: App;
|
||||
|
||||
await this.entityManager.transaction(async (manager) => {
|
||||
clonedApp = await this.createClonedAppForUser(manager, existingApp, user);
|
||||
await this.buildClonedAppAssociations(manager, clonedApp, existingApp);
|
||||
await this.createAdminGroupPermissions(manager, clonedApp);
|
||||
});
|
||||
|
||||
return clonedApp;
|
||||
}
|
||||
|
||||
async createClonedAppForUser(manager: EntityManager, existingApp: App, currentUser: User): Promise<App> {
|
||||
const newApp = manager.create(App, {
|
||||
name: existingApp.name,
|
||||
organizationId: currentUser.organizationId,
|
||||
user: currentUser,
|
||||
});
|
||||
await manager.save(newApp);
|
||||
|
||||
const newAppUser = manager.create(AppUser, {
|
||||
app: newApp,
|
||||
user: currentUser,
|
||||
role: 'admin',
|
||||
});
|
||||
await manager.save(newAppUser);
|
||||
return newApp;
|
||||
}
|
||||
|
||||
async buildClonedAppAssociations(manager: EntityManager, newApp: App, existingApp: App) {
|
||||
const dataSourceMapping = {};
|
||||
const newDefinition = existingApp.editingVersion?.definition;
|
||||
|
||||
const existingDataSources = await manager.find(DataSource, {
|
||||
app: existingApp,
|
||||
});
|
||||
|
||||
for (const source of existingDataSources) {
|
||||
const clonedOptions = await this.cloneOptionsWithNewCredentials(manager, source.options);
|
||||
|
||||
const newSource = manager.create(DataSource, {
|
||||
app: newApp,
|
||||
name: source.name,
|
||||
options: clonedOptions,
|
||||
kind: source.kind,
|
||||
});
|
||||
|
||||
await manager.save(newSource);
|
||||
dataSourceMapping[source.id] = newSource.id;
|
||||
}
|
||||
|
||||
const existingDataQueries = await manager.find(DataQuery, {
|
||||
app: existingApp,
|
||||
});
|
||||
|
||||
for (const query of existingDataQueries) {
|
||||
const newQuery = manager.create(DataQuery, {
|
||||
app: newApp,
|
||||
name: query.name,
|
||||
options: query.options,
|
||||
kind: query.kind,
|
||||
dataSourceId: dataSourceMapping[query.dataSourceId],
|
||||
});
|
||||
await manager.save(newQuery);
|
||||
dataSourceMapping[query.id] = newQuery.id;
|
||||
}
|
||||
|
||||
const version = manager.create(AppVersion, {
|
||||
app: newApp,
|
||||
definition: newDefinition,
|
||||
name: 'v0',
|
||||
});
|
||||
await manager.save(version);
|
||||
|
||||
await manager.update(App, newApp, {
|
||||
currentVersionId: version.id,
|
||||
});
|
||||
}
|
||||
|
||||
async cloneOptionsWithNewCredentials(manager: EntityManager, options: any) {
|
||||
for (const key of Object.keys(options)) {
|
||||
if ('credential_id' in options[key]) {
|
||||
const existingCredential = await manager.findOne(Credential, {
|
||||
id: options[key]['credential_id'],
|
||||
});
|
||||
const newCredential = manager.create(Credential, {
|
||||
valueCiphertext: existingCredential.valueCiphertext,
|
||||
});
|
||||
await manager.save(newCredential);
|
||||
options[key]['credential_id'] = newCredential.id;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async createAdminGroupPermissions(manager: EntityManager, app: App) {
|
||||
const orgDefaultGroupPermissions = await manager.find(GroupPermission, {
|
||||
where: {
|
||||
organizationId: app.organizationId,
|
||||
group: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
const adminPermissions = {
|
||||
read: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
};
|
||||
|
||||
for (const groupPermission of orgDefaultGroupPermissions) {
|
||||
const appGroupPermission = manager.create(AppGroupPermission, {
|
||||
groupPermissionId: groupPermission.id,
|
||||
appId: app.id,
|
||||
...adminPermissions,
|
||||
});
|
||||
|
||||
return await manager.save(AppGroupPermission, appGroupPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,19 +8,21 @@ import { DataQuery } from 'src/entities/data_query.entity';
|
|||
import { AppVersion } from 'src/entities/app_version.entity';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
import { Credential } from 'src/entities/credential.entity';
|
||||
import { DataSourcesService } from './data_sources.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppImportExportService {
|
||||
constructor(
|
||||
@InjectRepository(App)
|
||||
private appsRepository: Repository<App>,
|
||||
private dataSourcesService: DataSourcesService,
|
||||
private readonly entityManager: EntityManager
|
||||
) {}
|
||||
|
||||
async export(user: User, id: string): Promise<App> {
|
||||
const appToExport = this.appsRepository.findOne(id, {
|
||||
relations: ['dataQueries', 'dataSources', 'appVersions'],
|
||||
where: { organizationId: user.organizationId },
|
||||
});
|
||||
|
||||
return appToExport;
|
||||
|
|
@ -53,8 +55,10 @@ export class AppImportExportService {
|
|||
name: appParams.name,
|
||||
organizationId: user.organizationId,
|
||||
user: user,
|
||||
slug: null, // Prevent db unique constraint error. App entity afterload callback will set this as id.
|
||||
isPublic: true,
|
||||
slug: null, // Prevent db unique constraint error.
|
||||
isPublic: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await manager.save(importedApp);
|
||||
return importedApp;
|
||||
|
|
@ -62,25 +66,32 @@ export class AppImportExportService {
|
|||
|
||||
async buildImportedAppAssociations(manager: EntityManager, importedApp: App, appParams: any) {
|
||||
const dataSourceMapping = {};
|
||||
const dataQueryMapping = {};
|
||||
let currentVersionId: string;
|
||||
const dataSources = appParams?.dataSources || [];
|
||||
const dataQueries = appParams?.dataQueries || [];
|
||||
const appVersions = appParams?.appVersions || [];
|
||||
|
||||
for (const source of dataSources) {
|
||||
const newOptions = await this.copyOptionsWithNewCredentials(manager, source.options);
|
||||
const convertedOptions = this.convertToArrayOfKeyValuePairs(source.options);
|
||||
// FIXME: credentials if present is created outside this db transaction and
|
||||
// will not be rolled back if import fails
|
||||
const newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions);
|
||||
|
||||
const newSource = manager.create(DataSource, {
|
||||
app: importedApp,
|
||||
name: source.name,
|
||||
kind: source.kind,
|
||||
options: newOptions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await manager.save(newSource);
|
||||
dataSourceMapping[source.id] = newSource.id;
|
||||
}
|
||||
|
||||
const newDataQueries = [];
|
||||
for (const query of dataQueries) {
|
||||
const newQuery = manager.create(DataQuery, {
|
||||
app: importedApp,
|
||||
|
|
@ -90,13 +101,24 @@ export class AppImportExportService {
|
|||
dataSourceId: dataSourceMapping[query.dataSourceId],
|
||||
});
|
||||
await manager.save(newQuery);
|
||||
|
||||
dataQueryMapping[query.id] = newQuery.id;
|
||||
newDataQueries.push(newQuery);
|
||||
}
|
||||
|
||||
for (const newQuery of newDataQueries) {
|
||||
const newOptions = this.replaceDataQueryOptionsWithNewDataQueryIds(newQuery.options, dataQueryMapping);
|
||||
newQuery.options = newOptions;
|
||||
await manager.save(newQuery);
|
||||
}
|
||||
|
||||
for (const appVersion of appVersions) {
|
||||
const version = manager.create(AppVersion, {
|
||||
app: importedApp,
|
||||
definition: appVersion.definition,
|
||||
definition: await this.replaceDataQueryIdWithinDefinitions(appVersion.definition, dataQueryMapping),
|
||||
name: appVersion.name,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await manager.save(version);
|
||||
|
|
@ -109,26 +131,6 @@ export class AppImportExportService {
|
|||
}
|
||||
}
|
||||
|
||||
async copyOptionsWithNewCredentials(manager: EntityManager, options: any) {
|
||||
for (const key of Object.keys(options)) {
|
||||
if ('credential_id' in options[key]) {
|
||||
const existingCredential = await manager.findOne(Credential, {
|
||||
id: options[key]['credential_id'],
|
||||
});
|
||||
|
||||
if (existingCredential) {
|
||||
const newCredential = manager.create(Credential, {
|
||||
valueCiphertext: existingCredential.valueCiphertext,
|
||||
});
|
||||
await manager.save(newCredential);
|
||||
options[key]['credential_id'] = newCredential.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async createAdminGroupPermissions(manager: EntityManager, app: App) {
|
||||
const orgDefaultGroupPermissions = await manager.find(GroupPermission, {
|
||||
where: {
|
||||
|
|
@ -153,4 +155,61 @@ export class AppImportExportService {
|
|||
return await manager.save(AppGroupPermission, appGroupPermission);
|
||||
}
|
||||
}
|
||||
|
||||
convertToArrayOfKeyValuePairs(options): Array<object> {
|
||||
return Object.keys(options).map((key) => {
|
||||
return {
|
||||
key: key,
|
||||
value: options[key]['value'],
|
||||
encrypted: options[key]['encrypted'],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
replaceDataQueryOptionsWithNewDataQueryIds(options, dataQueryMapping) {
|
||||
if (options && options.events) {
|
||||
const replacedEvents = options.events.map((event) => {
|
||||
if (event.queryId) {
|
||||
event.queryId = dataQueryMapping[event.queryId];
|
||||
}
|
||||
return event;
|
||||
});
|
||||
options.events = replacedEvents;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
replaceDataQueryIdWithinDefinitions(definition, dataQueryMapping) {
|
||||
if (definition?.components) {
|
||||
for (const id of Object.keys(definition.components)) {
|
||||
const component = definition.components[id].component;
|
||||
|
||||
if (component?.definition?.events) {
|
||||
const replacedComponentEvents = component.definition.events.map((event) => {
|
||||
if (event.queryId) {
|
||||
event.queryId = dataQueryMapping[event.queryId];
|
||||
}
|
||||
return event;
|
||||
});
|
||||
component.definition.events = replacedComponentEvents;
|
||||
}
|
||||
|
||||
if (component?.definition?.properties?.actions?.value) {
|
||||
for (const value of component.definition.properties.actions.value) {
|
||||
if (value?.events) {
|
||||
const replacedComponentActionEvents = value.events.map((event) => {
|
||||
if (event.queryId) {
|
||||
event.queryId = dataQueryMapping[event.queryId];
|
||||
}
|
||||
return event;
|
||||
});
|
||||
value.events = replacedComponentActionEvents;
|
||||
}
|
||||
}
|
||||
}
|
||||
definition.components[id].component = component;
|
||||
}
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { FolderApp } from 'src/entities/folder_app.entity';
|
|||
import { DataSource } from 'src/entities/data_source.entity';
|
||||
import { DataQuery } from 'src/entities/data_query.entity';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { AppCloneService } from './app_clone.service';
|
||||
import { GroupPermission } from 'src/entities/group_permission.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
import { UserGroupPermission } from 'src/entities/user_group_permission.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { AppImportExportService } from './app_import_export.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppsService {
|
||||
|
|
@ -42,8 +42,8 @@ export class AppsService {
|
|||
@InjectRepository(AppGroupPermission)
|
||||
private appGroupPermissionsRepository: Repository<AppGroupPermission>,
|
||||
|
||||
private AppCloneService: AppCloneService,
|
||||
private usersService: UsersService
|
||||
private usersService: UsersService,
|
||||
private appImportExportService: AppImportExportService
|
||||
) {}
|
||||
|
||||
async find(id: string): Promise<App> {
|
||||
|
|
@ -88,12 +88,12 @@ export class AppsService {
|
|||
})
|
||||
);
|
||||
|
||||
await this.createAdminGroupPermissions(app);
|
||||
await this.createAppGroupPermissionsForAdmin(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async createAdminGroupPermissions(app: App) {
|
||||
async createAppGroupPermissionsForAdmin(app: App) {
|
||||
const orgDefaultGroupPermissions = await this.groupPermissionsRepository.find({
|
||||
where: {
|
||||
organizationId: app.organizationId,
|
||||
|
|
@ -105,14 +105,14 @@ export class AppsService {
|
|||
const appGroupPermission = this.appGroupPermissionsRepository.create({
|
||||
groupPermissionId: groupPermission.id,
|
||||
appId: app.id,
|
||||
...this.determineDefaultAppGroupPermissions(groupPermission.group),
|
||||
...this.fetchDefaultAppGroupPermissions(groupPermission.group),
|
||||
});
|
||||
|
||||
await this.appGroupPermissionsRepository.save(appGroupPermission);
|
||||
}
|
||||
}
|
||||
|
||||
determineDefaultAppGroupPermissions(group: string): {
|
||||
fetchDefaultAppGroupPermissions(group: string): {
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
|
|
@ -128,7 +128,8 @@ export class AppsService {
|
|||
}
|
||||
|
||||
async clone(existingApp: App, user: User): Promise<App> {
|
||||
const clonedApp = await this.AppCloneService.perform(existingApp, user);
|
||||
const appWithRelations = await this.appImportExportService.export(user, existingApp.id);
|
||||
const clonedApp = await this.appImportExportService.import(user, appWithRelations);
|
||||
|
||||
return clonedApp;
|
||||
}
|
||||
|
|
@ -144,9 +145,10 @@ export class AppsService {
|
|||
)
|
||||
.where('user_group_permissions.user_id = :userId', { userId: user.id })
|
||||
.andWhere('app_group_permissions.read = :value', { value: true })
|
||||
.orWhere('apps.is_public = :value AND apps.organization_id = :organizationId', {
|
||||
.orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', {
|
||||
value: true,
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
})
|
||||
.getCount();
|
||||
}
|
||||
|
|
@ -155,6 +157,7 @@ export class AppsService {
|
|||
const viewableAppsQb = await createQueryBuilder(App, 'apps')
|
||||
.innerJoin('apps.groupPermissions', 'group_permissions')
|
||||
.innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions')
|
||||
.innerJoinAndSelect('apps.user', 'user')
|
||||
.innerJoin(
|
||||
UserGroupPermission,
|
||||
'user_group_permissions',
|
||||
|
|
@ -162,9 +165,10 @@ export class AppsService {
|
|||
)
|
||||
.where('user_group_permissions.user_id = :userId', { userId: user.id })
|
||||
.andWhere('app_group_permissions.read = :value', { value: true })
|
||||
.orWhere('apps.is_public = :value AND apps.organization_id = :organizationId', {
|
||||
.orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', {
|
||||
value: true,
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// FIXME:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||
import { User } from '../entities/user.entity';
|
||||
import { OrganizationUsersService } from './organization_users.service';
|
||||
import { EmailService } from './email.service';
|
||||
import { decamelizeKeys } from 'humps';
|
||||
const bcrypt = require('bcrypt');
|
||||
const uuid = require('uuid');
|
||||
|
||||
|
|
@ -33,13 +34,16 @@ export class AuthService {
|
|||
if (user) {
|
||||
const payload = { username: user.id, sub: user.email };
|
||||
|
||||
return {
|
||||
return decamelizeKeys({
|
||||
id: user.id,
|
||||
auth_token: this.jwtService.sign(payload),
|
||||
email: user.email,
|
||||
first_name: user.firstName,
|
||||
last_name: user.lastName,
|
||||
admin: await this.usersService.hasGroup(user, 'admin'),
|
||||
};
|
||||
group_permissions: await this.usersService.groupPermissions(user),
|
||||
app_group_permissions: await this.usersService.appGroupPermissions(user),
|
||||
});
|
||||
} else {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,9 +55,10 @@ export class FoldersService {
|
|||
)
|
||||
.where('user_group_permissions.user_id = :userId', { userId: user.id })
|
||||
.andWhere('app_group_permissions.read = :value', { value: true })
|
||||
.orWhere('apps.is_public = :value and apps.organization_id = :organizationId', {
|
||||
.orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', {
|
||||
value: true,
|
||||
organizationId: user.organizationId,
|
||||
userId: user.id,
|
||||
})
|
||||
.getMany();
|
||||
const allViewableAppIds = allViewableApps.map((app) => app.id);
|
||||
|
|
@ -122,6 +123,7 @@ export class FoldersService {
|
|||
viewableApps = [];
|
||||
} else {
|
||||
viewableApps = await createQueryBuilder(App, 'apps')
|
||||
.innerJoinAndSelect('apps.user', 'user')
|
||||
.innerJoin('apps.groupPermissions', 'group_permissions')
|
||||
.innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions')
|
||||
.innerJoin(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class GroupPermissionsService {
|
|||
if (groupPermission.group == 'admin' || groupPermission.group == 'all_users') {
|
||||
throw new BadRequestException('Cannot delete default group');
|
||||
}
|
||||
getManager().transaction(async (manager) => {
|
||||
await getManager().transaction(async (manager) => {
|
||||
const relationalEntitiesToBeDeleted = [AppGroupPermission, UserGroupPermission];
|
||||
|
||||
for (const entityToDelete of relationalEntitiesToBeDeleted) {
|
||||
|
|
@ -94,25 +94,37 @@ export class GroupPermissionsService {
|
|||
organizationId: user.organizationId,
|
||||
});
|
||||
|
||||
await this.appGroupPermissionsRepository.manager.transaction(async (manager) => {
|
||||
if (body.remove_apps) {
|
||||
const { app_create, app_delete, add_apps, remove_apps, add_users, remove_users } = body;
|
||||
|
||||
await getManager().transaction(async (manager) => {
|
||||
// update group permissions
|
||||
const groupPermissionUpdateParams = {
|
||||
...(typeof app_create === 'boolean' && { appCreate: app_create }),
|
||||
...(typeof app_delete === 'boolean' && { appDelete: app_delete }),
|
||||
};
|
||||
if (Object.keys(groupPermissionUpdateParams).length !== 0) {
|
||||
await manager.update(GroupPermission, groupPermissionId, groupPermissionUpdateParams);
|
||||
}
|
||||
|
||||
// update app group permissions
|
||||
if (remove_apps) {
|
||||
if (groupPermission.group == 'admin') {
|
||||
throw new BadRequestException('Cannot update admin group');
|
||||
}
|
||||
for (const appId of body.remove_apps) {
|
||||
manager.delete(AppGroupPermission, {
|
||||
for (const appId of remove_apps) {
|
||||
await manager.delete(AppGroupPermission, {
|
||||
appId: appId,
|
||||
groupPermissionId: groupPermissionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.add_apps) {
|
||||
if (add_apps) {
|
||||
if (groupPermission.group == 'admin') {
|
||||
throw new BadRequestException('Cannot update admin group');
|
||||
}
|
||||
for (const appId of body.add_apps) {
|
||||
manager.save(
|
||||
for (const appId of add_apps) {
|
||||
await manager.save(
|
||||
AppGroupPermission,
|
||||
manager.create(AppGroupPermission, {
|
||||
appId: appId,
|
||||
|
|
@ -122,10 +134,9 @@ export class GroupPermissionsService {
|
|||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.userGroupPermissionsRepository.manager.transaction(async (manager) => {
|
||||
if (body.remove_users) {
|
||||
// update user group permissions
|
||||
if (remove_users) {
|
||||
for (const userId of body.remove_users) {
|
||||
const params = {
|
||||
removeGroups: [groupPermission.group],
|
||||
|
|
@ -134,7 +145,7 @@ export class GroupPermissionsService {
|
|||
}
|
||||
}
|
||||
|
||||
if (body.add_users) {
|
||||
if (add_users) {
|
||||
for (const userId of body.add_users) {
|
||||
const params = {
|
||||
addGroups: [groupPermission.group],
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class OrganizationUsersService {
|
|||
private organizationUsersRepository: Repository<OrganizationUser>,
|
||||
private usersService: UsersService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async findOne(id: string): Promise<OrganizationUser> {
|
||||
return await this.organizationUsersRepository.findOne({ id: id });
|
||||
|
|
@ -28,6 +28,10 @@ export class OrganizationUsersService {
|
|||
email: params['email'],
|
||||
};
|
||||
|
||||
const existingUser = await this.usersService.findByEmail(userParams.email);
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('User with such email already exists.');
|
||||
}
|
||||
const user = await this.usersService.create(userParams, currentUser.organization, ['all_users']);
|
||||
const organizationUser = await this.create(user, currentUser.organization);
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ export class SeedsService {
|
|||
const groupPermission = manager.create(GroupPermission, {
|
||||
organizationId: user.organizationId,
|
||||
group: group,
|
||||
appCreate: group == 'admin',
|
||||
appDelete: group == 'admin',
|
||||
});
|
||||
|
||||
await manager.save(groupPermission);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { Organization } from 'src/entities/organization.entity';
|
||||
import { App } from 'src/entities/app.entity';
|
||||
import { createQueryBuilder, EntityManager, getManager, getRepository, In, Repository } from 'typeorm';
|
||||
import { OrganizationUser } from '../entities/organization_user.entity';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
|
|
@ -19,7 +20,9 @@ export class UsersService {
|
|||
@InjectRepository(OrganizationUser)
|
||||
private organizationUsersRepository: Repository<OrganizationUser>,
|
||||
@InjectRepository(Organization)
|
||||
private organizationsRepository: Repository<Organization>
|
||||
private organizationsRepository: Repository<Organization>,
|
||||
@InjectRepository(App)
|
||||
private appsRepository: Repository<App>
|
||||
) {}
|
||||
|
||||
async findOne(id: string): Promise<User> {
|
||||
|
|
@ -229,19 +232,53 @@ export class UsersService {
|
|||
async userCan(user: User, action: string, entityName: string, resourceId?: string): Promise<boolean> {
|
||||
switch (entityName) {
|
||||
case 'App':
|
||||
if (action == 'create') {
|
||||
return await this.hasGroup(user, 'admin');
|
||||
} else {
|
||||
return this.canAnyGroupPerformAction(action, await this.appGroupPermissions(user, resourceId));
|
||||
}
|
||||
return await this.canUserPerformActionOnApp(user, action, resourceId);
|
||||
|
||||
case 'User':
|
||||
return await this.hasGroup(user, 'admin');
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
canAnyGroupPerformAction(action: string, permissions: AppGroupPermission[]): boolean {
|
||||
return permissions.some((p) => p[action.toLowerCase()]);
|
||||
async canUserPerformActionOnApp(user: User, action: string, appId?: string): Promise<boolean> {
|
||||
let permissionGrant: boolean;
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
permissionGrant = this.canAnyGroupPerformAction('appCreate', await this.groupPermissions(user));
|
||||
break;
|
||||
case 'read':
|
||||
case 'update':
|
||||
permissionGrant =
|
||||
this.canAnyGroupPerformAction(action, await this.appGroupPermissions(user, appId)) ||
|
||||
(await this.isUserOwnerOfApp(user, appId));
|
||||
break;
|
||||
case 'delete':
|
||||
permissionGrant =
|
||||
this.canAnyGroupPerformAction('delete', await this.appGroupPermissions(user, appId)) ||
|
||||
this.canAnyGroupPerformAction('appDelete', await this.groupPermissions(user)) ||
|
||||
(await this.isUserOwnerOfApp(user, appId));
|
||||
break;
|
||||
default:
|
||||
permissionGrant = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return permissionGrant;
|
||||
}
|
||||
|
||||
async isUserOwnerOfApp(user, appId): Promise<boolean> {
|
||||
const app = await this.appsRepository.findOne({
|
||||
id: appId,
|
||||
userId: user.id,
|
||||
});
|
||||
return !!app;
|
||||
}
|
||||
|
||||
canAnyGroupPerformAction(action: string, permissions: AppGroupPermission[] | GroupPermission[]): boolean {
|
||||
return permissions.some((p) => p[action]);
|
||||
}
|
||||
|
||||
async groupPermissions(user: User, organizationId?: string): Promise<GroupPermission[]> {
|
||||
|
|
@ -258,15 +295,21 @@ export class UsersService {
|
|||
return await groupPermissionRepository.find({ organizationId });
|
||||
}
|
||||
|
||||
async appGroupPermissions(user: User, appId: string, organizationId?: string): Promise<AppGroupPermission[]> {
|
||||
async appGroupPermissions(user: User, appId?: string, organizationId?: string): Promise<AppGroupPermission[]> {
|
||||
const orgUserGroupPermissions = await this.userGroupPermissions(user, organizationId);
|
||||
const groupIds = orgUserGroupPermissions.map((p) => p.groupPermissionId);
|
||||
const appGroupPermissionRepository = getRepository(AppGroupPermission);
|
||||
|
||||
return await appGroupPermissionRepository.find({
|
||||
groupPermissionId: In(groupIds),
|
||||
appId: appId,
|
||||
});
|
||||
if (appId) {
|
||||
return await appGroupPermissionRepository.find({
|
||||
groupPermissionId: In(groupIds),
|
||||
appId: appId,
|
||||
});
|
||||
} else {
|
||||
return await appGroupPermissionRepository.find({
|
||||
groupPermissionId: In(groupIds),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async userGroupPermissions(user: User, organizationId?: string): Promise<UserGroupPermission[]> {
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ describe('apps controller', () => {
|
|||
expect(await AppUser.findOne({ appId: application.id })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not be possible for non-admin user to delete an app, cascaded with its versions, queries and data sources', async () => {
|
||||
it('should be possible for app creator to delete an app', async () => {
|
||||
const developer = await createUser(app, {
|
||||
email: 'developer@tooljet.io',
|
||||
groups: ['all_users', 'developer'],
|
||||
|
|
@ -306,11 +306,35 @@ describe('apps controller', () => {
|
|||
.delete(`/api/apps/${application.id}`)
|
||||
.set('Authorization', authHeaderForUser(developer.user));
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
expect(await App.findOne(application.id)).not.toBeUndefined();
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(await App.findOne(application.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be possible for non admin to delete an app', async () => {
|
||||
const adminUserData = await createUser(app, {
|
||||
email: 'admin@tooljet.io',
|
||||
groups: ['all_users', 'admin'],
|
||||
});
|
||||
const application = await createApplication(app, {
|
||||
name: 'name',
|
||||
user: adminUserData.user,
|
||||
});
|
||||
|
||||
const developerUserData = await createUser(app, {
|
||||
email: 'dev@tooljet.io',
|
||||
groups: ['all_users', 'developer'],
|
||||
organization: adminUserData.organization,
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/api/apps/${application.id}`)
|
||||
.set('Authorization', authHeaderForUser(developerUserData.user));
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
expect(await App.findOne(application.id)).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/apps/uuid/users', () => {
|
||||
|
|
|
|||
|
|
@ -541,13 +541,13 @@ describe('group permissions controller', () => {
|
|||
} = await setupOrganizations(nestApp);
|
||||
|
||||
const manager = getManager();
|
||||
const adminGroupPermission = await manager.findOne(GroupPermission, {
|
||||
const groupPermission = await manager.findOne(GroupPermission, {
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
group: 'all_users',
|
||||
},
|
||||
});
|
||||
const groupPermissionId = adminGroupPermission.id;
|
||||
const groupPermissionId = groupPermission.id;
|
||||
const appGroupPermission = await manager.findOne(AppGroupPermission, {
|
||||
groupPermissionId,
|
||||
});
|
||||
|
|
@ -576,13 +576,13 @@ describe('group permissions controller', () => {
|
|||
} = await setupOrganizations(nestApp);
|
||||
|
||||
const manager = getManager();
|
||||
const adminGroupPermission = await manager.findOne(GroupPermission, {
|
||||
const groupPermission = await manager.findOne(GroupPermission, {
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
group: 'all_users',
|
||||
},
|
||||
});
|
||||
const groupPermissionId = adminGroupPermission.id;
|
||||
const groupPermissionId = groupPermission.id;
|
||||
const appGroupPermission = await manager.findOne(AppGroupPermission, {
|
||||
groupPermissionId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { GroupPermission } from 'src/entities/group_permission.entity';
|
|||
import { AppImportExportService } from '@services/app_import_export.service';
|
||||
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
|
||||
|
||||
describe('UsersService', () => {
|
||||
describe('AppImportExportService', () => {
|
||||
let nestApp: INestApplication;
|
||||
let service: AppImportExportService;
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ describe('UsersService', () => {
|
|||
|
||||
expect(importedApp.id == exportedApp.id).toBeFalsy();
|
||||
expect(importedApp.name).toBe(exportedApp.name);
|
||||
expect(importedApp.isPublic).toBe(exportedApp.isPublic);
|
||||
expect(importedApp.isPublic).toBeFalsy();
|
||||
expect(importedApp.organizationId).toBe(exportedApp.organizationId);
|
||||
expect(importedApp.currentVersionId).toBe(null);
|
||||
expect(importedApp.appVersions).toEqual([]);
|
||||
|
|
@ -186,7 +186,7 @@ describe('UsersService', () => {
|
|||
|
||||
expect(importedApp.id == exportedApp.id).toBeFalsy();
|
||||
expect(importedApp.name).toBe(exportedApp.name);
|
||||
expect(importedApp.isPublic).toBe(exportedApp.isPublic);
|
||||
expect(importedApp.isPublic).toBeFalsy();
|
||||
expect(importedApp.organizationId).toBe(exportedApp.organizationId);
|
||||
expect(importedApp.currentVersionId).toBe(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -160,16 +160,21 @@ describe('UsersService', () => {
|
|||
|
||||
describe('.appGroupPermissions', () => {
|
||||
it('should return app group permissions for the user', async () => {
|
||||
const { defaultUser, app } = await setupOrganization(nestApp);
|
||||
const groupPermissionIdsFromApp = (await service.appGroupPermissions(defaultUser, app.id)).map(
|
||||
const { adminUser, defaultUser, app } = await setupOrganization(nestApp);
|
||||
let groupPermissionIdsFromApp = (await service.appGroupPermissions(adminUser, app.id)).map(
|
||||
(x) => x.groupPermissionId
|
||||
);
|
||||
|
||||
const groupPermissionIds = (await service.groupPermissions(defaultUser))
|
||||
const adminGroupPermissionIds = (await service.groupPermissions(adminUser))
|
||||
.filter((x) => x.group == 'admin')
|
||||
.map((x) => x.id);
|
||||
|
||||
expect(new Set(groupPermissionIdsFromApp)).toEqual(new Set(groupPermissionIds));
|
||||
expect(new Set(groupPermissionIdsFromApp)).toEqual(new Set(adminGroupPermissionIds));
|
||||
|
||||
groupPermissionIdsFromApp = (await service.appGroupPermissions(defaultUser, app.id)).map(
|
||||
(x) => x.groupPermissionId
|
||||
);
|
||||
|
||||
expect(groupPermissionIdsFromApp).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -215,6 +215,8 @@ export async function maybeCreateDefaultGroupPermissions(nestApp, organizationId
|
|||
const groupPermission = groupPermissionRepository.create({
|
||||
organizationId: organizationId,
|
||||
group: group,
|
||||
appCreate: group == 'admin',
|
||||
appDelete: group == 'admin',
|
||||
});
|
||||
await groupPermissionRepository.save(groupPermission);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue