Merge branch 'release/v0.8.0' into main

This commit is contained in:
navaneeth 2021-10-19 17:01:27 +05:30
commit 310656afb8
141 changed files with 19964 additions and 1226 deletions

View file

@ -23,7 +23,7 @@ DEFAULT_FROM_EMAIL=hello@tooljet.io
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_DOMAIN=
SMTP_ADDRESS=
SMTP_PORT=
# DISABLE USER SIGNUPS (true or false). Default: true
DISABLE_SIGNUPS=

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v14.17.3

View file

@ -1 +1 @@
0.7.4
0.8.0

View file

@ -36,7 +36,7 @@ Pull requests are the best way to propose changes to the codebase (we use [Git-F
In short, when you submit code changes, your submissions are understood to be under the same [AGPL v3 License](https://www.gnu.org/licenses/agpl-3.0.en.html) that covers the project.
## Report bugs using Github's [issues](https://github.com/ToolJet/ToolJet/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy!
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/ToolJet/ToolJet/issues/new/choose). It's that easy!
**Great Bug Reports** tend to have:
@ -52,4 +52,4 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue]
By contributing, you agree that your contributions will be licensed under its AGPL v3 License.
## Questions?
Contact us on slack [Slack](https://join.slack.com/t/tooljet/shared_invite/zt-r2neyfcw-KD1COL6t2kgVTlTtAV5rtg) or mail us at [hello@tooljet.io](hello@tooljet)
Contact us on slack [Slack](https://join.slack.com/t/tooljet/shared_invite/zt-r2neyfcw-KD1COL6t2kgVTlTtAV5rtg) or mail us at [hello@tooljet.io](mailto:hello@tooljet.io.).

View file

@ -4,7 +4,7 @@
Build and deploy internal tools.
</p>
ToolJet is an **open-source no-code framework** to build and deploy internal tools quickly without much effort from the engineering teams. You can connect to your data sources such as databases ( like PostgreSQL, MongoDB, Elasticsearch, etc ), API endpoints ( ToolJet supports importing OpenAPI spec & OAuth2 authorization) and external services ( like Stripe, Slack, Google Sheets, Airtable ) and use our pre-built UI widgets to build internal tools.
ToolJet is an **open-source no-code framework** to build and deploy internal tools quickly without much effort from the engineering teams. You can connect to your data sources such as databases ( like PostgreSQL, MongoDB, Elasticsearch, etc ), API endpoints ( ToolJet supports importing OpenAPI spec & OAuth2 authorization), and external services ( like Stripe, Slack, Google Sheets, Airtable ) and use our pre-built UI widgets to build internal tools.
![Docker Cloud Build Status](https://img.shields.io/docker/cloud/build/tooljet/tooljet-ce)
![GitHub contributors](https://img.shields.io/github/contributors/tooljet/tooljet)
@ -27,23 +27,23 @@ ToolJet is an **open-source no-code framework** to build and deploy internal too
## Features
- Visual app builder with widgets such as tables, charts, modals, buttons, dropdowns and more
- Visual app builder with widgets such as tables, charts, modals, buttons, dropdowns, and more.
- Mobile 📱 & desktop layouts 🖥
- Dark mode 🌛
- Connect to databases, APIs and external services
- Deploy on-premise ( supports docker, kubernetes, heroku and more )
- Granular access control on organization level and app level
- Write JS code almost anywhere in the builder
- Query editors for all supported data sources
- Transform query results using JS code
- Connect to databases, APIs, and external services.
- Deploy on-premise (supports docker, kubernetes, heroku, and more)
- Granular access control on organization-level and app-level.
- Write JS code almost anywhere in the builder.
- Query editors for all supported data sources.
- Transform query results using JS code.
- All the credentials are securely encrypted using `aes-256-gcm`.
- ToolJet acts only as a proxy and doesn't store any data.
- Support for OAuth
- Support for OAuth.
<hr>
## Quickstart
The easiest way to get started with ToolJet is by creating a [ToolJet Cloud](https://tooljet.io) account. ToolJet Cloud offers a hosted solution of ToolJet. If you want to self-host ToolJet, please proceed to [deployment documentation](https://docs.tooljet.io/docs/deployment/architecture).
The easiest way to get started with ToolJet is by creating a [ToolJet Cloud](https://tooljet.io) account. ToolJet Cloud offers a hosted solution of ToolJet. If you want to self-host ToolJet, kindly proceed to [deployment documentation](https://docs.tooljet.io/docs/deployment/architecture).
You can deploy ToolJet on Heroku for free using the one-click-deployment button below.
<p align="center">
@ -55,19 +55,18 @@ You can deploy ToolJet on Heroku for free using the one-click-deployment button
[Building a Github contributor leaderboard using ToolJet](https://blog.tooljet.io/building-a-github-contributor-leaderboard-using-tooljet/)<br>
## Documentation
The documentation is available at https://docs.tooljet.io
Documentation is available at https://docs.tooljet.io.
[Getting Started](https://docs.tooljet.io)<br>
[Deploying](https://docs.tooljet.io)<br>
[Datasource Reference](https://docs.tooljet.io)<br>
[Widget Reference](https://docs.tooljet.io)
- [Getting Started](https://docs.tooljet.io)<br>
- [Deploying](https://docs.tooljet.io/docs/deployment/architecture)<br>
- [Datasource Reference](https://docs.tooljet.io/docs/data-sources/airtable/)<br>
- [Widget Reference](https://docs.tooljet.io/docs/widgets/button)
## Branching model
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 labelled as v1.x.x.
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.
## Contributing
Read our contributing guide (CONTRIBUTING.md) to learn about our development process, how to propose bugfixes and improvements, and how to build and test your changes to ToolJet. <br>
[Contributing Guide](https://docs.tooljet.io/docs/contributing-guide/setup/docker)
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>
## Contributors
<a href="https://github.com/tooljet/tooljet/graphs/contributors">

View file

@ -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

View file

@ -5,12 +5,15 @@ sidebar_position: 1
# Mac OS
Follow these steps to setup and run ToolJet on Mac OS for development purposes. Open terminal and run the commands below. We recommend reading our guide on [architecture](/docs/deployment/architecture) of ToolJet before proceeding.
1. ## Setting up the environment
### Install Homebrew
## Setting up
1. Set up the environment
1.1 Install Homebrew
```bash
/bin/bash -c "(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
/bin/bash -c "(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
```
### Install Node.js ( version: v14.17.3 )
1.2 Install Node.js ( version: v14.17.3 )
```bash
brew install nvm
export NVM_DIR=~/.nvm
@ -19,7 +22,7 @@ Follow these steps to setup and run ToolJet on Mac OS for development purposes.
nvm use 14.17.3
```
### Install Postgres
1.3 Install Postgres
:::tip
ToolJet uses a postgres database as the persistent storage for storing data related to users and apps. We do not plan to support other databases such as MySQL.
:::
@ -27,13 +30,15 @@ Follow these steps to setup and run ToolJet on Mac OS for development purposes.
```bash
brew install postgresql
```
2. ## Setup environment variables
Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given here: env variable reference
2. Set up environment variables
Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given in the [environment variables reference](/docs/deployment/env-vars)
```bash
cp .env.example .env
cp .env.example .env
```
3. ## Populate the keys in the env file.
3. Populate the keys in the env file
:::info
`SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key)
@ -56,50 +61,50 @@ Follow these steps to setup and run ToolJet on Mac OS for development purposes.
ORM_LOGGING=all
```
4. ## Install dependencies
4. Install dependencies
```bash
npm install --prefix server
npm install --prefix frontend
```
5. ## Setup database
5. Set up database
```bash
npm run --prefix server db:reset
```
6. ## Install webpack & nest-cli
6. Install webpack & nest-cli
```bash
npm install -g webpack
npm install -g webpack-cli
npm install -g @nestjs/cli
```
7. ## Running the server
7. Run the server
```bash
cd ./server && npm run start:dev
```
8. ## Running the client
8. Run the client
```bash
cd ./frontend && npm start
cd ./frontend && npm start
```
The client will start on the port 8082, you can access the client by visiting: [https://localhost:8082](https://localhost:8082)
9. ## Creating login credentials
9. Create login credentials
Visiting [https://localhost:8082](https://localhost:8082) should redirect you to the login page, click on the signup link and enter your email. The emails sent by the server in development environment are captured and are opened in your default browser. Click the invitation link in the email preview to setup the account.
10. ## Running tests
## Running tests
Test config requires the presence of `.env.test` file at the root of the project.
To run the unit tests
```bash
$ npm run --prefix server test
npm run --prefix server test
```
To run e2e tests
```bash
npm run --prefix server test:e2e
```

View file

@ -3,6 +3,7 @@ sidebar_position: 1
---
# Docker
Docker compose is the easiest way to setup ToolJet server and client locally.
## Prerequisites
@ -10,9 +11,11 @@ Docker compose is the easiest way to setup ToolJet server and client locally.
Make sure you have the latest version of `docker` and `docker-compose` installed.
[Official docker installation guide](https://docs.docker.com/desktop/)
[Official docker-compose installation guide](https://docs.docker.com/compose/install/)
We recommend:
```bash
docker --version
Docker version 19.03.12, build 48a66213fe
@ -23,25 +26,26 @@ docker-compose version 1.26.2, build eefe0d31
## Setting up
1. Close the repository
1. Clone the repository
```bash
git clone https://github.com/tooljet/tooljet.git
git clone https://github.com/tooljet/tooljet.git
```
2. Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given here: env variable reference
2. Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given in the [environment variables reference](/docs/deployment/env-vars)
```bash
cp .env.example .env
cp .env.example .env.test
cp .env.example .env
cp .env.example .env.test
```
3. Populate the keys in the `.env` and `.env.test` file.
3. Populate the keys in the `.env` and `.env.test` file
:::info
`SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key)
`SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key)
`LOCKBOX_MASTER_KEY` requires a 32 byte key. (Run `openssl rand -hex 32` to create a 32 byte secure random key)
:::
Example:
```bash
cat .env
TOOLJET_HOST=http://localhost:8082
@ -73,31 +77,33 @@ docker-compose version 1.26.2, build eefe0d31
```
4. Build docker images
```bash
docker-compose build
docker-compose build
```
5. Run ToolJet
```bash
docker-compose up
docker-compose up
```
6. ToolJet server is built using NestJS and the data such as application definitions are persisted on a postgres database. You can run the below command to seed the database.
```bash
docker-compose run --rm server npm run db:seed
```
7. ToolJet should now be served locally at `http://localhost:8082`. You can login using the default user created.
```
email: dev@tooljet.io
password: password
```
```
email: dev@tooljet.io
password: password
```
8. To shut down the containers,
```bash
docker-compose stop
```
8. To shut down the containers,
```bash
docker-compose stop
```
## Making changes to the codebase
@ -107,10 +113,11 @@ Caveat:
1. If the changes include database migrations or new npm package additions in the package.json, you would need to restart the ToolJet server container by running `docker-compose restart server`.
2. If you need to add a new binary or system libary to the container itself, you would need to add those dependencies in `docker/server.Dockerfile.dev` and then rebuild the ToolJet server image. You can do that by running `docker-compose build server`. Once that completes you can start everything normally with `docker-compose up`.
2. If you need to add a new binary or system library to the container itself, you would need to add those dependencies in `docker/server.Dockerfile.dev` and then rebuild the ToolJet server image. You can do that by running `docker-compose build server`. Once that completes you can start everything normally with `docker-compose up`.
Example:
Let's say you need to install the `imagemagick` binary in your ToolJet server's container. You'd then need to make sure that `apt` installs `imagemagick` while building the image. The Dockerfile at `docker/server.Dockerfile.dev` for the server would then look something like this:
```
FROM node:14.17.0-buster
@ -137,32 +144,32 @@ COPY ./.env ../.env
RUN ["chmod", "755", "entrypoint.sh"]
```
Once you've updated the Dockerfile, rebuild the image by running `docker-compose build server`. After building the new image, start the services by running `docker-compose up`.
Once you've updated the Dockerfile, rebuild the image by running `docker-compose build server`. After building the new image, start the services by running `docker-compose up`.
## Running tests
Test config picks up config from `.env.test` file at the root of the project.
Run the following command to create and migrate data for test db
```bash
docker-compose run --rm -e NODE_ENV=test server npm run db:create
docker-compose run --rm -e NODE_ENV=test server npm run db:migrate
```
To run the unit tests
```bash
$ docker-compose --rm run server npm run test
docker-compose --rm run server npm run test
```
To run e2e tests
```bash
docker-compose run --rm server npm run test:e2e
```
To run a specific unit test
```bash
docker-compose run --rm server npm run test <path-to-file>
```

View file

@ -5,27 +5,30 @@ sidebar_position: 1
# Ubuntu
Follow these steps to setup and run ToolJet on Ubuntu. Open terminal and run the commands below.
1. ## Setting up the environment
### Install Node.js
## Setting up
1. Set up the environment
1.1 Install Node.js
```bash
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt-get install -y nodejs
```
### Install Postgres
1.2 Install Postgres
```bash
sudo apt install postgresql postgresql-contrib
sudo apt-get install libpq-dev
sudo apt install postgresql postgresql-contrib
sudo apt-get install libpq-dev
```
2. ## Setup environment variables
Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given here: env variable reference
2. Set up environment variables
Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given in the [environment variables reference](/docs/deployment/env-vars)
```bash
cp .env.example .env
cp .env.example .env
```
3. ## Populate the keys in the env file.
3. Populate the keys in the env file
:::info
`SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key)
@ -34,49 +37,50 @@ Follow these steps to setup and run ToolJet on Ubuntu. Open terminal and run the
Example:
```bash
cat .env
cat .env
TOOLJET_HOST=http://localhost:8082
LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281
SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041
```
4. ## Install dependencies
4. Install dependencies
```bash
npm install --prefix server
npm install --prefix frontend
```
5. ## Setup database
5. Set up database
```bash
npm run --prefix server db:reset
```
6. ## Running the server
6. Run the server
```bash
cd ./server && npm run start:dev
```
7. ## Running the client
7. Run the client
```bash
cd ./frontend && npm start
```
The client will start running on the port 8082, you can access the client by visiting: [https://localhost:8082](https://localhost:8082)
8. ## Creating login credentials
Visiting https://localhost:8082 should redirect you to the login page, click on the signup link and enter your email. The emails sent by the server in development environment are captured and are opened in your default browser. Click the invitation link in the email preview to setup the account.
8. Create login credentials
Visiting https://localhost:8082 should redirect you to the login page, click on the signup link and enter your email. The emails sent by the server in development environment are captured and are opened in your default browser. Click the invitation link in the email preview to setup the account.
9. ## Running tests
## Running tests
Test config requires the presence of `.env.test` file at the root of the project.
To run the unit tests
```bash
$ npm run --prefix server test
npm run --prefix server test
```
To run e2e tests
```bash
npm run --prefix server test:e2e
```

View file

@ -0,0 +1,38 @@
---
sidebar_position: 11
---
# MongoDB
ToolJet can connect to MongoDB to read and write data.
## Connection
Please make sure the host/ip of the database is accessible from your VPC if you have self-hosted ToolJet. If you are using ToolJet cloud, please whitelist our IP.
To add a new MongoDB, click on the `+` button on data sources panel at the left-bottom corner of the app editor. Select MongoDB from the modal that pops up.
ToolJet requires the following to connect to your MongoDB.
- **Host**
- **Port**
- **Username**
- **Password**
It is recommended to create a new MongoDB user so that you can control the access levels of ToolJet.
<img src="/img/datasource-reference/mo-connect.png" alt="ToolJet - Mongo connection" height="250"/>
Click on 'Test connection' button to verify if the credentials are correct and that the database is accessible to ToolJet server. Click on 'Save' button to save the datasource.
## Querying MongoDB
Click on `+` button of the query manager at the bottom panel of the editor and select the database added in the previous step as the datasource. Select the operation that you want to perform and click 'Save' to save the query.
<img src="/img/datasource-reference/mo-query.png" alt="ToolJet - Mongo query" height="250"/>
Click on the 'run' button to run the query. NOTE: Query should be saved before running.
:::tip
Query results can be transformed using transformations. Read our transformations documentation to see how: [link](/docs/tutorial/transformations)
:::

View file

@ -1,5 +1,5 @@
---
sidebar_position: 6
sidebar_position: 7
---
# Deploying ToolJet client
@ -15,14 +15,14 @@ For example: `TOOLJET_SERVER_URL=https://server.tooljet.io npm run build && fire
:::
1. Initialize firebase project
```bash
firebase init
```
Select Firebase Hosting and set build as the static file directory
```bash
firebase init
```
Select Firebase Hosting and set build as the static file directory
2. Deploy client to Firebase
```bash
firebase deploy
```
```bash
firebase deploy
```
:::tip
If you want to run ToolJet on your local machine, please checkout the setup section of the contributing guide: [link](/docs/contributing-guide/setup/docker)

View file

@ -43,18 +43,18 @@ For example, if the server is an AWS EC2 instance and the installation should re
`TOOLJET_HOST` environment variable can either be the public ipv4 address of your server or a custom domain that you want to use.
:::info
We use a [lets encrypt](https://letsencrypt.org/) plugin on top of nginx to create TLS certificates on the fly.
:::
:::info
We use a [lets encrypt](https://letsencrypt.org/) plugin on top of nginx to create TLS certificates on the fly.
:::
Examples:
`TOOLJET_HOST=http://12.34.56.78` or
`TOOLJET_HOST=https://yourdomain.com` or
`TOOLJET_HOST=https://tooljet.yourdomain.com`
:::info
Please make sure that `TOOLJET_HOST` starts with either `http://` or `https://`
:::
:::info
Please make sure that `TOOLJET_HOST` starts with either `http://` or `https://`
:::
6. Once you've populated the `.env` file, run
@ -67,7 +67,7 @@ For example, if the server is an AWS EC2 instance and the installation should re
If you're running on a linux server, `docker` might need sudo permissions. In that case you can either run:
`sudo docker-compose up -d`
OR
Setup docker to run without root privilages by following the instructions written here https://docs.docker.com/engine/install/linux-postinstall/
Setup docker to run without root privileges by following the instructions written here https://docs.docker.com/engine/install/linux-postinstall/
:::
7. If you've set a custom domain for `TOOLJET_HOST`, add a `A record` entry in your DNS settings to point to the IP address of the server.

View file

@ -1,5 +1,5 @@
---
sidebar_position: 7
sidebar_position: 8
---
# Environment variables
@ -10,30 +10,28 @@ Both the ToolJet server and client requires some environment variables to start
#### ToolJet host ( required )
| variable | description |
| ----------- | ----------- |
| variable | description |
| ------------ | --------------------------------------------------------------- |
| TOOLJET_HOST | the public URL of ToolJet client ( eg: https://app.tooljet.io ) |
#### Database configuration ( required )
ToolJet server uses PostgreSQL as the database.
| variable | description |
| ----------- | ----------- |
| PG_HOST | postgres database host |
| PG_DB | name of the database |
| PG_USER | username |
| PG_PASS | password |
| variable | description |
| -------- | ---------------------- |
| PG_HOST | postgres database host |
| PG_DB | name of the database |
| PG_USER | username |
| PG_PASS | password |
#### Lockbox configuration ( required )
ToolJet server uses lockbox to encrypt datasource credentials. You should set the environment variable `LOCKBOX_MASTER_KEY` with a 32 byte hexadecimal string.
#### Application Secret ( required )
ToolJet server uses a secure 64 byte hexadecimal string to encrypt session cookies. You should set the environment variable `SECRET_KEY_BASE`.
ToolJet server uses a secure 64 byte hexadecimal string to encrypt session cookies. You should set the environment variable `SECRET_KEY_BASE`.
:::tip
If you have `openssl` installed, you can run the following commands to generate the the value for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
@ -42,7 +40,6 @@ For `LOCKBOX_MASTER_KEY` use `openssl rand -hex 32`
For `SECRET_KEY_BASE` use `openssl rand -hex 64`
:::
#### Disabling signups ( optional )
If want to restrict the signups and allow new users only by invitations, set the environment variable `DISABLE_SIGNUPS` to `true`.
@ -53,7 +50,7 @@ You will still be able to see the signup page but won't be able to successfully
#### Serve client as a server end-point ( optional )
By default, the `SERVE_CLIENT` variable will be set to `false` and the server won't serve the client at its `/` end-point.
By default, the `SERVE_CLIENT` variable will be set to `false` and the server won't serve the client at its `/` end-point.
You can set `SERVE_CLIENT` to `true` and the server will attempt to serve the client at its root end-point (`/`).
#### SMTP configuration ( optional )
@ -61,7 +58,7 @@ You can set `SERVE_CLIENT` to `true` and the server will attempt to serve the cl
ToolJet uses SMTP services to send emails ( Eg: invitation email when you add new users to your organization ).
| variable | description |
|--------------------|-------------------------------------------|
| ------------------ | ----------------------------------------- |
| DEFAULT_FROM_EMAIL | from email for the email fired by ToolJet |
| SMTP_USERNAME | username |
| SMTP_PASSWORD | password |
@ -72,34 +69,34 @@ ToolJet uses SMTP services to send emails ( Eg: invitation email when you add ne
If your ToolJet installation requires Slack as a datasource, you need to create a Slack app and set the following environment variables:
| variable | description |
| ----------- | ----------- |
| SLACK_CLIENT_ID | client id of the slack app |
| SLACK_CLIENT_SECRET | client secret of the slack app |
| variable | description |
| ------------------- | ------------------------------ |
| SLACK_CLIENT_ID | client id of the slack app |
| SLACK_CLIENT_SECRET | client secret of the slack app |
#### Google OAuth ( optional )
If your ToolJet installation needs access to datasources such as Google sheets, you need to create OAuth credentials from Google Cloud Console.
| variable | description |
| ----------- | ----------- |
| GOOGLE_CLIENT_ID | client id |
| GOOGLE_CLIENT_SECRET | client secret |
| variable | description |
| -------------------- | ------------- |
| GOOGLE_CLIENT_ID | client id |
| GOOGLE_CLIENT_SECRET | client secret |
#### Google maps configuration ( optional )
If your ToolJet installation requires `Maps` widget, you need to create an API key for Google Maps API.
| variable | description |
| ----------- | ----------- |
| variable | description |
| ------------------- | ------------------- |
| GOOGLE_MAPS_API_KEY | Google maps API key |
#### APM VENDOR ( optional )
Specify application monitoring vendor. Currently supported values - `sentry`.
| variable | description |
| ----------- | ----------- |
| variable | description |
| ---------- | ----------------------------------------- |
| APM VENDOR | Application performance monitoring vendor |
#### SENTRY DNS ( optional )
@ -112,21 +109,23 @@ Prints logs for sentry. Supported values: `true` | `false`
Default value is `false`
#### Server URL ( optional)
This is used to set up for CSP headers and put trace info to be used with APM vendors.
| variable | description |
| ----------- | ----------- |
| variable | description |
| ------------------ | ----------------------------------------------------------- |
| TOOLJET_SERVER_URL | the URL of ToolJet server ( eg: https://server.tooljet.io ) |
#### RELEASE VERSION ( optional)
Once set any APM provider that supports segregation with releases will track it.
## ToolJet client
#### Server URL ( optionally required )
This is required when client is built separately.
| variable | description |
| ----------- | ----------- |
| variable | description |
| ------------------ | ----------------------------------------------------------- |
| TOOLJET_SERVER_URL | the URL of ToolJet server ( eg: https://server.tooljet.io ) |

View file

@ -1,5 +1,5 @@
---
sidebar_position: 2
sidebar_position: 3
sidebar_label: Heroku
---
@ -8,14 +8,15 @@ sidebar_label: Heroku
Follow the steps below to deploy ToolJet on Heroku:
1. Click the button below to start one click deployment.
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/tooljet/tooljet/tree/main)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/tooljet/tooljet/tree/main)
2. Navigate to Heroku dashboard and go to resources tab to verify that the dyno is turned on.
3. Go to settings tab on Heroku dashboard and select `reveal config vars` to configure additional environment variables that your installation might need.
3. Go to settings tab on Heroku dashboard and select `reveal config vars` to configure additional environment variables that your installation might need.
Read [environment variables reference](/docs/deployment/env-vars)
Read [environment variables reference](/docs/deployment/env-vars)
4. Open the app.
5. The default username of the admin is dev@tooljet.io and password is `password`.
5. The default username of the admin is `dev@tooljet.io` and the password is `password`.
:::tip
The one click deployment will create a free dyno and a free postgresql database.

View file

@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 6
sidebar_label: Kubernetes (GKE)
---
@ -11,52 +11,57 @@ You should setup a PostgreSQL database manually to be used by ToolJet. We recomm
Follow the steps below to deploy ToolJet on a GKE Kubernetes cluster.
1. Create an SSL certificate.
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/certificate.yaml
```
1. Create an SSL certificate.
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/certificate.yaml
```
Change the domain name to the domain/subdomain that you wish to use for ToolJet installation.
2. Reserve a static IP address using `gcloud` cli
```bash
gcloud compute addresses create tj-static-ip --global
```
2. Reserve a static IP address using `gcloud` cli
```bash
gcloud compute addresses create tj-static-ip --global
```
3. Create k8s deployment
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/deployment.yaml
```
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/deployment.yaml
```
Make sure to edit the environment variables in the `deployment.yaml`. You can check out the available options [here](https://docs.tooljet.io/docs/deployment/env-vars).
4. Create k8s service
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/service.yaml
```
4. Create k8s service
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/service.yaml
```
5. Create k8s ingress
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/ingress.yaml
```
```bash
curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/ingress.yaml
```
Change the domain name to the domain/subdomain that you wish to use for ToolJet installation.
6. Apply YAML configs
```bash
kubectl apply -f certificate.yaml, deployment.yaml, service.yaml, ingress.yaml
```
```bash
kubectl apply -f certificate.yaml, deployment.yaml, service.yaml, ingress.yaml
```
:::info
It might take a few minutes to provision the managed certificates. [Managed certificates documentation](https://cloud.google.com/kubernetes-engine/docs/how-to/managed-certs).
:::
You will be able to access your ToolJet installation once the pods, service and the ingress is running.
You will be able to access your ToolJet installation once the pods, service and the ingress is running.
If you want to seed the database with a sample user, please SSH into a pod and run:
`npm run db:seed --prefix server`.
This seeds the database with a default user with the following credentials:
email: `dev@tooljet.io`
password: `password`
If you want to seed the database with a sample user, please SSH into a pod and run:
`npm run db:seed --prefix server`.
This seeds the database with a default user with the following credentials:
email: `dev@tooljet.io`
password: `password`

View file

@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 5
sidebar_label: Kubernetes
---
@ -12,17 +12,17 @@ You should setup a PostgreSQL database manually to be used by ToolJet.
Follow the steps below to deploy ToolJet on a Kubernetes cluster.
1. Setup a PostgreSQL database
ToolJet uses a postgres database as the persistent storage for storing data related to users and apps. We do not have plans to support other databases such as MySQL.
ToolJet uses a postgres database as the persistent storage for storing data related to users and apps. We do not have plans to support other databases such as MySQL.
2. Create a Kubernetes secret with name `server`. For the minimal setup, ToolJet requires `pg_host`, `pg_db`, `pg_user`, `pg_password`, `secret_key_base` & `lockbox_key` keys in the secret.
Read [environment variables reference](/docs/deployment/env-vars)
Read [environment variables reference](/docs/deployment/env-vars)
3. Create a Kubernetes deployment
```bash
kubectl apply -f https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/deployment.yaml
```
```bash
kubectl apply -f https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/deployment.yaml
```
:::info
The file given above is just a template and might not suit production environments. You should download the file and configure parameters such as the replica count and environment variables according to your needs.
@ -30,14 +30,14 @@ The file given above is just a template and might not suit production environmen
4. Verify if ToolJet is running
```bash
kubectl get pods
```
```bash
kubectl get pods
```
5. Create a Kubernetes services to publish the Kubernetes deployment that you've created. This step varies with cloud providers. We have a [template](https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/service.yaml) for exposing the ToolJet server as a service using an AWS loadbalancer.
Examples:
Application load balancing on Amazon EKS: https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html
GKE Ingress for HTTP(S) Load Balancing: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress
Examples:
Application load balancing on Amazon EKS: https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html
GKE Ingress for HTTP(S) Load Balancing: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress
:::tip
If you want to serve ToolJet client from services such as Firebase or Netlify, please read the client deployment documentation [here](/docs/deployment/client).

View file

@ -47,5 +47,10 @@ If you're setting this environment variable, please make sure that the value doe
simply be `yourdomain.com`.
:::
:::info
### Restrict signup via SSO
Set the environment variable `SSO_DISABLE_SIGNUP` to `true` to ensure that users can only log in and not sign up via SSO. If this variable is set to `true`, only those users who have already signed up, or the ones that are invited, can access ToolJet via SSO.
:::
<br />
The Google sign-in button will now be available in your ToolJet login screen.

View file

@ -30,7 +30,7 @@ Now we will connect the `data` object of the `fetch customers` query with the ta
Let's select `data` object of the 'postgresql' query.
Since we have already run the query in previous step, the data will be immedietly displayed in the table.
Since we have already run the query in previous step, the data will be immediately displayed in the table.
<img class="screenshot-full" src="/img/tutorial/adding-widget/table-data.gif" alt="ToolJet - Table with data" height="420"/>

View file

@ -18,6 +18,6 @@ You will redirected to the visual app editor once the app has been created. The
The main components of an app:
- **Widgets** - UI components such as tables, buttons, dropdowns.
- **Data sources** - ToolJet can connect to databases, APIs and external services to fetch and modify data.
- **Queries** - Queries are used to access the connected datasources.
- **[Widgets](https://docs.tooljet.io/docs/tutorial/adding-widget)** - UI components such as tables, buttons, dropdowns.
- **[Data sources](https://docs.tooljet.io/docs/tutorial/adding-a-datasource)** - ToolJet can connect to databases, APIs and external services to fetch and modify data.
- **[Queries](https://docs.tooljet.io/docs/tutorial/building-queries)** - Queries are used to access the connected datasources.

View file

@ -4,7 +4,7 @@ sidebar_position: 8
# Mobile layout
Mobile layout is activated when the width of window is less than 600px.
Mobile layout is activated when the width of the window is less than 600px.
:::tip
Widgets can be shown on desktop, mobile, or both.
@ -20,4 +20,4 @@ Switch the layout to mobile by clicking the button on the top navigation bar. Dr
:::tip
Width of the widgets will be automatically adjusted to fit the screen while viewing the application in app viewer.
:::
:::

View file

@ -7,7 +7,7 @@ Scan QR codes using device camera and hold the data they carry.
<img class="screenshot-full" src="/img/widgets/qr-scanner/qr-scanner.jpeg" alt="ToolJet - QR Scanner" height="420"/>
#### Known issue:
In IOS, you might have to stick to the Safari browser as camera access had been restricted for third party browsers.
You might have to stick to the Safari browser in IOS as camera access is restricted for third-party browsers.
## Exposed variables
#### lastDetectedValue

View file

@ -86,7 +86,7 @@ module.exports = {
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} ToolJet.`,
copyright: `Copyright © ${new Date().getFullYear()} ToolJet Solutions, Inc.`,
},
},
presets: [

13997
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-separator" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<line x1="3" y1="12" x2="3" y2="12.01"></line>
<line x1="7" y1="12" x2="17" y2="12"></line>
<line x1="21" y1="12" x2="21" y2="12.01"></line>
</svg>

After

Width:  |  Height:  |  Size: 446 B

View file

@ -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

View file

@ -12,7 +12,9 @@ import { Editor, Viewer } from '@/Editor';
import '@/_styles/theme.scss';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { ManageGroupPermissions } from '@/ManageGroupPermissions';
import { ManageOrgUsers } from '@/ManageOrgUsers';
import { ManageGroupPermissionResources } from '@/ManageGroupPermissionResources';
import { SettingsPage } from '../SettingsPage/SettingsPage';
import { OnboardingModal } from '@/Onboarding/OnboardingModal';
import { ForgotPassword } from '@/ForgotPassword';
@ -134,6 +136,20 @@ class App extends React.Component {
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
<PrivateRoute
exact
path="/groups"
component={ManageGroupPermissions}
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
<PrivateRoute
exact
path="/groups/:id"
component={ManageGroupPermissionResources}
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
<PrivateRoute
exact
path="/settings"

View file

@ -20,6 +20,7 @@ import { QrScanner } from './Components/QrScanner/QrScanner';
import { ToggleSwitch } from './Components/Toggle';
import { RadioButton } from './Components/RadioButton';
import { StarRating } from './Components/StarRating';
import { Divider } from './Components/Divider';
import { renderTooltip } from '../_helpers/appUtils';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import '@/_styles/custom.scss';
@ -46,6 +47,7 @@ const AllComponents = {
ToggleSwitch,
RadioButton,
StarRating,
Divider,
};
export const Box = function Box({

View file

@ -42,7 +42,7 @@ export function makeOverlay(style) {
var ch;
if (stream.match('{{')) {
while ((ch = stream.next()) != null)
if (ch == '}' && stream.next() == '}') {
if (ch === '}' && stream.next() === '}') {
stream.eat('}');
return style;
}

View file

@ -3,8 +3,6 @@ import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
var tinycolor = require('tinycolor2');
export const Button = function Button({ id, width, height, component, onComponentClick, currentState }) {
console.log('currentState', currentState);
const [loadingState, setLoadingState] = useState(false);
useEffect(() => {
@ -51,7 +49,7 @@ export const Button = function Button({ id, width, height, component, onComponen
onComponentClick(id, component);
}}
>
{text}
{resolveReferences(text, currentState)}
</button>
);
};

View file

@ -11,9 +11,12 @@ export const Checkbox = function Checkbox({
onComponentOptionChanged,
onEvent,
}) {
const [checked, setChecked] = React.useState(false);
const label = component.definition.properties.label.value;
const textColorProperty = component.definition.styles.textColor;
const textColor = textColorProperty ? textColorProperty.value : '#000';
const checkboxColorProperty = component.definition.styles.checkboxColor;
const checkboxColor = checkboxColorProperty ? checkboxColorProperty.value : '#3c92dc';
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
@ -29,9 +32,10 @@ export const Checkbox = function Checkbox({
}
function toggleValue(e) {
const checked = e.target.checked;
onComponentOptionChanged(component, 'value', checked);
if (checked) {
const isChecked = e.target.checked;
setChecked(isChecked);
onComponentOptionChanged(component, 'value', isChecked);
if (isChecked) {
onEvent('onCheck', { component });
} else {
onEvent('onUnCheck', { component });
@ -48,18 +52,21 @@ export const Checkbox = function Checkbox({
onComponentClick(id, component);
}}
>
<label className="my-auto mx-2 form-check form-check-inline">
<input
className="form-check-input"
type="checkbox"
onClick={(e) => {
toggleValue(e);
}}
/>
<span className="form-check-label" style={{ color: textColor }}>
{label}
</span>
</label>
<div className="col px-1 py-0 mt-0">
<label className="mx-1 form-check form-check-inline">
<input
className="form-check-input"
type="checkbox"
onClick={(e) => {
toggleValue(e);
}}
style={{ backgroundColor: checked ? `${checkboxColor}` : 'white', marginTop: '1px' }}
/>
<span className="form-check-label" style={{ color: textColor }}>
{label}
</span>
</label>
</div>
</div>
);
};

View file

@ -0,0 +1,23 @@
import React from 'react';
import { resolveWidgetFieldValue } from '@/_helpers/utils';
export const Divider = function Divider({ id, component, onComponentClick, currentState }) {
const dividerColorProperty = component.definition.styles.dividerColor;
const color = dividerColorProperty ? dividerColorProperty.value : '#E7E8EA';
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
let parsedWidgetVisibility = widgetVisibility;
parsedWidgetVisibility = resolveWidgetFieldValue(parsedWidgetVisibility, currentState);
return (
<div
className="hr mt-1"
onClick={(event) => {
event.stopPropagation();
onComponentClick(id, component);
}}
style={{ display: parsedWidgetVisibility ? '' : 'none', color: color, opacity: '1' }}
></div>
);
};

View file

@ -1,6 +1,6 @@
/* eslint-disable react/no-string-refs */
import React from 'react';
import { Editor, EditorState, RichUtils, getDefaultKeyBinding } from 'draft-js';
import { Editor, EditorState, RichUtils, getDefaultKeyBinding, ContentState } from 'draft-js';
import 'draft-js/dist/Draft.css';
import { stateToHTML } from 'draft-js-export-html';
@ -127,7 +127,7 @@ const InlineStyleControls = (props) => {
class DraftEditor extends React.Component {
constructor(props) {
super(props);
this.state = { editorState: EditorState.createEmpty() };
this.state = { editorState: EditorState.createWithContent(ContentState.createFromText(this.props.defaultValue)) };
this.focus = () => this.refs.editor.focus();
this.onChange = (editorState) => {
@ -142,6 +142,18 @@ class DraftEditor extends React.Component {
this.toggleInlineStyle = this._toggleInlineStyle.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.defaultValue !== this.props.defaultValue) {
const newContentState = ContentState.createFromText(this.props.defaultValue);
const newEditorState = EditorState.createWithContent(newContentState);
const newEditorStateWithFocus = EditorState.moveFocusToEnd(newEditorState);
const html = stateToHTML(newContentState);
this.props.handleChange(html);
this.setState({ editorState: newEditorStateWithFocus });
}
}
_handleKeyCommand(command, editorState) {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {

View file

@ -4,7 +4,7 @@ import Button from 'react-bootstrap/Button';
import { SubCustomDragLayer } from '../SubCustomDragLayer';
import { SubContainer } from '../SubContainer';
import { ConfigHandle } from '../ConfigHandle';
import { resolveWidgetFieldValue } from '../../_helpers/utils';
import { resolveWidgetFieldValue } from '@/_helpers/utils';
export const Modal = function Modal({ id, component, height, containerProps, currentState, darkMode }) {
const [show, showModal] = useState(false);
@ -50,7 +50,7 @@ export const Modal = function Modal({ id, component, height, containerProps, cur
<ConfigHandle id={id} component={component} configHandleClicked={containerProps.onComponentClick} />
)}
<BootstrapModal.Header>
<BootstrapModal.Title>{title}</BootstrapModal.Title>
<BootstrapModal.Title>{resolveWidgetFieldValue(title, currentState)}</BootstrapModal.Title>
<div>
<Button variant={darkMode ? 'secondary' : 'light'} size="sm" onClick={hideModal}>
x

View file

@ -6,13 +6,13 @@ import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
export const QrScanner = function QrScanner({ component, onEvent, onComponentOptionChanged, currentState }) {
const handleError = async (errorMessage) => {
console.log(errorMessage);
setErrorOccured(true);
await setErrorOccured(true);
};
const handleScan = async (data) => {
if (data != null) {
onEvent('onDetect', { component, data: data });
onComponentOptionChanged(component, 'lastDetectedValue', data);
if (data !== null || data !== undefined) {
await onEvent('onDetect', { component, data: data });
await onComponentOptionChanged(component, 'lastDetectedValue', data);
}
};

View file

@ -85,20 +85,21 @@ export const RadioButton = function RadioButton({
return (
<div
data-disabled={parsedDisabledState}
className="row"
className="row py-1"
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
onClick={(event) => {
event.stopPropagation();
onComponentClick(id, component);
}}
>
<span className="form-check-label col-auto py-1" style={{ color: textColor }}>
<span className="form-check-label col-auto py-0" style={{ color: textColor }}>
{label}
</span>
<div className="col py-1">
<div className="col px-1 py-0 mt-0">
{selectOptions.map((option, index) => (
<label key={index} className="form-check form-check-inline">
<input
style={{ marginTop: '1px' }}
className="form-check-input"
checked={value === option.value}
type="radio"

View file

@ -15,6 +15,7 @@ export const RichTextEditor = function RichTextEditor({
const placeholder = component.definition.properties.placeholder.value;
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
const defaultValue = component.definition.properties?.defaultValue?.value ?? '';
const parsedDisabledState =
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
@ -40,7 +41,13 @@ export const RichTextEditor = function RichTextEditor({
onComponentClick(id, component);
}}
>
<DraftEditor handleChange={handleChange} height={height} width={width} placeholder={placeholder}></DraftEditor>
<DraftEditor
handleChange={handleChange}
height={height}
width={width}
placeholder={placeholder}
defaultValue={defaultValue}
></DraftEditor>
</div>
);
};

View file

@ -104,25 +104,27 @@ export const StarRating = function StarRating({
<span className="label form-check-label form-check-label col-auto" style={{ color: labelColor }}>
{label}
</span>
{animatedStars.map((props, index) => (
<Star
tooltip={getTooltip(index)}
active={getActive(index)}
isHalfStar={isHalfStar(index)}
maxRating={maxRating}
onClick={(e, idx) => {
e.stopPropagation();
setRatingIndex(idx);
handleClick(idx);
}}
allowHalfStar={allowHalfStar}
key={index}
index={index}
color={color}
style={{ ...props }}
setHoverIndex={setHoverIndex}
/>
))}
<div className="col px-1 py-0 mt-0">
{animatedStars.map((props, index) => (
<Star
tooltip={getTooltip(index)}
active={getActive(index)}
isHalfStar={isHalfStar(index)}
maxRating={maxRating}
onClick={(e, idx) => {
e.stopPropagation();
setRatingIndex(idx);
handleClick(idx);
}}
allowHalfStar={allowHalfStar}
key={index}
index={index}
color={color}
style={{ ...props }}
setHoverIndex={setHoverIndex}
/>
))}
</div>
</div>
);
};

View file

@ -48,7 +48,7 @@ const Star = ({
}
function roundValueToPrecision(value, precision) {
if (value == null) {
if (value === null || value === undefined) {
return value;
}

View file

@ -630,6 +630,7 @@ export function Table({
marginTop: 8,
marginLeft: 10,
}}
onClick={(event) => event.stopPropagation()}
{...rest}
/>
</>
@ -820,7 +821,7 @@ export function Table({
<tr
key={index}
className={`table-row ${
highlightSelectedRow && row.id == componentState.selectedRowId ? 'selected' : ''
highlightSelectedRow && row.id === componentState.selectedRowId ? 'selected' : ''
}`}
{...row.getRowProps()}
onClick={(e) => {

View file

@ -8,7 +8,7 @@ class Switch extends React.Component {
return (
<label className="form-switch form-check-inline">
<input
style={{ backgroundColor: on ? `${color}` : 'white' }}
style={{ backgroundColor: on ? `${color}` : 'white', marginTop: '0px' }}
disabled={disabledState}
className="form-check-input"
type="checkbox"
@ -61,7 +61,7 @@ export const ToggleSwitch = ({
return (
<div
className="row"
className="row py-1"
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
onClick={(event) => {
event.stopPropagation();
@ -71,7 +71,7 @@ export const ToggleSwitch = ({
<span className="form-check-label form-check-label col-auto my-auto" style={{ color: textColor }}>
{label}
</span>
<div className="col px-1 py-0 my-auto">
<div className="col px-1 py-0 mt-0">
<Switch
disabledState={parsedDisabledState}
on={on}

View file

@ -336,7 +336,7 @@ export const componentTypes = [
showOnMobile: { value: false },
},
properties: {
value: { value: '' },
value: { value: '99' },
placeholder: { value: '0' },
},
events: [],
@ -385,7 +385,7 @@ export const componentTypes = [
customRule: { value: null },
},
properties: {
defaultValue: { value: '' },
defaultValue: { value: '01/04/2021' },
format: { value: 'DD/MM/YYYY' },
enableTime: { value: '{{false}}' },
enableDate: { value: '{{true}}' },
@ -419,6 +419,7 @@ export const componentTypes = [
},
styles: {
textColor: { type: 'color', displayName: 'Text Color' },
checkboxColor: { type: 'color', displayName: 'Checkbox Color' },
visibility: { type: 'code', displayName: 'Visibility' },
disabledState: { type: 'code', displayName: 'Disable' },
},
@ -434,6 +435,7 @@ export const componentTypes = [
events: [],
styles: {
textColor: { value: '#000' },
checkboxColor: { value: '#3c92dc' },
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
},
@ -561,7 +563,10 @@ export const componentTypes = [
showOnMobile: { value: false },
},
properties: {
value: { value: '' },
value: {
value:
'ToolJet is an open-source low-code platform for building and deploying internal tools with minimal engineering efforts 🚀',
},
placeholder: { value: 'Placeholder text' },
},
events: [],
@ -643,7 +648,7 @@ export const componentTypes = [
properties: {
text: { value: 'Text goes here !' },
visible: { value: true },
loadingState: { value: false },
loadingState: { value: `{{false}}` },
},
events: [],
styles: {
@ -847,6 +852,7 @@ export const componentTypes = [
},
properties: {
placeholder: { type: 'code', displayName: 'Placeholder' },
defaultValue: { type: 'code', displayName: 'Default Value' },
},
events: {},
styles: {
@ -854,7 +860,7 @@ export const componentTypes = [
disabledState: { type: 'code', displayName: 'Disable' },
},
exposedVariables: {
value: {},
value: '',
},
definition: {
others: {
@ -863,6 +869,7 @@ export const componentTypes = [
},
properties: {
placeholder: { value: 'Placeholder text' },
defaultValue: { value: '' },
},
events: [],
styles: {
@ -1031,4 +1038,39 @@ export const componentTypes = [
},
},
},
{
name: 'Divider',
displayName: 'Divider',
description: 'Separator between components',
component: 'Divider',
defaultSize: {
width: 200,
height: 10,
},
others: {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
properties: {},
events: {},
styles: {
dividerColor: { type: 'color', displayName: 'Divider Color' },
visibility: { type: 'code', displayName: 'Visibility' },
},
exposedVariables: {
value: {},
},
definition: {
others: {
showOnDesktop: { value: true },
showOnMobile: { value: false },
},
properties: {},
events: [],
styles: {
dividerColor: { value: '#E7E8EA' },
visibility: { value: '{{true}}' },
},
},
},
];

View file

@ -116,7 +116,6 @@ export const Container = ({
deltaY = delta.y;
}
componentData = item.component;
left = Math.round(currentLayoutOptions.left + deltaX);
top = Math.round(currentLayoutOptions.top + deltaY);
@ -311,7 +310,12 @@ export const Container = ({
{Object.keys(boxes).length === 0 && !appLoading && !isDragging && (
<div className="mx-auto w-50 p-5 bg-light no-components-box" style={{ marginTop: '10%' }}>
<center className="text-muted">
You haven&apos;t added any components yet. Drag components from the right sidebar and drop here.
You haven&apos;t added any components yet. Drag components from the right sidebar and drop here. Check out
our{' '}
<a href="https://docs.tooljet.io/docs/tutorial/adding-widget" target="_blank" rel="noreferrer">
guide
</a>{' '}
on adding widgets.
</center>
</div>
)}

View file

@ -19,7 +19,7 @@ function getItemStyles(delta, item, initialOffset, currentOffset, currentLayout)
display: 'none',
};
}
let { x, y } = currentOffset;
let x, y;
let id = item.id;

View file

@ -41,7 +41,7 @@ class DataSourceManager extends React.Component {
}
componentDidUpdate(prevProps) {
if (prevProps.selectedDataSource != this.props.selectedDataSource) {
if (prevProps.selectedDataSource !== this.props.selectedDataSource) {
this.setState({
selectedDataSource: this.props.selectedDataSource,
options: this.props.selectedDataSource?.options,

View file

@ -11,7 +11,7 @@ export const TestConnection = ({ kind, options, onConnectionTestFailed }) => {
useEffect(() => {
if (isTesting) {
setButtonText('Testing connection...');
} else if (!isTesting && connectionStatus === 'success') {
} else if (connectionStatus === 'success') {
setButtonText('Connection verified');
} else {
setButtonText('Test Connection');
@ -54,7 +54,7 @@ export const TestConnection = ({ kind, options, onConnectionTestFailed }) => {
<Button
className="m-2"
variant="success"
disabled={isTesting || (!isTesting && !(connectionStatus !== 'success'))}
disabled={isTesting || connectionStatus === 'success'}
onClick={testDataSource}
>
{buttonText}

View file

@ -213,7 +213,7 @@ class Editor extends React.Component {
};
switchSidebarTab = (tabIndex) => {
if (tabIndex == 2) {
if (tabIndex === 2) {
this.setState({ selectedComponent: null });
}
this.setState({
@ -575,7 +575,7 @@ class Editor extends React.Component {
value={this.state.app.name}
/>
)}
<small>{this.state.editingVersion && `Editing version: ${this.state.editingVersion.name}`}</small>
<small>{this.state.editingVersion && `App version: ${this.state.editingVersion.name}`}</small>
<div className="editor-buttons">
<span
className={`btn btn-light mx-2`}
@ -864,6 +864,7 @@ class Editor extends React.Component {
componentTypes={componentTypes}
zoomLevel={zoomLevel}
currentLayout={currentLayout}
darkMode={this.props.darkMode}
></WidgetManager>
)}
</div>

View file

@ -24,6 +24,29 @@ export const EventManager = ({
return { name: action.name, value: action.id };
});
let alertTypes = [
{
name: 'Info',
id: 'info',
},
{
name: 'Success',
id: 'success',
},
{
name: 'Warning',
id: 'warning',
},
{
name: 'Danger',
id: 'error',
},
];
let alertOptions = alertTypes.map((alert) => {
return { name: alert.name, value: alert.id };
});
excludeEvents = excludeEvents || [];
/* Filter events based on excludesEvents ( a list of event ids to exclude ) */
@ -53,8 +76,8 @@ export const EventManager = ({
function getAllApps() {
let appsOptionsList = [];
apps
.filter((item) => item.slug != undefined)
.map((item) => {
.filter((item) => item.slug !== undefined)
.forEach((item) => {
appsOptionsList.push({
name: item.name,
value: item.slug,
@ -128,16 +151,31 @@ export const EventManager = ({
<div className="hr-text">Action options</div>
<div>
{event.actionId === 'show-alert' && (
<div className="row">
<div className="col-3 p-2">Message</div>
<div className="col-9">
<CodeHinter
currentState={currentState}
initialValue={event.message}
onChange={(value) => handlerChanged(index, 'message', value)}
/>
<>
<div className="row">
<div className="col-3 p-2">Message</div>
<div className="col-9">
<CodeHinter
currentState={currentState}
initialValue={event.message}
onChange={(value) => handlerChanged(index, 'message', value)}
/>
</div>
</div>
</div>
<div className="row mt-3">
<div className="col-3 p-2">Alert Type</div>
<div className="col-9">
<SelectSearch
options={alertOptions}
value={event.alertType}
search={false}
onChange={(value) => handlerChanged(index, 'alertType', value)}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
</div>
</div>
</>
)}
{event.actionId === 'open-webpage' && (

View file

@ -36,7 +36,7 @@ export const LeftSidebarDebugger = ({ darkMode, errors }) => {
])(errors);
const errorData = [];
Object.entries(newError).map(([key, value]) => {
Object.entries(newError).forEach(([key, value]) => {
const variableNames = {
options: '',
response: '',
@ -46,6 +46,10 @@ export const LeftSidebarDebugger = ({ darkMode, errors }) => {
variableNames.options = 'substitutedVariables';
variableNames.response = 'response';
break;
case 'transformations':
variableNames.response = 'data';
break;
default:
'options';
}
@ -168,27 +172,28 @@ function ErrorLogsComponent({ errorProps, idx, darkMode }) {
height="16"
/>
[{_.capitalize(errorProps.type)} {errorProps.key}] &nbsp;
<span className="text-red">
{`Query Failed: ${errorProps.description}`} {errorProps.message}.
</span>
<span className="text-red">{`${_.startCase(errorProps.type)} Failed: ${errorProps.message}`} .</span>
<br />
<small className="text-muted px-1">{moment(errorProps.timestamp).fromNow()}</small>
</p>
<div className={` queryData ${open ? 'open' : 'close'} py-0`}>
<span>
<ReactJson
src={errorProps.options.data}
theme={darkMode ? 'shapeshifter' : 'rjv-default'}
name={errorProps.options.name}
style={{ fontSize: '0.7rem', paddingLeft: '0.17rem' }}
enableClipboard={false}
displayDataTypes={false}
collapsed={true}
displayObjectSize={false}
quotesOnKeys={false}
sortKeys={false}
/>
</span>
{errorProps.type === 'query' && (
<span>
<ReactJson
src={errorProps.options.data}
theme={darkMode ? 'shapeshifter' : 'rjv-default'}
name={errorProps.options.name}
style={{ fontSize: '0.7rem', paddingLeft: '0.17rem' }}
enableClipboard={false}
displayDataTypes={false}
collapsed={true}
displayObjectSize={false}
quotesOnKeys={false}
sortKeys={false}
/>
</span>
)}
<span>
<ReactJson
src={errorProps.response.data}

View file

@ -8,7 +8,7 @@ export const SidebarPinnedButton = ({ state, component, updateState, darkMode })
return (
<SidebarPinnedButton.OverlayContainer tip={tooltipMsg}>
<div
className={`btn btn-sm m-1 ${darkMode ? 'btn-outline-secondary' : 'btn-default'} ${state && 'active'} ${
className={`btn btn-sm btn-light m-1 ${darkMode && 'btn-outline-secondary'} ${state && 'active'} ${
component === 'Inspector' && 'position-absolute end-0'
}`}
onClick={updateState}

View file

@ -1,6 +1,6 @@
import '@/_styles/left-sidebar.scss';
import React from 'react';
import React, { useState } from 'react';
import { LeftSidebarItem } from './sidebar-item';
import { LeftSidebarInspector } from './sidebar-inspector';
@ -9,6 +9,7 @@ import { LeftSidebarZoom } from './sidebar-zoom';
import { DarkModeToggle } from '../../_components/DarkModeToggle';
import useRouter from '../../_hooks/use-router';
import { LeftSidebarDebugger } from './SidebarDebugger';
import { ConfirmDialog } from '@/_components';
export const LeftSidebar = ({
appId,
@ -23,6 +24,7 @@ export const LeftSidebar = ({
errorLogs,
}) => {
const router = useRouter();
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
return (
<div className="left-sidebar">
<LeftSidebarInspector darkMode={darkMode} globals={globals} components={components} queries={queries} />
@ -34,11 +36,17 @@ export const LeftSidebar = ({
/>
<LeftSidebarDebugger darkMode={darkMode} components={components} errors={errorLogs} />
<LeftSidebarItem
onClick={() => router.push('/')}
onClick={() => setShowLeaveDialog(true)}
tip="Back to home"
icon="back"
className="left-sidebar-item no-border"
/>
<ConfirmDialog
show={showLeaveDialog}
message={'The unsaved changes will be lost if you leave the editor, do you want to leave?'}
onConfirm={() => router.push('/')}
onCancel={() => setShowLeaveDialog(false)}
/>
<div className="left-sidebar-stack-bottom">
<LeftSidebarZoom onZoomChanged={onZoomChanged} />
<div className="left-sidebar-item no-border">

View file

@ -297,7 +297,7 @@ class ManageAppUsers extends React.Component {
</Modal.Body>
<Modal.Footer>
<a href="/users" target="_blank">
<a href="/users" target="_blank" className="btn btn-outline-azure mt-3">
Manage Organization Users
</a>
</Modal.Footer>

View file

@ -0,0 +1,21 @@
import React from 'react';
export const BaseUrl = ({ dataSourceURL, theme }) => {
return (
<span
htmlFor=""
style={{
padding: '7px',
border: theme === 'default' ? '1px solid rgb(217 220 222)' : '1px solid #2c3a4c',
background: theme === 'default' ? 'rgb(246 247 251)' : '#20211e',
color: theme === 'default' ? '#9ca1a6' : '#9e9e9e',
marginRight: '-3px',
borderTopLeftRadius: '3px',
borderBottomLeftRadius: '3px',
zIndex: 1,
}}
>
{dataSourceURL}
</span>
);
};

View file

@ -7,6 +7,7 @@ import Tabs from './Tabs';
import { changeOption } from '../utils';
import { CodeHinter } from '../../../CodeBuilder/CodeHinter';
import { BaseUrl } from './BaseUrl';
class Restapi extends React.Component {
constructor(props) {
@ -100,21 +101,7 @@ class Restapi extends React.Component {
<div className="col" style={{ display: 'flex' }}>
{dataSourceURL && (
<span
htmlFor=""
style={{
padding: '7px',
border: '1px solid rgb(217 220 222)',
background: 'rgb(246 247 251)',
color: '#9ca1a6',
marginRight: '-3px',
borderTopLeftRadius: '3px',
borderBottomLeftRadius: '3px',
zIndex: 1,
}}
>
{dataSourceURL}
</span>
<BaseUrl theme={this.props.darkMode ? 'monokai' : 'default'} dataSourceURL={dataSourceURL} />
)}
<CodeHinter
currentState={this.props.currentState}

View file

@ -151,6 +151,7 @@ class SaveAndPreview extends React.Component {
</div>
) : (
<div className="table-responsive">
{!versions?.length && !showVersionForm && !isLoading && <div>No versions yet.</div>}
<table className="table table-vcenter">
<tbody>
{versions.map((version) => (
@ -177,7 +178,7 @@ class SaveAndPreview extends React.Component {
<button
className="btn btn btn-sm mx-2 text-muted"
onClick={() => this.props.setAppDefinitionFromVersion(version)}
disabled={this.props.editingVersionId == version.id}
disabled={this.props.editingVersionId === version.id}
>
edit
</button>

View file

@ -305,7 +305,13 @@ export const SubContainer = ({
{Object.keys(boxes).length === 0 && !appLoading && !isDragging && (
<div className="mx-auto mt-5 w-50 p-5 bg-light no-components-box">
<center className="text-muted">Drag components from the right sidebar and drop here.</center>
<center className="text-muted">
Drag components from the right sidebar and drop here. Check out our{' '}
<a href="https://docs.tooljet.io/docs/tutorial/adding-widget" target="_blank" rel="noreferrer">
guide
</a>{' '}
on adding widgets.
</center>
</div>
)}
{appLoading && (

View file

@ -25,7 +25,7 @@ function getItemStyles(delta, item, initialOffset, currentOffset, parentRef, par
};
}
let { x, y } = currentOffset;
let x, y;
let id = item.id;

View file

@ -3,11 +3,11 @@ import { DraggableBox } from './DraggableBox';
import Fuse from 'fuse.js';
import { isEmpty } from 'lodash';
export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel, currentLayout }) {
export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel, currentLayout, darkMode }) {
const [filteredComponents, setFilteredComponents] = useState(componentTypes);
function filterComponents(value) {
if (value != '') {
if (value !== '') {
const fuse = new Fuse(componentTypes, { keys: ['component'] });
const results = fuse.search(value);
setFilteredComponents(results.map((result) => result.item));
@ -46,7 +46,7 @@ export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel,
<img src="./static/illustrations/undraw_printing_invoices_5r4r.svg" height="128" alt="" />
</div> */}
<p className="empty-title">No results found</p>
<p className="empty-subtitle text-muted">
<p className={`empty-subtitle ${darkMode ? 'text-white-50' : 'text-secondary'}`}>
Try adjusting your search or filter to find what you&apos;re looking for.
</p>
</div>
@ -71,7 +71,7 @@ export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel,
];
const integrationItems = ['Map'];
filteredComponents.map((f) => {
filteredComponents.forEach((f) => {
if (commonItems.includes(f.name)) commonSection.items.push(f);
if (formItems.includes(f.name)) formSection.items.push(f);
else if (integrationItems.includes(f.name)) integrationSection.items.push(f);

View file

@ -17,13 +17,12 @@ class ForgotPassword extends React.Component {
handleChange = (event) => {
this.setState({ [event.target.name]: event.target.value });
if (event.target.value == '') {
if (event.target.value === '') {
this.setState({ isEmailFound: false, buttonClicked: false });
}
};
handleClick = (event) => {
this.setState({ buttonClicked: true });
event.preventDefault();
fetch(`${config.apiUrl}/forgot_password`, {
@ -38,6 +37,7 @@ class ForgotPassword extends React.Component {
this.setState({ isEmailFound: true });
return res.json();
} else {
this.setState({ buttonClicked: true });
this.setState({ isEmailFound: false });
}
})
@ -58,7 +58,7 @@ class ForgotPassword extends React.Component {
<div className="page page-center">
<div className="container-tight py-2">
<div className="text-center mb-4">
<a href=".">
<a href="." className="navbar-brand-autodark">
<img src="/assets/images/logo-text.svg" height="30" alt="" />
</a>
</div>
@ -86,7 +86,7 @@ class ForgotPassword extends React.Component {
onClick={this.handleClick}
disabled={!this.state.email}
>
Submit
Reset Password
</button>
</div>
</div>

View file

@ -6,7 +6,7 @@ import Fuse from 'fuse.js';
import { folderService } from '@/_services';
import { toast } from 'react-toastify';
export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteApp, cloneApp }) {
export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteApp, cloneApp, exportApp }) {
const [addToFolder, setAddToFolder] = useState(false);
const [isAdding, setIsAdding] = useState(false);
@ -70,7 +70,7 @@ export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteAp
<div>
<div className="field mb-2">
<span role="button" onClick={() => setAddToFolder(true)}>
Add to folder{' '}
Add to folder
</span>
</div>
<div className="field mb-2">
@ -78,9 +78,14 @@ export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteAp
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{' '}
Delete app
</span>
</div>
</div>
@ -105,6 +110,7 @@ export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteAp
onChange={(newVal) => {
addAppToFolder(app.id, newVal);
}}
emptyMessage={folders.length === 0 ? 'No folders present' : 'Not found'}
filterOptions={customFuzzySearch}
placeholder="Select folder"
/>

View file

@ -22,6 +22,13 @@ export const Folders = function Folders({
const [activeFolder, setActiveFolder] = useState(currentFolder || {});
function saveFolder() {
if (!newFolderName || !newFolderName.trim()) {
toast.warn("folder name can't be empty.", {
hideProgressBar: true,
position: 'top-left',
});
return;
}
setCreationStatus(true);
folderService.create(newFolderName).then(() => {
toast.info('folder created.', {
@ -90,7 +97,7 @@ export const Folders = function Folders({
))}
<hr />
{!showForm && (
<a className="mx-3" onClick={() => setShowForm(true)}>
<a className="mx-3 fw-500" onClick={() => setShowForm(true)}>
+ Folder
</a>
)}
@ -98,8 +105,6 @@ export const Folders = function Folders({
<div className="p-2 row">
<div className="col">
<input
// eslint-disable-next-line no-undef
onClick={() => onComponentClick(id, component)} //onComponentClick, id and compoenent is not defined
type="text"
onChange={(e) => setNewFolderName(e.target.value)}
className="form-control"

View file

@ -13,6 +13,7 @@ class HomePage extends React.Component {
constructor(props) {
super(props);
this.fileInput = React.createRef();
this.state = {
currentUser: authenticationService.currentUserValue,
users: null,
@ -20,7 +21,10 @@ class HomePage extends React.Component {
creatingApp: false,
isDeletingApp: false,
isCloningApp: false,
isExportingApp: false,
isImportingApp: false,
currentFolder: {},
currentPage: 1,
showAppDeletionConfirmation: false,
apps: [],
folders: [],
@ -81,10 +85,15 @@ class HomePage extends React.Component {
createApp = () => {
let _self = this;
_self.setState({ creatingApp: true });
appService.createApp().then((data) => {
console.log(data);
_self.props.history.push(`/apps/${data.id}`);
});
appService
.createApp()
.then((data) => {
console.log(data);
_self.props.history.push(`/apps/${data.id}`);
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
deleteApp = (app) => {
@ -104,12 +113,95 @@ class HomePage extends React.Component {
this.props.history.push(`/apps/${data.id}`);
})
.catch(({ _error }) => {
toast.error('Could not clone the app.', { hideProgressBar: true, position: 'top-center' });
toast.error('Could not clone the app.', {
hideProgressBar: true,
position: 'top-center',
});
this.setState({ isCloningApp: false });
console.log(_error);
});
};
exportApp = (app) => {
this.setState({ isExportingApp: true });
appService
.exportApp(app.id)
.then((data) => {
const appName = app.name.replace(/\s+/g, '-').toLowerCase();
const fileName = `${appName}-export-${new Date().getTime()}`;
// simulate link click download
const json = JSON.stringify(data);
const blob = new Blob([json], { type: 'application/json' });
const href = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = href;
link.download = fileName + '.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.fileInput.value = '';
this.setState({ isExportingApp: false });
})
.catch(({ _error }) => {
toast.error('Could not export the app.', {
hideProgressBar: true,
position: 'top-center',
});
this.fileInput.value = '';
this.setState({ isExportingApp: false });
console.log(_error);
});
};
handleImportApp = (event) => {
const fileReader = new FileReader();
fileReader.readAsText(event.target.files[0], 'UTF-8');
fileReader.onload = (event) => {
const fileContent = event.target.result;
this.setState({ isImportingApp: true });
try {
const requestBody = JSON.parse(fileContent);
appService
.importApp(requestBody)
.then(() => {
toast.info('App imported successfully.', {
hideProgressBar: true,
position: 'top-center',
});
this.setState({
isImportingApp: false,
});
this.fetchApps(this.state.currentPage, this.state.currentFolder.id);
})
.catch(({ error }) => {
toast.error(`Could not import the app: ${error}`, {
hideProgressBar: true,
position: 'top-center',
});
this.setState({
isImportingApp: false,
});
});
} catch (error) {
toast.error(`Could not import the app: ${error}`, {
hideProgressBar: true,
position: 'top-center',
});
this.setState({
isImportingApp: false,
});
}
};
};
isAppEditable = (app) => {
return app.app_group_permissions.some((p) => p.update);
};
isAppDeletable = (app) => {
return app.app_group_permissions.some((p) => p.delete);
};
executeAppDeletion = () => {
this.setState({ isDeletingApp: true });
appService
@ -129,7 +221,10 @@ class HomePage extends React.Component {
this.fetchFolders();
})
.catch(({ error }) => {
toast.error('Could not delete the app.', { hideProgressBar: true, position: 'top-center' });
toast.error('Could not delete the app.', {
hideProgressBar: true,
position: 'top-center',
});
this.setState({
isDeletingApp: false,
appToBeDeleted: null,
@ -146,13 +241,13 @@ class HomePage extends React.Component {
render() {
const {
apps,
currentUser,
isLoading,
creatingApp,
meta,
currentFolder,
showAppDeletionConfirmation,
isDeletingApp,
isImportingApp,
} = this.state;
return (
<div className="wrapper home-page">
@ -192,18 +287,34 @@ class HomePage extends React.Component {
</h2>
</div>
<div className="col-auto ms-auto d-print-none">
<button
className={`btn btn-primary d-none d-lg-inline ${creatingApp ? 'btn-loading' : ''}`}
onClick={this.createApp}
>
Create new application
</button>
<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>
<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
currentFolder.count === 0
? 'table-responsive w-100 apps-table mt-3 d-flex align-items-center'
: 'table-responsive w-100 apps-table mt-3'
}
@ -241,16 +352,21 @@ class HomePage extends React.Component {
<td className="col p-3">
<span className="app-title mb-3">{app.name}</span> <br />
<small className="pt-2 app-description">
created {moment(app.created_at).fromNow()} ago by {app.user?.first_name}{' '}
created {moment(app.created_at).fromNow(true)} ago by {app.user?.first_name}{' '}
{app.user?.last_name}{' '}
</small>
</td>
<td className="text-muted col-auto pt-4">
{currentUser.role !== 'viewer' && (
{!isLoading && this.isAppEditable(app) && (
<Link to={`/apps/${app.id}`} className="d-none d-lg-inline">
<OverlayTrigger
placement="top"
overlay={(props) => renderTooltip({ props, text: 'Open in app builder' })}
overlay={(props) =>
renderTooltip({
props,
text: 'Open in app builder',
})
}
>
<span className="badge bg-green-lt">Edit</span>
</OverlayTrigger>
@ -267,7 +383,7 @@ class HomePage extends React.Component {
renderTooltip({
props,
text:
app?.current_version_id == null
app?.current_version_id === null
? 'App does not have a deployed version'
: 'Open in app viewer',
})
@ -293,7 +409,7 @@ class HomePage extends React.Component {
renderTooltip({
props,
text:
app?.current_version_id == null
app?.current_version_id === null
? 'App does not have a deployed version'
: 'Open in app viewer',
})
@ -302,14 +418,14 @@ class HomePage extends React.Component {
{
<span
className={`${
app?.current_version_id == null
app?.current_version_id === null
? 'badge mx-2 '
: 'badge bg-azure-lt mx-2'
}`}
style={{
filter:
app?.current_version_id == null
? 'brightness(0.8)'
app?.current_version_id === null
? 'brightness(0.3)'
: 'brightness(1) invert(1)',
}}
>
@ -320,19 +436,22 @@ class HomePage extends React.Component {
)}
</Link>
<AppMenu
app={app}
folders={this.state.folders}
foldersChanged={this.foldersChanged}
deleteApp={() => this.deleteApp(app)}
cloneApp={() => this.cloneApp(app)}
/>
{this.isAppDeletable(app) && (
<AppMenu
app={app}
folders={this.state.folders}
foldersChanged={this.foldersChanged}
deleteApp={() => this.deleteApp(app)}
cloneApp={() => this.cloneApp(app)}
exportApp={() => this.exportApp(app)}
/>
)}
</td>
</tr>
))}
</>
)}
{currentFolder.count == 0 && (
{currentFolder.count === 0 && (
<div>
<img
className="mx-auto d-block"

View file

@ -68,7 +68,7 @@ class LoginPage extends React.Component {
name="email"
type="email"
className="form-control"
placeholder="Enter email"
placeholder="Email"
data-testid="emailField"
/>
</div>

View file

@ -0,0 +1,522 @@
import React from 'react';
import SelectSearch, { fuzzySearch } from 'react-select-search';
import { groupPermissionService } from '../_services/groupPermission.service';
import 'react-toastify/dist/ReactToastify.css';
import { Header } from '@/_components';
import { toast } from 'react-toastify';
import { Link } from 'react-router-dom';
class ManageGroupPermissionResources extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoadingGroup: true,
isLoadingApps: true,
isAddingApps: false,
isLoadingUsers: true,
isAddingUsers: false,
groupPermission: null,
usersInGroup: [],
appsInGroup: [],
usersNotInGroup: [],
appsNotInGroup: [],
selectedAppIds: [],
selectedUserIds: [],
removeAppIds: [],
currentTab: 'apps',
};
}
componentDidMount() {
const groupPermissionId = this.props.match.params.id;
this.fetchGroupAndResources(groupPermissionId);
}
humanizeIfDefaultGroupName = (groupName) => {
switch (groupName) {
case 'all_users':
return 'All Users';
case 'admin':
return 'Admin';
default:
return groupName;
}
};
fetchGroupAndResources = (groupPermissionId) => {
groupPermissionService.getGroup(groupPermissionId).then((data) => {
this.setState({
groupPermission: data,
isLoadingGroup: false,
});
this.fetchUsersNotInGroup(groupPermissionId);
this.fetchUsersInGroup(groupPermissionId);
this.fetchAppsNotInGroup(groupPermissionId);
this.fetchAppsInGroup(groupPermissionId);
});
};
fetchUsersNotInGroup = (groupPermissionId) => {
groupPermissionService.getUsersNotInGroup(groupPermissionId).then((data) => {
this.setState({
usersNotInGroup: data.users,
});
});
};
fetchUsersInGroup = (groupPermissionId) => {
groupPermissionService.getUsersInGroup(groupPermissionId).then((data) => {
this.setState({
usersInGroup: data.users,
isLoadingUsers: false,
});
});
};
fetchAppsNotInGroup = (groupPermissionId) => {
groupPermissionService.getAppsNotInGroup(groupPermissionId).then((data) => {
this.setState({
appsNotInGroup: data.apps,
});
});
};
fetchAppsInGroup = (groupPermissionId) => {
groupPermissionService.getAppsInGroup(groupPermissionId).then((data) => {
this.setState({
appsInGroup: data.apps,
isLoadingApps: false,
});
});
};
updateAppGroupPermission = (app, groupPermissionId, action) => {
const appGroupPermission = app.app_group_permissions.find(
(permission) => permission.group_permission_id === groupPermissionId
);
let actionParams = { read: true, update: action === 'edit' };
groupPermissionService
.updateAppGroupPermission(groupPermissionId, appGroupPermission.id, actionParams)
.then(() => {
toast.success('App permissions updated', {
hideProgressBar: true,
position: 'top-center',
});
this.fetchAppsInGroup(groupPermissionId);
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
canAppGroupPermission = (app, groupPermissionId, action) => {
let appGroupPermission;
switch (action) {
case 'edit':
appGroupPermission = this.findAppGroupPermission(app, groupPermissionId);
return appGroupPermission['read'] && appGroupPermission['update'];
case 'view':
appGroupPermission = this.findAppGroupPermission(app, groupPermissionId);
return appGroupPermission['read'] && !appGroupPermission['update'];
default:
return false;
}
};
findAppGroupPermission = (app, groupPermissionId) => {
const appGroupPermission = app.app_group_permissions.find(
(permission) => permission.group_permission_id === groupPermissionId
);
return appGroupPermission;
};
setSelectedUsers = (value) => {
this.setState({
selectedUserIds: value,
});
};
setSelectedApps = (value) => {
this.setState({
selectedAppIds: value,
});
};
addSelectedAppsToGroup = (groupPermissionId, selectedAppIds) => {
this.setState({ isAddingApps: true });
const updateParams = {
selectedAppIds,
};
groupPermissionService
.update(groupPermissionId, updateParams)
.then(() => {
this.setState({
selectedAppIds: [],
isLoadingApps: true,
isAddingApps: false,
});
this.fetchAppsNotInGroup(groupPermissionId);
this.fetchAppsInGroup(groupPermissionId);
})
.then(() => {
toast.success('Apps added to the group', {
hideProgressBar: true,
position: 'top-center',
});
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
removeAppFromGroup = (groupPermissionId, appId) => {
const updateParams = {
removeAppIds: [appId],
};
groupPermissionService
.update(groupPermissionId, updateParams)
.then(() => {
this.setState({ removeAppIds: [], isLoadingApps: true });
this.fetchAppsNotInGroup(groupPermissionId);
this.fetchAppsInGroup(groupPermissionId);
})
.then(() => {
toast.success('Apps removed from the group', {
hideProgressBar: true,
position: 'top-center',
});
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
addSelectedUsersToGroup = (groupPermissionId, selectedUserIds) => {
this.setState({ isAddingUsers: true });
const updateParams = {
selectedUserIds,
};
groupPermissionService
.update(groupPermissionId, updateParams)
.then(() => {
this.setState({
selectedUserIds: [],
isLoadingUsers: true,
isAddingUsers: false,
});
this.fetchUsersNotInGroup(groupPermissionId);
this.fetchUsersInGroup(groupPermissionId);
})
.then(() => {
toast.success('Users added to the group', {
hideProgressBar: true,
position: 'top-center',
});
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
removeUserFromGroup = (groupPermissionId, userId) => {
const updateParams = {
removeUserIds: [userId],
};
groupPermissionService
.update(groupPermissionId, updateParams)
.then(() => {
this.setState({ removeUserIds: [], isLoadingUsers: true });
this.fetchUsersNotInGroup(groupPermissionId);
this.fetchUsersInGroup(groupPermissionId);
})
.then(() => {
toast.success('Users removed from the group', {
hideProgressBar: true,
position: 'top-center',
});
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
render() {
const {
isLoadingGroup,
isLoadingApps,
isAddingApps,
isLoadingUsers,
isAddingUsers,
appsInGroup,
appsNotInGroup,
usersInGroup,
usersNotInGroup,
groupPermission,
currentTab,
selectedAppIds,
selectedUserIds,
} = this.state;
const appSelectOptions = appsNotInGroup.map((app) => {
return { name: app.name, value: app.id };
});
const userSelectOptions = usersNotInGroup.map((user) => {
return { name: `${user.first_name} ${user.last_name}`, value: user.id };
});
return (
<div className="wrapper org-users-page">
<Header switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode} />
<div className="page-wrapper">
<div className="container-xl">
<div className="page-header d-print-none">
<div className="row align-items-center">
<div className="col">
<div className="page-pretitle"></div>
{isLoadingGroup ? (
<ol className="breadcrumb" aria-label="breadcrumbs">
<li className="breadcrumb-item">
<a href="#">User groups</a>
</li>
</ol>
) : (
<ol className="breadcrumb" aria-label="breadcrumbs">
<li className="breadcrumb-item">
<Link to="/groups">User groups</Link>
</li>
<li className="breadcrumb-item">
<a href="#">{this.humanizeIfDefaultGroupName(groupPermission.group)}</a>
</li>
</ol>
)}
</div>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">
<div className="card">
<ul className="nav nav-tabs">
<li className="nav-item">
<a
className={`nav-link ${currentTab === 'apps' ? 'active' : ''}`}
onClick={() => this.setState({ currentTab: 'apps' })}
>
Apps
</a>
</li>
<li className="nav-item">
<a
className={`nav-link ${currentTab === 'users' ? 'active' : ''}`}
onClick={() => this.setState({ currentTab: 'users' })}
>
Users
</a>
</li>
</ul>
<div className="card-body">
<div className="tab-content">
<div className={`tab-pane ${currentTab === 'apps' ? 'active show' : ''}`}>
<div className="row">
<div className="col-5">
<SelectSearch
options={appSelectOptions}
closeOnSelect={false}
multiple
search={true}
value={selectedAppIds}
filterOptions={fuzzySearch}
onChange={(value) => this.setSelectedApps(value)}
printOptions="on-focus"
placeholder="Select apps to add to the group"
/>
</div>
<div className="col-auto">
<div
className={`btn btn-primary w-100 ${isAddingApps ? 'btn-loading' : ''} ${
selectedAppIds.length === 0 ? 'disabled' : ''
}`}
onClick={() => this.addSelectedAppsToGroup(groupPermission.id, selectedAppIds)}
>
Add
</div>
</div>
</div>
<br />
<div>
<div className="table-responsive">
<table className="table table-vcenter">
<thead>
<tr>
<th>Name</th>
<th>Permissions</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoadingApps ? (
<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>
) : (
appsInGroup.map((app) => (
<tr key={app.id}>
<td>{app.name}</td>
<td className="text-muted">
<div>
<label className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
onChange={() => {
this.updateAppGroupPermission(app, groupPermission.id, 'view');
}}
disabled={groupPermission.group === 'admin'}
checked={this.canAppGroupPermission(app, groupPermission.id, 'view')}
/>
<span className="form-check-label">View</span>
</label>
<label className="form-check form-check-inline">
<input
className="form-check-input"
type="radio"
onChange={() => {
this.updateAppGroupPermission(app, groupPermission.id, 'edit');
}}
disabled={groupPermission.group === 'admin'}
checked={this.canAppGroupPermission(app, groupPermission.id, 'edit')}
/>
<span className="form-check-label">Edit</span>
</label>
</div>
</td>
<td>
{groupPermission.group !== 'admin' && (
<Link
to="#"
onClick={() => {
this.removeAppFromGroup(groupPermission.id, app.id);
}}
>
Delete
</Link>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className={`tab-pane ${currentTab === 'users' ? 'active show' : ''}`}>
<div className="row">
<div className="col-5">
<SelectSearch
options={userSelectOptions}
closeOnSelect={false}
multiple
search={true}
filterOptions={fuzzySearch}
value={selectedUserIds}
onChange={(value) => this.setSelectedUsers(value)}
printOptions="on-focus"
placeholder="Select users to add to the group"
/>
</div>
<div className="col-auto">
<div
className={`btn btn-primary w-100 ${isAddingUsers ? 'btn-loading' : ''} ${
selectedUserIds.length === 0 ? 'disabled' : ''
}`}
onClick={() => this.addSelectedUsersToGroup(groupPermission.id, selectedUserIds)}
>
Add
</div>
</div>
</div>
<br />
<div>
<div className="table-responsive">
<table className="table table-vcenter">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoadingUsers ? (
<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>
) : (
usersInGroup.map((user) => (
<tr key={user.id}>
<td>{`${user.first_name} ${user.last_name}`}</td>
<td>{user.email}</td>
<td className="text-muted">
{groupPermission.group !== 'all_users' && (
<Link
to="#"
onClick={() => {
this.removeUserFromGroup(groupPermission.id, user.id);
}}
>
Delete
</Link>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
}
export { ManageGroupPermissionResources };

View file

@ -0,0 +1 @@
export * from './ManageGroupPermissionResources';

View file

@ -0,0 +1,235 @@
import React from 'react';
import { authenticationService } from '@/_services';
import { groupPermissionService } from '../_services/groupPermission.service';
import 'react-toastify/dist/ReactToastify.css';
import { Header } from '@/_components';
import { toast } from 'react-toastify';
import { Link } from 'react-router-dom';
class ManageGroupPermissions extends React.Component {
constructor(props) {
super(props);
this.state = {
currentUser: authenticationService.currentUserValue,
isLoading: true,
groups: [],
creatingGroup: false,
showNewGroupForm: false,
newGroupName: null,
};
}
componentDidMount() {
this.fetchGroups();
}
fetchGroups = () => {
this.setState({
isLoading: true,
});
groupPermissionService.getGroups().then((data) => {
this.setState({
groups: data.group_permissions,
isLoading: false,
});
});
};
changeNewGroupName = (value) => {
this.setState({
newGroupName: value,
});
};
humanizeifDefaultGroupName = (groupName) => {
switch (groupName) {
case 'all_users':
return 'All Users';
case 'admin':
return 'Admin';
default:
return groupName;
}
};
createGroup = () => {
this.setState({ creatingGroup: true });
groupPermissionService
.create(this.state.newGroupName)
.then(() => {
this.setState({
creatingGroup: false,
showNewGroupForm: false,
newGroup: null,
});
toast.success('Group has been created', {
hideProgressBar: true,
position: 'top-center',
});
this.fetchGroups();
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
this.setState({
creatingGroup: false,
showNewGroupForm: true,
newGroup: {},
});
});
};
deleteGroup = (groupPermissionId) => {
groupPermissionService
.del(groupPermissionId)
.then(() => {
toast.success('Group has been deleted', {
hideProgressBar: true,
position: 'top-center',
});
this.fetchGroups();
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
render() {
const { isLoading, showNewGroupForm, creatingGroup, groups } = this.state;
return (
<div className="wrapper org-users-page">
<Header switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode} />
<div className="page-wrapper">
<div className="container-xl">
<div className="page-header d-print-none">
<div className="row align-items-center">
<div className="col">
<div className="page-pretitle"></div>
<h2 className="page-title">User Groups</h2>
</div>
<div className="col-auto ms-auto d-print-none">
{!showNewGroupForm && (
<div className="btn btn-primary" onClick={() => this.setState({ showNewGroupForm: true })}>
Create new group
</div>
)}
</div>
</div>
</div>
</div>
<div className="page-body">
{showNewGroupForm && (
<div className="container-xl">
<div className="card">
<div className="card-header">
<h3 className="card-title">Add new group</h3>
</div>
<div className="card-body">
<form>
<div className="form-group mb-3 ">
<div className="row">
<div className="col">
<input
type="text"
className="form-control"
placeholder="Enter Name"
onChange={(e) => {
this.changeNewGroupName(e.target.value);
}}
/>
</div>
</div>
</div>
<div className="form-footer">
<button
className="btn btn-light mr-2"
onClick={() =>
this.setState({
showNewGroupForm: false,
newGroup: null,
})
}
disabled={creatingGroup}
>
Cancel
</button>
<button
className={`btn mx-2 btn-primary ${creatingGroup ? 'btn-loading' : ''}`}
onClick={(e) => {
e.preventDefault();
this.createGroup();
}}
disabled={creatingGroup}
>
Create Group
</button>
</div>
</form>
</div>
</div>
</div>
)}
{!showNewGroupForm && (
<div className="container-xl">
<div className="card">
<div className="card-table table-responsive table-bordered">
<table data-testid="usersTable" className="table table-vcenter" disabled={true}>
<thead>
<tr>
<th>Name</th>
<th className="w-1"></th>
<th className="w-1"></th>
</tr>
</thead>
{isLoading ? (
<tbody className="w-100" style={{ minHeight: '300px' }}>
{Array.from(Array(2)).map((index) => (
<tr key={index}>
<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>
))}
</tbody>
) : (
<tbody>
{groups.map((permissionGroup) => (
<tr key={permissionGroup.id}>
<td>
<Link to={`/groups/${permissionGroup.id}`}>
{this.humanizeifDefaultGroupName(permissionGroup.group)}
</Link>
</td>
<td>
{permissionGroup.group !== 'admin' && permissionGroup.group !== 'all_users' && (
<Link onClick={() => this.deleteGroup(permissionGroup.id)}>Delete</Link>
)}
</td>
</tr>
))}
</tbody>
)}
</table>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
}
export { ManageGroupPermissions };

View file

@ -0,0 +1 @@
export * from './ManageGroupPermissions';

View file

@ -2,10 +2,10 @@ import React from 'react';
import { authenticationService, organizationService, organizationUserService } from '@/_services';
import 'react-toastify/dist/ReactToastify.css';
import { Header } from '@/_components';
import SelectSearch, { fuzzySearch } from 'react-select-search';
import { toast } from 'react-toastify';
import { history } from '@/_helpers';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import ReactTooltip from 'react-tooltip';
class ManageOrgUsers extends React.Component {
constructor(props) {
@ -17,8 +17,6 @@ class ManageOrgUsers extends React.Component {
showNewUserForm: false,
creatingUser: false,
newUser: {},
role: '',
idChangingRole: null,
archivingUser: null,
fields: {},
errors: {},
@ -27,9 +25,10 @@ class ManageOrgUsers extends React.Component {
validateEmail(email) {
console.log(email);
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const re =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
}
handleValidation() {
let fields = this.state.fields;
@ -38,14 +37,14 @@ class ManageOrgUsers extends React.Component {
if (!fields['firstName']) {
errors['firstName'] = 'This field is required';
} else if (typeof fields['firstName'] !== 'undefined') {
if (!fields['firstName'].match(/^[a-zA-Z]+$/)) {
if (!/^[a-zA-Z]+$/.test(fields['firstName'])) {
errors['firstName'] = 'Only letters are allowed';
}
}
if (!fields['lastName']) {
errors['lastName'] = 'This field is required';
} else if (typeof fields['lastName'] !== 'undefined') {
if (!fields['lastName'].match(/^[a-zA-Z]+$/)) {
if (!/^[a-zA-Z]+$/.test(fields['lastName'])) {
errors['lastName'] = 'Only letters are allowed';
}
}
@ -53,14 +52,11 @@ class ManageOrgUsers extends React.Component {
if (!fields['email']) {
errors['email'] = 'This field is required';
} else if (!this.validateEmail(fields['email'])) {
errors['email'] = 'Email is not valid';
errors['email'] = 'Email is not valid';
}
if (!fields['role']) {
errors['role'] = 'This field is required';
}
this.setState({ errors: errors });
return Object.keys(errors).length === 0;
return Object.keys(errors).length === 0;
}
componentDidMount() {
@ -89,20 +85,6 @@ class ManageOrgUsers extends React.Component {
});
};
changeNewUserRole = (id, role) => {
this.setState({ idChangingRole: id });
organizationUserService
.changeRole(id, role)
.then(() => {
toast.success('User role has been updated', { hideProgressBar: true, position: 'top-center' });
this.setState({ idChangingRole: null });
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
this.setState({ idChangingRole: null });
});
};
archiveOrgUser = (id) => {
this.setState({ archivingUser: id });
@ -124,7 +106,9 @@ class ManageOrgUsers extends React.Component {
if (this.handleValidation()) {
let fields = {};
Object.keys(fields).map(key => { fields[key] = '' })
Object.keys(fields).forEach((key) => {
fields[key] = '';
});
this.setState({
creatingUser: true,
@ -151,12 +135,6 @@ class ManageOrgUsers extends React.Component {
}
};
dropdownVal = (role) => {
this.setState({
fields: { ...this.state.fields, role },
});
};
logout = () => {
authenticationService.logout();
history.push('/login');
@ -169,19 +147,11 @@ class ManageOrgUsers extends React.Component {
};
render() {
const {
isLoading,
role,
showNewUserForm,
creatingUser,
users,
errors,
idChangingRole,
archivingUser,
} = this.state;
const { isLoading, showNewUserForm, creatingUser, users, archivingUser } = this.state;
return (
<div className="wrapper org-users-page">
<Header switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode} />
<ReactTooltip type="dark" effect="solid" delayShow={250} />
<div className="page-wrapper">
<div className="container-xl">
@ -210,7 +180,7 @@ class ManageOrgUsers extends React.Component {
<h3 className="card-title">Add new user</h3>
</div>
<div className="card-body">
<form>
<form onSubmit={this.createUser} noValidate>
<div className="form-group mb-3 ">
<div className="row">
<div className="col">
@ -241,7 +211,7 @@ class ManageOrgUsers extends React.Component {
<label className="form-label">Email address</label>
<div>
<input
type="email"
type="text"
className="form-control"
aria-describedby="emailHelp"
placeholder="Enter email"
@ -252,33 +222,18 @@ class ManageOrgUsers extends React.Component {
<span className="text-danger">{this.state.errors['email']}</span>
</div>
</div>
<div className="form-group mb-3 ">
<label className="form-label">Role</label>
<div>
<SelectSearch
options={['Admin', 'Developer', 'Viewer'].map((role) => {
return { name: role, value: role.toLowerCase() };
})}
search={true}
value={role}
name="role"
onChange={this.dropdownVal}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
<span className="text-danger">{errors.role}</span>
</div>
</div>
<div className="form-footer">
<button
type="button"
className="btn btn-light mr-2"
onClick={() => this.setState({ showNewUserForm: false, newUser: {} })}
>
Cancel
</button>
<button
type="submit"
className={`btn mx-2 btn-primary ${creatingUser ? 'btn-loading' : ''}`}
onClick={(e) => this.createUser(e)}
disabled={creatingUser}
>
Create User
</button>
@ -298,9 +253,6 @@ class ManageOrgUsers extends React.Component {
<tr>
<th>Name</th>
<th>Email</th>
<th>
<center>Role</center>
</th>
<th>Status</th>
<th className="w-1"></th>
</tr>
@ -351,27 +303,9 @@ class ManageOrgUsers extends React.Component {
{user.email}
</a>
</td>
<td className="text-muted" style={{ width: '280px' }}>
<center className="mx-5 select-search-role">
<SelectSearch
options={['Admin', 'Developer', 'Viewer'].map((role) => {
return { name: role, value: role.toLowerCase() };
})}
value={user.role}
search={false}
disabled={idChangingRole === user.id}
onChange={(value) => {
this.changeNewUserRole(user.id, value);
}}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
{idChangingRole === user.id && <small>Updating role...</small>}
</center>
</td>
<td className="text-muted">
<span
className={`badge bg-${user.status === 'invited' ? 'warning' : 'success'} me-1 m-1`}
className={`badge bg-${user.status === 'invited' ? 'warning' : user.status === 'archived' ? 'danger' : 'success'} me-1 m-1`}
></span>
<small className="user-status">{user.status}</small>
{user.status === 'invited' && 'invitation_token' in user ? (
@ -380,10 +314,14 @@ class ManageOrgUsers extends React.Component {
onCopy={this.invitationLinkCopyHandler}
>
<img
data-tip="Copy invitation link"
className="svg-icon"
src="/assets/images/icons/copy.svg"
width="15"
height="15"
style={{
cursor: 'pointer'
}}
></img>
</CopyToClipboard>
) : (

View file

@ -55,7 +55,7 @@ class ResetPassword extends React.Component {
<div className="page page-center">
<div className="container-tight py-2">
<div className="text-center mb-4">
<a href=".">
<a href="." className="navbar-brand-autodark">
<img src="/assets/images/logo-text.svg" height="30" alt="" />
</a>
</div>

View file

@ -22,7 +22,7 @@ function SettingsPage(props) {
const changePassword = async () => {
setPasswordChangeInProgress(true);
const response = userService.changePassword(currentpassword, newPassword);
const response = await userService.changePassword(currentpassword, newPassword);
response
.then(() => {
toast.success('Password updated successfully', { hideProgressBar: true, autoClose: 3000 });
@ -40,7 +40,7 @@ function SettingsPage(props) {
const newPasswordKeyPressHandler = async (event) => {
if (event.key === 'Enter') {
changePassword();
await changePassword();
}
};
@ -54,7 +54,7 @@ function SettingsPage(props) {
<div className="row align-items-center">
<div className="col">
<div className="page-pretitle"></div>
<h2 className="page-title">Settings</h2>
<h2 className="page-title">Profile Settings</h2>
</div>
</div>
</div>

View file

@ -43,7 +43,7 @@ class SignupPage extends React.Component {
<div className="page page-center">
<div className="container-tight py-2">
<div className="text-center mb-4">
<a href=".">
<a href="." className="navbar-brand-autodark">
<img src="/assets/images/logo-text.svg" height="30" alt="" />
</a>
</div>

View file

@ -53,7 +53,7 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
case 'toggle':
return {
defaultChecked: options[$key],
onChange: () => optionchanged($key, !options[$key]),
onChange: () => optionchanged($key, !options[$key].value),
};
case 'dropdown':
case 'dropdown-component-flip':

View file

@ -1,18 +1,10 @@
import React, { useState, useEffect } from 'react';
import cx from 'classnames';
import React from 'react';
import { Link } from 'react-router-dom';
import { authenticationService } from '@/_services';
import { history } from '@/_helpers';
import { DarkModeToggle } from './DarkModeToggle';
export const Header = function Header({ switchDarkMode, darkMode }) {
const [pathName, setPathName] = useState(document.location.pathname);
useEffect(() => {
setPathName(document.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.location.pathname]);
function logout() {
authenticationService.logout();
history.push('/login');
@ -22,7 +14,7 @@ export const Header = function Header({ switchDarkMode, darkMode }) {
history.push('/settings');
}
const { first_name, last_name } = authenticationService.currentUserValue;
const { first_name, last_name, admin } = authenticationService.currentUserValue;
return (
<header className="navbar navbar-expand-md navbar-light d-print-none">
@ -36,25 +28,6 @@ export const Header = function Header({ switchDarkMode, darkMode }) {
</Link>
</h1>
<ul className="navbar-nav d-none d-lg-flex">
<li className={cx(`nav-item mx-3`, { active: pathName === '/' })}>
<Link to={'/'} className="nav-link">
<span className="nav-link-icon d-md-none d-lg-inline-block">
<img className="svg-icon" src="/assets/images/icons/apps.svg" width="15" height="15" />
</span>
<span className="nav-link-title">Apps</span>
</Link>
</li>
<li className={cx(`nav-item`, { active: pathName === '/users' })}>
<Link to={'/users'} className="nav-link">
<span className="nav-link-icon d-md-none d-lg-inline-block">
<img className="svg-icon" src="/assets/images/icons/users.svg" width="15" height="15" />
</span>
<span className="nav-link-title">Users</span>
</Link>
</li>
</ul>
<div className="navbar-nav flex-row order-md-last">
<div className="p-1 m-1 d-flex align-items-center">
<DarkModeToggle switchDarkMode={switchDarkMode} darkMode={darkMode} />
@ -75,12 +48,22 @@ export const Header = function Header({ switchDarkMode, darkMode }) {
</div>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a data-testId="settingsBtn" onClick={openSettings} className="dropdown-item">
Settings
</a>
<a data-testId="logoutBtn" onClick={logout} className="dropdown-item">
{admin && (
<Link data-testid="settingsBtn" to="/users" className="dropdown-item">
Manage Users
</Link>
)}
{admin && (
<Link data-tesid="settingsBtn" to="/groups" className="dropdown-item">
Manage Groups
</Link>
)}
<Link data-testid="settingsBtn" to="#" onClick={openSettings} className="dropdown-item">
Profile
</Link>
<Link data-testid="logoutBtn" to="#" onClick={logout} className="dropdown-item">
Logout
</a>
</Link>
</div>
</div>
</div>

View file

@ -56,7 +56,7 @@ export function runTransformation(_ref, rawData, transformation, query) {
result = evalFunction(data, moment, _, currentState.components, currentState.queries, currentState.globals);
} catch (err) {
console.log('Transformation failed for query: ', query.name, err);
toast.error(err.message, { hideProgressBar: true });
result = { message: err.stack.split('\n')[0], status: 'failed', data: data };
}
return result;
@ -67,7 +67,7 @@ export async function executeActionsForEventId(_ref, eventId, component, mode) {
const filteredEvents = events.filter((event) => event.eventId === eventId);
for (const event of filteredEvents) {
await executeAction(_ref, event, mode);
await executeAction(_ref, event, mode); // skipcq: JS-0032
}
}
@ -98,6 +98,11 @@ async function copyToClipboard(text) {
}
function showModal(_ref, modalId, show) {
if (_.isEmpty(modalId)) {
console.log('No modal is associated with this event.');
return Promise.resolve();
}
const modalMeta = _ref.state.appDefinition.components[modalId];
const newState = {
@ -115,9 +120,7 @@ function showModal(_ref, modalId, show) {
_ref.setState(newState);
return new Promise(function (resolve, reject) {
resolve();
});
return Promise.resolve();
}
function executeAction(_ref, event, mode) {
@ -125,10 +128,8 @@ function executeAction(_ref, event, mode) {
switch (event.actionId) {
case 'show-alert': {
const message = resolveReferences(event.message, _ref.state.currentState);
toast(message, { hideProgressBar: true });
return new Promise(function (resolve, reject) {
resolve();
});
toast(message, { hideProgressBar: true, type: event.alertType });
return Promise.resolve();
}
case 'run-query': {
@ -139,9 +140,7 @@ function executeAction(_ref, event, mode) {
case 'open-webpage': {
const url = resolveReferences(event.url, _ref.state.currentState);
window.open(url, '_blank');
return new Promise(function (resolve, reject) {
resolve();
});
return Promise.resolve();
}
case 'go-to-app': {
@ -174,9 +173,7 @@ function executeAction(_ref, event, mode) {
window.open(url, '_blank');
}
}
return new Promise(function (resolve, reject) {
resolve();
});
return Promise.resolve();
}
case 'show-modal':
@ -189,9 +186,7 @@ function executeAction(_ref, event, mode) {
const contentToCopy = resolveReferences(event.contentToCopy, _ref.state.currentState);
copyToClipboard(contentToCopy);
return new Promise(function (resolve, reject) {
resolve();
});
return Promise.resolve();
}
}
}
@ -451,6 +446,34 @@ export function runQuery(_ref, queryId, queryName, confirmed = undefined) {
if (dataQuery.options.enableTransformation) {
finalData = runTransformation(_self, rawData, dataQuery.options.transformation, dataQuery);
if (finalData.status === 'failed') {
return _self.setState(
{
currentState: {
..._self.state.currentState,
queries: {
..._self.state.currentState.queries,
[queryName]: {
..._self.state.currentState.queries[queryName],
isLoading: false,
},
},
errors: {
..._self.state.currentState.errors,
[queryName]: {
type: 'transformations',
data: finalData,
options: options,
},
},
},
},
() => {
resolve();
onEvent(_self, 'onDataQueryFailure', { definition: { events: dataQuery.options.events } });
}
);
}
}
if (dataQuery.options.showSuccessNotification) {

View file

@ -165,7 +165,7 @@ export const serializeNestedObjectToQueryParams = function (obj, prefix) {
var str = [],
p;
for (p in obj) {
if (obj.hasOwnProperty(p)) {
if (Object.prototype.hasOwnProperty.call(obj, p)) {
var k = prefix ? prefix + '[' + p + ']' : p,
v = obj[p];
str.push(

View file

@ -13,7 +13,7 @@ const useEscapeHandler = (handler = noop, dependencies = []) => {
document === null || document === void 0 ? void 0 : document.removeEventListener('keyup', escapeHandler);
}, dependencies);
};
const useClickOutside = (handler = noop, dependencies) => {
const useClickOutside = (dependencies, handler = noop) => {
const callbackRef = useRef(handler);
const ref = useRef(null);
const outsideClickHandler = (e) => {
@ -43,7 +43,7 @@ const usePopover = (defaultOpen = false) => {
const toggle = useCallback(() => setOpen(!open), []);
const close = useCallback(() => setOpen(false), []);
useEscapeHandler(close, []);
const contentRef = useClickOutside(open ? close : undefined, []);
const contentRef = useClickOutside([], open ? close : undefined);
const trigger = {
ref: triggerRef,
onClick: toggle,

View file

@ -6,6 +6,8 @@ export const appService = {
getAll,
createApp,
cloneApp,
exportApp,
importApp,
deleteApp,
getApp,
getAppBySlug,
@ -40,6 +42,16 @@ function cloneApp(id) {
return fetch(`${config.apiUrl}/apps/${id}/clone`, requestOptions).then(handleResponse);
}
function exportApp(id) {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/apps/${id}/export`, requestOptions).then(handleResponse);
}
function importApp(body) {
const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body) };
return fetch(`${config.apiUrl}/apps/import`, requestOptions).then(handleResponse);
}
function getApp(id) {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/apps/${id}`, requestOptions).then(handleResponse);

View file

@ -0,0 +1,120 @@
import config from 'config';
import { authHeader, handleResponse } from '@/_helpers';
export const groupPermissionService = {
create,
update,
del,
getGroup,
getGroups,
getAppsInGroup,
getAppsNotInGroup,
getUsersInGroup,
getUsersNotInGroup,
updateAppGroupPermission,
};
function create(group) {
const body = {
group,
};
const requestOptions = {
method: 'POST',
headers: authHeader(),
body: JSON.stringify(body),
};
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,
};
const requestOptions = {
method: 'PUT',
headers: authHeader(),
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}`, requestOptions).then(handleResponse);
}
function del(groupPermissionId) {
const requestOptions = {
method: 'DELETE',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}`, requestOptions).then(handleResponse);
}
function getGroup(groupPermissionId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}`, requestOptions).then(handleResponse);
}
function getGroups() {
const requestOptions = {
method: 'GET',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions`, requestOptions).then(handleResponse);
}
function getAppsInGroup(groupPermissionId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/apps`, requestOptions).then(handleResponse);
}
function getAppsNotInGroup(groupPermissionId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/addable_apps`, requestOptions).then(
handleResponse
);
}
function getUsersInGroup(groupPermissionId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/users`, requestOptions).then(handleResponse);
}
function getUsersNotInGroup(groupPermissionId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
};
return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/addable_users`, requestOptions).then(
handleResponse
);
}
function updateAppGroupPermission(groupPermissionId, appGroupPermissionId, actions) {
const body = {
actions,
};
const requestOptions = {
method: 'PUT',
headers: authHeader(),
body: JSON.stringify(body),
};
return fetch(
`${config.apiUrl}/group_permissions/${groupPermissionId}/app_group_permissions/${appGroupPermissionId}`,
requestOptions
).then(handleResponse);
}

View file

@ -7,18 +7,18 @@ export const organizationUserService = {
changeRole,
};
function create(first_name, last_name, email, role) {
function create(first_name, last_name, email) {
const body = {
first_name,
last_name,
email,
role,
};
const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body) };
return fetch(`${config.apiUrl}/organization_users`, requestOptions).then(handleResponse);
}
// Deprecated
function changeRole(id, role) {
const body = {
role,

View file

@ -388,7 +388,6 @@ body {
z-index: 3;
width: 59.3%;
margin-top: 0px;
padding: 0.5px;
}
.preview-header {
@ -893,7 +892,6 @@ body {
.jet-data-table::-webkit-scrollbar {
background: transparent;
height: 0;
}
.jet-data-table:hover {
@ -952,7 +950,7 @@ tr:focus {
}
.jet-container {
border-radius: 8px;
}
.select-search__option {
@ -1797,6 +1795,9 @@ input:focus-visible {
.card {
background-color: #324156 !important;
}
.card .table tbody td a{
color: inherit;
}
.DateInput {
background: #1f2936;
@ -2013,8 +2014,13 @@ 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%);
@ -2238,3 +2244,7 @@ input[type='text'] {
height: 17px;
}
}
.fw-500 {
font-weight: 500;
}

View file

@ -7,4 +7,7 @@
.label {
flex: 1;
}
.star {
margin-bottom: 1px;
}
}

View file

@ -1,7 +1,7 @@
import React from 'react';
export default ({ defaultChecked, onChange }) => {
return (
<label className="form-switch mt-3">
<label className="form-switch">
<input className="form-check-input" type="checkbox" defaultChecked={defaultChecked} onChange={onChange} />
</label>
);

View file

@ -11,7 +11,7 @@ appService
.then((config) => {
window.public_config = config;
if (window.public_config.APM_VENDOR == 'sentry') {
if (window.public_config.APM_VENDOR === 'sentry') {
const history = createBrowserHistory();
const tooljetServerUrl = window.public_config.TOOLJET_SERVER_URL;
const tracingOrigins = ['localhost', /^\//];

427
package-lock.json generated
View file

@ -18,6 +18,7 @@
"babel-loader": "^8.0.5",
"html-webpack-plugin": "^5.3.2",
"husky": "^7.0.2",
"lint-staged": "^11.2.3",
"path": "^0.12.7",
"webpack": "^5.55.1",
"webpack-cli": "^4.8.0"
@ -1312,7 +1313,6 @@
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
"dev": true,
"peer": true,
"dependencies": {
"clean-stack": "^2.0.0",
"indent-string": "^4.0.0"
@ -1868,7 +1868,6 @@
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
"dev": true,
"peer": true,
"engines": {
"node": ">=6"
}
@ -1954,7 +1953,6 @@
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
"dev": true,
"peer": true,
"dependencies": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
@ -2013,9 +2011,9 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/colorette": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w=="
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="
},
"node_modules/colors": {
"version": "1.4.0",
@ -2265,24 +2263,6 @@
"node": ">= 6"
}
},
"node_modules/cypress/node_modules/debug": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
"dev": true,
"peer": true,
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/cypress/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -2349,9 +2329,9 @@
"peer": true
},
"node_modules/debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
"dependencies": {
"ms": "2.1.2"
},
@ -3207,6 +3187,12 @@
"node": ">=6.9.0"
}
},
"node_modules/get-own-enumerable-property-symbols": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
"integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
"dev": true
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@ -3568,7 +3554,6 @@
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -3717,6 +3702,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-obj": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
"integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -3739,6 +3733,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
"integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -3966,15 +3969,126 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="
},
"node_modules/listr2": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.11.1.tgz",
"integrity": "sha512-ZXQvQfmH9iWLlb4n3hh31yicXDxlzB0pE7MM1zu6kgbVL4ivEsO4H8IPh4E682sC8RjnYO9anose+zT52rrpyg==",
"node_modules/lint-staged": {
"version": "11.2.3",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-11.2.3.tgz",
"integrity": "sha512-Tfmhk8O2XFMD25EswHPv+OYhUjsijy5D7liTdxeXvhG2rsadmOLFtyj8lmlfoFFXY8oXWAIOKpoI+lJe1DB1mw==",
"dev": true,
"dependencies": {
"cli-truncate": "2.1.0",
"colorette": "^1.4.0",
"commander": "^8.2.0",
"cosmiconfig": "^7.0.1",
"debug": "^4.3.2",
"enquirer": "^2.3.6",
"execa": "^5.1.1",
"listr2": "^3.12.2",
"micromatch": "^4.0.4",
"normalize-path": "^3.0.0",
"please-upgrade-node": "^3.2.0",
"string-argv": "0.3.1",
"stringify-object": "3.3.0",
"supports-color": "8.1.1"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/lint-staged/node_modules/commander": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.2.0.tgz",
"integrity": "sha512-LLKxDvHeL91/8MIyTAD5BFMNtoIwztGPMiM/7Bl8rIPmHCZXRxmSWr91h57dpOpnQ6jIUqEWdXE/uBYMfiVZDA==",
"dev": true,
"engines": {
"node": ">= 12"
}
},
"node_modules/lint-staged/node_modules/cosmiconfig": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
"integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
"dev": true,
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lint-staged/node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/lint-staged/node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lint-staged/node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/lint-staged/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/listr2": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.12.2.tgz",
"integrity": "sha512-64xC2CJ/As/xgVI3wbhlPWVPx0wfTqbUAkpb7bjDi0thSWMqrf07UFhrfsGoo8YSXmF049Rp9C0cjLC8rZxK9A==",
"dev": true,
"peer": true,
"dependencies": {
"cli-truncate": "^2.1.0",
"colorette": "^1.2.2",
"colorette": "^1.4.0",
"log-update": "^4.0.0",
"p-map": "^4.0.0",
"rxjs": "^6.6.7",
@ -4074,7 +4188,6 @@
"resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
"integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-escapes": "^4.3.0",
"cli-cursor": "^3.1.0",
@ -4093,7 +4206,6 @@
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
"integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@ -4111,7 +4223,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -4187,6 +4298,19 @@
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
},
"node_modules/micromatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
"integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==",
"dev": true,
"dependencies": {
"braces": "^3.0.1",
"picomatch": "^2.2.3"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
@ -4454,7 +4578,6 @@
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
"dev": true,
"peer": true,
"dependencies": {
"aggregate-error": "^3.0.0"
},
@ -4635,6 +4758,15 @@
"node": ">=8"
}
},
"node_modules/please-upgrade-node": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz",
"integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==",
"dev": true,
"dependencies": {
"semver-compare": "^1.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@ -4988,6 +5120,12 @@
"node": ">=10"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
"dev": true
},
"node_modules/serialize-javascript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
@ -5053,7 +5191,6 @@
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@ -5129,6 +5266,15 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-argv": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
"integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
"dev": true,
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
@ -5142,6 +5288,20 @@
"node": ">=8"
}
},
"node_modules/stringify-object": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
"integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
"dev": true,
"dependencies": {
"get-own-enumerable-property-symbols": "^3.0.0",
"is-obj": "^1.0.1",
"is-regexp": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
@ -5936,7 +6096,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -7089,7 +7248,6 @@
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
"dev": true,
"peer": true,
"requires": {
"clean-stack": "^2.0.0",
"indent-string": "^4.0.0"
@ -7486,8 +7644,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
"dev": true,
"peer": true
"dev": true
},
"cli-cursor": {
"version": "3.1.0",
@ -7546,7 +7703,6 @@
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
"dev": true,
"peer": true,
"requires": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
@ -7587,9 +7743,9 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"colorette": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w=="
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="
},
"colors": {
"version": "1.4.0",
@ -7794,16 +7950,6 @@
"dev": true,
"peer": true
},
"debug": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
"dev": true,
"peer": true,
"requires": {
"ms": "2.1.2"
}
},
"fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -7857,9 +8003,9 @@
"peer": true
},
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
"requires": {
"ms": "2.1.2"
}
@ -8516,6 +8662,12 @@
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true
},
"get-own-enumerable-property-symbols": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
"integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
"dev": true
},
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@ -8754,8 +8906,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"peer": true
"dev": true
},
"inflight": {
"version": "1.0.6",
@ -8865,6 +9016,12 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"is-obj": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
"integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
"dev": true
},
"is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -8881,6 +9038,12 @@
"isobject": "^3.0.1"
}
},
"is-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
"integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=",
"dev": true
},
"is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -9060,15 +9223,95 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="
},
"listr2": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.11.1.tgz",
"integrity": "sha512-ZXQvQfmH9iWLlb4n3hh31yicXDxlzB0pE7MM1zu6kgbVL4ivEsO4H8IPh4E682sC8RjnYO9anose+zT52rrpyg==",
"lint-staged": {
"version": "11.2.3",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-11.2.3.tgz",
"integrity": "sha512-Tfmhk8O2XFMD25EswHPv+OYhUjsijy5D7liTdxeXvhG2rsadmOLFtyj8lmlfoFFXY8oXWAIOKpoI+lJe1DB1mw==",
"dev": true,
"requires": {
"cli-truncate": "2.1.0",
"colorette": "^1.4.0",
"commander": "^8.2.0",
"cosmiconfig": "^7.0.1",
"debug": "^4.3.2",
"enquirer": "^2.3.6",
"execa": "^5.1.1",
"listr2": "^3.12.2",
"micromatch": "^4.0.4",
"normalize-path": "^3.0.0",
"please-upgrade-node": "^3.2.0",
"string-argv": "0.3.1",
"stringify-object": "3.3.0",
"supports-color": "8.1.1"
},
"dependencies": {
"commander": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.2.0.tgz",
"integrity": "sha512-LLKxDvHeL91/8MIyTAD5BFMNtoIwztGPMiM/7Bl8rIPmHCZXRxmSWr91h57dpOpnQ6jIUqEWdXE/uBYMfiVZDA==",
"dev": true
},
"cosmiconfig": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
"integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
"dev": true,
"requires": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
}
},
"execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"requires": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
}
},
"get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true
},
"human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true
},
"supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"listr2": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.12.2.tgz",
"integrity": "sha512-64xC2CJ/As/xgVI3wbhlPWVPx0wfTqbUAkpb7bjDi0thSWMqrf07UFhrfsGoo8YSXmF049Rp9C0cjLC8rZxK9A==",
"dev": true,
"peer": true,
"requires": {
"cli-truncate": "^2.1.0",
"colorette": "^1.2.2",
"colorette": "^1.4.0",
"log-update": "^4.0.0",
"p-map": "^4.0.0",
"rxjs": "^6.6.7",
@ -9147,7 +9390,6 @@
"resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
"integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
"dev": true,
"peer": true,
"requires": {
"ansi-escapes": "^4.3.0",
"cli-cursor": "^3.1.0",
@ -9160,7 +9402,6 @@
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
"integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
"dev": true,
"peer": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@ -9172,7 +9413,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"peer": true,
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@ -9237,6 +9477,16 @@
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
},
"micromatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
"integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==",
"dev": true,
"requires": {
"braces": "^3.0.1",
"picomatch": "^2.2.3"
}
},
"mime-db": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
@ -9445,7 +9695,6 @@
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
"dev": true,
"peer": true,
"requires": {
"aggregate-error": "^3.0.0"
}
@ -9593,6 +9842,15 @@
"find-up": "^4.0.0"
}
},
"please-upgrade-node": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz",
"integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==",
"dev": true,
"requires": {
"semver-compare": "^1.0.0"
}
},
"pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@ -9844,6 +10102,12 @@
"lru-cache": "^6.0.0"
}
},
"semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
"dev": true
},
"serialize-javascript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
@ -9894,7 +10158,6 @@
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
"dev": true,
"peer": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@ -9956,6 +10219,12 @@
"safe-buffer": "~5.2.0"
}
},
"string-argv": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
"integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
"dev": true
},
"string-width": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
@ -9966,6 +10235,17 @@
"strip-ansi": "^6.0.0"
}
},
"stringify-object": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
"integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
"dev": true,
"requires": {
"get-own-enumerable-property-symbols": "^3.0.0",
"is-obj": "^1.0.1",
"is-regexp": "^1.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
@ -10527,7 +10807,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"peer": true,
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",

View file

@ -11,11 +11,6 @@
"eslint --fix"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"devDependencies": {
"@4tw/cypress-drag-drop": "^1.8.0",
"@babel/core": "^7.4.3",
@ -25,6 +20,7 @@
"babel-loader": "^8.0.5",
"html-webpack-plugin": "^5.3.2",
"husky": "^7.0.2",
"lint-staged": "^11.2.3",
"path": "^0.12.7",
"webpack": "^5.55.1",
"webpack-cli": "^4.8.0"

View file

@ -1 +1 @@
0.7.4
0.8.0

View file

@ -0,0 +1,70 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableUnique,
} from "typeorm";
export class CreateGroupPermissions1632382322381 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "group_permissions",
columns: [
{
name: "id",
type: "uuid",
isGenerated: true,
default: "gen_random_uuid()",
isPrimary: true,
},
{
name: "organization_id",
type: "uuid",
isNullable: false,
},
{
name: "group",
type: "varchar",
isNullable: false,
},
{
name: "created_at",
type: "timestamp",
isNullable: false,
default: "now()",
},
{
name: "updated_at",
type: "timestamp",
isNullable: false,
default: "now()",
},
],
}),
true
);
await queryRunner.createForeignKey(
"group_permissions",
new TableForeignKey({
columnNames: ["organization_id"],
referencedColumnNames: ["id"],
referencedTableName: "organizations",
onDelete: "CASCADE",
})
);
await queryRunner.createUniqueConstraint(
"group_permissions",
new TableUnique({
columnNames: ["organization_id", "group"],
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("group_permissions");
}
}

View file

@ -0,0 +1,74 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
} from "typeorm";
export class CreateUserGroupPermissions1632383798339
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "user_group_permissions",
columns: [
{
name: "id",
type: "uuid",
isGenerated: true,
default: "gen_random_uuid()",
isPrimary: true,
},
{
name: "user_id",
type: "uuid",
isNullable: false,
},
{
name: "group_permission_id",
type: "uuid",
isNullable: false,
},
{
name: "created_at",
type: "timestamp",
isNullable: false,
default: "now()",
},
{
name: "updated_at",
type: "timestamp",
isNullable: false,
default: "now()",
},
],
}),
true
);
await queryRunner.createForeignKey(
"user_group_permissions",
new TableForeignKey({
columnNames: ["user_id"],
referencedColumnNames: ["id"],
referencedTableName: "users",
onDelete: "CASCADE",
})
);
await queryRunner.createForeignKey(
"user_group_permissions",
new TableForeignKey({
columnNames: ["group_permission_id"],
referencedColumnNames: ["id"],
referencedTableName: "group_permissions",
onDelete: "CASCADE",
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("user_group_permissions");
}
}

View file

@ -0,0 +1,92 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
} from "typeorm";
export class CreateAppGroupPermissions1632384954344
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "app_group_permissions",
columns: [
{
name: "id",
type: "uuid",
isGenerated: true,
default: "gen_random_uuid()",
isPrimary: true,
},
{
name: "app_id",
type: "uuid",
isNullable: false,
},
{
name: "group_permission_id",
type: "uuid",
isNullable: false,
},
{
name: "read",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "update",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "delete",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "created_at",
type: "timestamp",
isNullable: false,
default: "now()",
},
{
name: "updated_at",
type: "timestamp",
isNullable: false,
default: "now()",
},
],
}),
true
);
await queryRunner.createForeignKey(
"app_group_permissions",
new TableForeignKey({
columnNames: ["app_id"],
referencedColumnNames: ["id"],
referencedTableName: "apps",
onDelete: "CASCADE",
})
);
await queryRunner.createForeignKey(
"app_group_permissions",
new TableForeignKey({
columnNames: ["group_permission_id"],
referencedColumnNames: ["id"],
referencedTableName: "group_permissions",
onDelete: "CASCADE",
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("app_group_permissions");
}
}

View file

@ -0,0 +1,137 @@
import { EntityManager, In, MigrationInterface, QueryRunner } from "typeorm";
import { Organization } from "../src/entities/organization.entity";
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 { App } from "../src/entities/app.entity";
export class PopulateUserGroupsFromOrganizationRoles1632468258787
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const OrganizationRepository = entityManager.getRepository(Organization);
const organizations = await OrganizationRepository.find({
relations: ["users"],
});
for (let organization of organizations) {
const groupPermissions = await setupInitialGroupPermissions(
entityManager,
organization
);
await setupUserAndAppGroupPermissions(
entityManager,
organization,
groupPermissions
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
entityManager
.createQueryBuilder()
.delete()
.from(GroupPermission)
.execute();
entityManager
.createQueryBuilder()
.delete()
.from(AppGroupPermission)
.execute();
entityManager
.createQueryBuilder()
.delete()
.from(UserGroupPermission)
.execute();
}
}
async function setupInitialGroupPermissions(
entityManager: EntityManager,
organization: Organization
): Promise<Array<GroupPermission>> {
const existingRoles = ["admin", "developer", "viewer"];
const groupsToCreate = ["all_users", ...existingRoles];
const createdGroupPermissions = [];
const groupPermissionRepository =
entityManager.getRepository(GroupPermission);
for (const group of groupsToCreate) {
const groupPermission = groupPermissionRepository.create({
organizationId: organization.id,
group: group,
});
await groupPermissionRepository.save(groupPermission);
createdGroupPermissions.push(groupPermission);
}
return createdGroupPermissions;
}
async function setupUserAndAppGroupPermissions(
entityManager: EntityManager,
organization: Organization,
createdGroupPermissions: Array<GroupPermission>
): Promise<void> {
const userGroupPermissionRepository =
entityManager.getRepository(UserGroupPermission);
const appGroupPermissionRepository =
entityManager.getRepository(AppGroupPermission);
const appRepository = entityManager.getRepository(App);
const organizationApps = await appRepository.find({
organizationId: organization.id,
});
for (const groupPermission of createdGroupPermissions) {
const usersForGroup = organization.users.filter(
(u) =>
u.organizationUsers[0].role == groupPermission.group || groupPermission.group == "all_users"
);
for (const user of usersForGroup) {
const userGroupPermission = userGroupPermissionRepository.create({
groupPermissionId: groupPermission.id,
userId: user.id,
});
await userGroupPermissionRepository.save(userGroupPermission);
}
const permissions = determinePermissionsForGroup(groupPermission.group);
for (const app of organizationApps) {
const appGroupPermission = appGroupPermissionRepository.create({
groupPermissionId: groupPermission.id,
appId: app.id,
...permissions,
});
await appGroupPermissionRepository.save(appGroupPermission);
}
}
}
function determinePermissionsForGroup(group: string): {
read: boolean;
update: boolean;
delete: boolean;
} {
switch (group) {
case "all_users":
return { read: true, update: false, delete: false };
case "admin":
return { read: true, update: true, delete: true };
case "developer":
return { read: true, update: true, delete: true };
case "viewer":
return { read: true, update: false, delete: false };
}
}

View file

@ -80,7 +80,7 @@ export default class RestapiQueryService implements QueryService {
let result = {};
/* Prefixing the base url of datasouce if datasource exists */
const url = hasDataSource ? `${sourceOptions.url}${queryOptions.url}` : queryOptions.url;
const url = hasDataSource ? `${sourceOptions.url}${queryOptions.url || ''}` : queryOptions.url;
const method = queryOptions['method'];
const json = method !== 'get' ? this.body(sourceOptions, queryOptions, hasDataSource) : undefined;

View file

@ -16,7 +16,6 @@ import { CaslModule } from './modules/casl/casl.module';
import { EmailService } from '@services/email.service';
import { MetaModule } from './modules/meta/meta.module';
import { AppController } from './controllers/app.controller';
import { AppService } from './services/app.service';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { AppConfigModule } from './modules/app_config/app_config.module';
@ -27,6 +26,7 @@ import { DataQueriesModule } from './modules/data_queries/data_queries.module';
import { DataSourcesModule } from './modules/data_sources/data_sources.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { join } from 'path';
import { GroupPermissionsModule } from './modules/group_permissions/group_permissions.module';
const imports = [
ConfigModule.forRoot({
@ -63,6 +63,7 @@ const imports = [
OrganizationsModule,
CaslModule,
MetaModule,
GroupPermissionsModule,
];
if (process.env.SERVE_CLIENT !== 'false') {
@ -86,7 +87,7 @@ if (process.env.APM_VENDOR == 'sentry') {
@Module({
imports,
controllers: [AppController],
providers: [AppService, EmailService, SeedsService],
providers: [EmailService, SeedsService],
})
export class AppModule implements OnModuleInit, OnApplicationBootstrap {
constructor(private connection: Connection) {}
@ -98,11 +99,11 @@ export class AppModule implements OnModuleInit, OnApplicationBootstrap {
});
}
onModuleInit() {
onModuleInit(): void {
console.log(`Initializing ToolJet server modules 📡 `);
}
onApplicationBootstrap() {
onApplicationBootstrap(): void {
console.log(`Initialized ToolJet server, waiting for requests 🚀`);
}
}

View file

@ -13,6 +13,7 @@ export class AppUsersController {
private appsAbilityFactory: AppsAbilityFactory
) {}
// TODO: remove deprecated
@UseGuards(JwtAuthGuard)
@Post()
async create(@Request() req) {
@ -22,7 +23,7 @@ export class AppUsersController {
const { role } = params;
const app = await this.appsService.find(appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, { id: appId });
if (!ability.can('createUsers', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');

View file

@ -16,11 +16,14 @@ import { decamelizeKeys } from 'humps';
import { AppsAbilityFactory } from 'src/modules/casl/abilities/apps-ability.factory';
import { AppAuthGuard } from 'src/modules/auth/app-auth.guard';
import { FoldersService } from '@services/folders.service';
import { App } from 'src/entities/app.entity';
import { AppImportExportService } from '@services/app_import_export.service';
@Controller('apps')
export class AppsController {
constructor(
private appsService: AppsService,
private appImportExportService: AppImportExportService,
private foldersService: FoldersService,
private appsAbilityFactory: AppsAbilityFactory
) {}
@ -28,12 +31,12 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@Post()
async create(@Request() req) {
const app = await this.appsService.create(req.user);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
if (!ability.can('createApp', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
if (!ability.can('createApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const app = await this.appsService.create(req.user);
await this.appsService.update(req.user, app.id, {
slug: app.id,
@ -46,6 +49,11 @@ export class AppsController {
@Get(':id')
async show(@Request() req, @Param() params) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('viewApp', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const response = decamelizeKeys(app);
const seralizedQueries = [];
@ -68,10 +76,12 @@ export class AppsController {
async appFromSlug(@Request() req, @Param() params) {
if (req.user) {
const app = await this.appsService.findBySlug(params.slug);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
if (!ability.can('viewApp', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
}
@ -92,10 +102,10 @@ export class AppsController {
@Put(':id')
async update(@Request() req, @Param() params) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('updateParams', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.update(req.user, params.id, req.body.app);
@ -108,10 +118,10 @@ export class AppsController {
@Post(':id/clone')
async clone(@Request() req, @Param() params) {
const existingApp = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('cloneApp', existingApp)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.clone(existingApp, req.user);
@ -120,11 +130,38 @@ export class AppsController {
return response;
}
@UseGuards(JwtAuthGuard)
@Get(':id/export')
async export(@Request() req, @Param() params) {
const appToExport = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('viewApp', appToExport)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const app = await this.appImportExportService.export(req.user, params.id);
return app;
}
@UseGuards(JwtAuthGuard)
@Post('/import')
async import(@Request() req) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
if (!ability.can('createApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
await this.appImportExportService.import(req.user, req.body);
return;
}
@UseGuards(JwtAuthGuard)
@Delete(':id')
async delete(@Request() req, @Param() params) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('deleteApp', app)) {
throw new ForbiddenException('Only administrators are allowed to delete apps.');
@ -172,14 +209,15 @@ export class AppsController {
return decamelizeKeys(response);
}
// deprecated
@UseGuards(JwtAuthGuard)
@Get(':id/users')
async fetchUsers(@Request() req, @Param() params) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('fetchUsers', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.fetchUsers(req.user, params.id);
@ -190,10 +228,10 @@ export class AppsController {
@Get(':id/versions')
async fetchVersions(@Request() req, @Param() params) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('fetchVersions', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.fetchVersions(req.user, params.id);
@ -206,10 +244,10 @@ export class AppsController {
const versionName = req.body['versionName'];
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('createVersions', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const appUser = await this.appsService.createVersion(req.user, app, versionName);
@ -220,10 +258,10 @@ export class AppsController {
@Get(':id/versions/:versionId')
async version(@Request() req, @Param() params) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('fetchVersions', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const appVersion = await this.appsService.findVersion(params.versionId);
@ -237,10 +275,10 @@ export class AppsController {
const definition = req.body['definition'];
const version = await this.appsService.findVersion(params.versionId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
if (!ability.can('updateVersions', version.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
throw new ForbiddenException('You do not have permissions to perform this action');
}
const appUser = await this.appsService.updateVersion(req.user, version, definition);

View file

@ -32,7 +32,9 @@ export class DataQueriesController {
@Get()
async index(@Request() req, @Query() query) {
const app = await this.appsService.find(query.app_id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: query.app_id,
});
if (!ability.can('getQueries', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -61,7 +63,9 @@ export class DataQueriesController {
const appId = req.body.app_id;
const app = await this.appsService.find(appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: appId,
});
if (!ability.can('createQuery', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -88,7 +92,9 @@ export class DataQueriesController {
const dataQueryId = params.id;
const dataQuery = await this.dataQueriesService.findOne(dataQueryId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQuery.appId,
});
if (!ability.can('updateQuery', dataQuery.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -104,7 +110,9 @@ export class DataQueriesController {
const dataQueryId = params.id;
const dataQuery = await this.dataQueriesService.findOne(dataQueryId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQuery.appId,
});
if (!ability.can('deleteQuery', dataQuery.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -123,7 +131,9 @@ export class DataQueriesController {
const dataQuery = await this.dataQueriesService.findOne(dataQueryId);
if (req.user) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQuery.appId,
});
if (!ability.can('runQuery', dataQuery.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -166,7 +176,9 @@ export class DataQueriesController {
};
if (dataQueryEntity.dataSource) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQueryEntity.dataSource.appId,
});
if (!ability.can('previewQuery', dataQueryEntity.dataSource.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');

View file

@ -19,7 +19,9 @@ export class DataSourcesController {
@Get()
async index(@Request() req, @Query() query) {
const app = await this.appsService.find(query.app_id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
if (!ability.can('getDataSources', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -38,7 +40,9 @@ export class DataSourcesController {
const appId = req.body.app_id;
const app = await this.appsService.find(appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: appId,
});
if (!ability.can('createDataSource', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -57,7 +61,9 @@ export class DataSourcesController {
const dataSource = await this.dataSourcesService.findOne(dataSourceId);
const app = await this.appsService.find(dataSource.appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
if (!ability.can('updateDataSource', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -90,7 +96,9 @@ export class DataSourcesController {
const dataSource = await this.dataSourcesService.findOne(dataSourceId);
const app = await this.appsService.find(dataSource.appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
if (!ability.can('authorizeOauthForSource', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');

View file

@ -0,0 +1,109 @@
import { Controller, Post, Get, Put, Delete, Request, UseGuards, Param } from '@nestjs/common';
import { decamelizeKeys } from 'humps';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { GroupPermissionsService } from '../services/group_permissions.service';
import { PoliciesGuard } from 'src/modules/casl/policies.guard';
import { CheckPolicies } from 'src/modules/casl/check_policies.decorator';
import { AppAbility } from 'src/modules/casl/casl-ability.factory';
import { User } from 'src/entities/user.entity';
@Controller('group_permissions')
export class GroupPermissionsController {
constructor(private groupPermissionsService: GroupPermissionsService) {}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Post()
async create(@Request() req) {
const groupPermission = await this.groupPermissionsService.create(req.user, req.body.group);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Get(':id')
async show(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.findOne(req.user, params.id);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Put(':id/app_group_permissions/:appGroupPermissionId')
async updateAppGroupPermission(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.updateAppGroupPermission(
req.user,
params.id,
params.appGroupPermissionId,
req.body.actions
);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Put(':id')
async update(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.update(req.user, params.id, req.body);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Get()
async index(@Request() req) {
const groupPermissions = await this.groupPermissionsService.findAll(req.user);
return decamelizeKeys({ groupPermissions });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Delete(':id')
async destroy(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.destroy(req.user, params.id);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Get(':id/apps')
async apps(@Request() req, @Param() params) {
const apps = await this.groupPermissionsService.findApps(req.user, params.id);
return decamelizeKeys({ apps });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Get(':id/addable_apps')
async addableApps(@Request() req, @Param() params) {
const apps = await this.groupPermissionsService.findAddableApps(req.user, params.id);
return decamelizeKeys({ apps });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Get(':id/users')
async users(@Request() req, @Param() params) {
const users = await this.groupPermissionsService.findUsers(req.user, params.id);
return decamelizeKeys({ users });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@Get(':id/addable_users')
async addableUsers(@Request() req, @Param() params) {
const users = await this.groupPermissionsService.findAddableUsers(req.user, params.id);
return decamelizeKeys({ users });
}
}

View file

@ -9,11 +9,17 @@ import {
OneToMany,
AfterLoad,
BaseEntity,
ManyToMany,
JoinTable,
AfterInsert,
getRepository,
} from 'typeorm';
import { User } from './user.entity';
import { AppVersion } from './app_version.entity';
import { DataQuery } from './data_query.entity';
import { DataSource } from './data_source.entity';
import { GroupPermission } from './group_permission.entity';
import { AppGroupPermission } from './app_group_permission.entity';
@Entity({ name: 'apps' })
export class App extends BaseEntity {
@ -45,17 +51,47 @@ export class App extends BaseEntity {
@JoinColumn({ name: 'user_id' })
user: User;
@OneToMany(() => AppVersion, (appVersion) => appVersion.app, { eager: true, onDelete: 'CASCADE' })
@OneToMany(() => AppVersion, (appVersion) => appVersion.app, {
eager: true,
onDelete: 'CASCADE',
})
appVersions: AppVersion[];
@OneToMany(() => DataQuery, (dataQuery) => dataQuery.app, { onDelete: 'CASCADE' })
@OneToMany(() => DataQuery, (dataQuery) => dataQuery.app, {
onDelete: 'CASCADE',
})
dataQueries: DataQuery[];
@OneToMany(() => DataSource, (dataSource) => dataSource.app, { onDelete: 'CASCADE' })
@OneToMany(() => DataSource, (dataSource) => dataSource.app, {
onDelete: 'CASCADE',
})
dataSources: DataSource[];
@ManyToMany(() => GroupPermission)
@JoinTable({
name: 'app_group_permissions',
joinColumn: {
name: 'app_id',
},
inverseJoinColumn: {
name: 'group_permission_id',
},
})
groupPermissions: GroupPermission[];
@OneToMany(() => AppGroupPermission, (appGroupPermission) => appGroupPermission.app, { onDelete: 'CASCADE' })
appGroupPermissions: AppGroupPermission[];
public editingVersion;
@AfterInsert()
updateSlug(): void {
if (!this.slug) {
const appRepository = getRepository(App);
appRepository.update(this.id, { slug: this.id });
}
}
@AfterLoad()
async afterLoad(): Promise<void> {
if (this.currentVersionId) {

View file

@ -0,0 +1,47 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { GroupPermission } from './group_permission.entity';
import { App } from './app.entity';
@Entity({ name: 'app_group_permissions' })
export class AppGroupPermission extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'app_id' })
appId: string;
@Column({ name: 'group_permission_id' })
groupPermissionId: string;
@Column({ default: false })
read: boolean;
@Column({ default: false })
update: boolean;
@Column({ default: false })
delete: boolean;
@CreateDateColumn({ default: () => 'now()', name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ default: () => 'now()', name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => App, (app) => app.id)
@JoinColumn({ name: 'app_id' })
app: App;
@ManyToOne(() => GroupPermission, (groupPermission) => groupPermission.id)
@JoinColumn({ name: 'group_permission_id' })
groupPermission: GroupPermission;
}

Some files were not shown because too many files have changed in this diff Show more