Merge branch 'develop' into main

This commit is contained in:
Gandharv 2022-05-07 14:30:01 +05:30 committed by GitHub
commit 1833f83603
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
152 changed files with 7004 additions and 1585 deletions

View file

@ -34,9 +34,12 @@ SMTP_PASSWORD=
SMTP_DOMAIN=
SMTP_PORT=
# DISABLE USER SIGNUPS (true or false). Default: true
# DISABLE USER SIGNUPS (true or false). only applicable if MULTI_ORGANIZATION=true
DISABLE_SIGNUPS=
# Enables all multi organization features
MULTI_ORGANIZATION=
# OBSERVABILITY
APM_VENDOR=
SENTRY_DNS=
@ -44,10 +47,4 @@ SENTRY_DEBUG=
# FEATURE TOGGLE
COMMENT_FEATURE_ENABLE=
#SSO
SSO_DISABLE_SIGNUP=
SSO_RESTRICTED_DOMAIN=
SSO_GOOGLE_OAUTH2_CLIENT_ID=
SSO_GIT_OAUTH2_CLIENT_ID=
SSO_GIT_OAUTH2_CLIENT_SECRET=
ENABLE_MULTIPLAYER_EDITING=true

View file

@ -32,6 +32,14 @@
"NODE_OPTIONS": {
"description": "Node options configured to increase node memory to support app build",
"value": "--max-old-space-size=4096"
},
"DISABLE_SIGNUPS": {
"description": "Disable sign up in login page only applicable if MULTI_ORGANIZATION=true",
"value": "false"
},
"MULTI_ORGANIZATION": {
"description": "Enables multi organization feature",
"value": "false"
}
},
"formation": {
@ -61,4 +69,4 @@
}
}
}
}
}

View file

@ -41,6 +41,7 @@ SENTRY_DEBUG=
# FEATURE TOGGLE
COMMENT_FEATURE_ENABLE=
ENABLE_MULTIPLAYER_EDITING=true
#SSO
SSO_DISABLE_SIGNUP=

View file

@ -0,0 +1,5 @@
{
"label": "Password Login",
"position": 10,
"collapsed": true
}

View file

@ -0,0 +1,24 @@
---
sidebar_position: 1
sidebar_label: Password Login
---
# Password Login
Password login is enabled by default for all organizations. User with admin privilege can enable/disable it.
Select `Manage SSO` from organization options
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/password-login/organization-menu.png)
</div>
Select `Password Login`. You can enable/disable it
<div style={{textAlign: 'center'}}>
![ToolJet - Password Login configs](/img/password-login/password-login.png)
</div>

View file

@ -57,6 +57,14 @@ Use this environment variable to enable/disable the feature that allows you to a
| -------- | ---------------------- |
| COMMENT_FEATURE_ENABLE | `true` or `false` |
#### Multiplayer feature enable ( optional )
Use this environment variable to enable/disable the feature that allows users to collaboratively work on the canvas.
| variable | value |
| -------- | ---------------------- |
| ENABLE_MULTIPLAYER_EDITING | `true` or `false` |
#### Server Host ( optional )
You can specify a different server for backend if it is hosted on another server.
@ -65,22 +73,19 @@ You can specify a different server for backend if it is hosted on another server
| -------- | ---------------------- |
| SERVER_HOST | Configure a hostname for the server as a proxy pass. If no value is set, it defaults to `server`. |
#### Enable multiple organizations ( optional )
If you want to enable multiple environments, set the environment variable `MULTI_ORGANIZATION` to `true`.
#### Disabling signups ( optional )
If you want to restrict the signups and allow new users only by invitations, set the environment variable `DISABLE_SIGNUPS` to `true`.
Sign up is enabled only for multiple organization environment. If you want to restrict the signups and allow new users only by invitations, set the environment variable `DISABLE_SIGNUPS` to `true`.
:::tip
You will still be able to see the signup page but won't be able to successfully submit the form.
:::
#### Disable login and signup using username and password
:::info
Use this feature only if you have configured other methods of authentication, such as SSO.
:::
If you want to restrict users from logging in using regular username and password, set the environment variable `DISABLE_PASSWORD_LOGIN` to `true`.
#### 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.
@ -146,12 +151,6 @@ Prints logs for sentry.
| ---------- | ----------------------------------------- |
| SENTRY_DEBUG | `true` or `false`. Default value is `false` |
#### SSO ( optional )
:::info
We currently support GitHub and Google SSO. Check out docs for **[GitHub SSO](/docs/sso/github)** and **[Google SSO](/docs/sso/google)** for more information on respective environment variables.
:::
#### Server URL ( optional)
This is used to set up for CSP headers and put trace info to be used with APM vendors.

View file

@ -0,0 +1,30 @@
---
sidebar_position: 6
sidebar_label: General Settings
---
# Single Sign-On General Settings
Select `Manage SSO` from organization options
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/password-login/organization-menu.png)
</div>
Select `General Settings`
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/sso/general/general-settings.png)
</div>
## Enable Signup
You can enable/disable `Enable signup`. If it is enabled, new account will be created for user's first time SSO sign in else only existing users will be allowed to sign in via SSO.
## Allowed domain
You can set allowed domains for SSO login, can add multiple domains comma separated. Allowed all domains by default.

View file

@ -5,7 +5,31 @@ title: GitHub
# GitHub Single Sign-on
- Go to the [GitHub Developer settings](https://github.com/settings/developers) and navigate to `OAuth Apps` and create a project. `Authorization callback URL` should be `<Your Domain>/sso/git`
Select `Manage SSO` from organization options
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/password-login/organization-menu.png)
</div>
Select `Git`, Git login will be disabled by default
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/sso/git/manage-sso-1.png)
</div>
Enable Git. You can see `Redirect URL` generated
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/sso/git/manage-sso-2.png)
</div>
Go to [GitHub Developer settings](https://github.com/settings/developers) and navigate to `OAuth Apps` and create a project. `Authorization callback URL` should be the generated `Redirect URL` in Git manage SSO page.
<div style={{textAlign: 'center'}}>
@ -29,14 +53,6 @@ title: GitHub
</div>
- Lastly, supply the environment variables `SSO_GIT_OAUTH2_CLIENT_ID` which is client id and `SSO_GIT_OAUTH2_CLIENT_SECRET` is client secret to your deployment.
Lastly, enter `Client Id` and `Client Secret` in Git manage SSO page and save.
:::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 GitHub sign-in button will now be available in your ToolJet login screen.
The GitHub sign-in button will now be available in your ToolJet login screen if you have not enabled multiple organization.

View file

@ -5,7 +5,31 @@ title: Google
# Google Single Sign-on
- Go to the [Google cloud console](https://console.cloud.google.com/) and create a project.
Select `Manage SSO` from organization options
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/password-login/organization-menu.png)
</div>
Select `Google`, Google login will be disabled by default
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/sso/google/manage-sso-1.png)
</div>
Enable Google. You can see `Redirect URL` generated
<div style={{textAlign: 'center'}}>
![ToolJet - SSO configs](/img/sso/google/manage-sso-2.png)
</div>
Go to [Google cloud console](https://console.cloud.google.com/) and create a project.
<div style={{textAlign: 'center'}}>
@ -48,20 +72,14 @@ user who is signing in
</div>
- Lastly, supply the environment variable `SSO_GOOGLE_OAUTH2_CLIENT_ID` to your deployment. This value will be available from your [Google cloud console credentials page](https://console.cloud.google.com/apis/credentials)
Set the `Redirect URL` generated at manage SSO `Google` page under Authorised redirect URIs
:::info
<div style={{textAlign: 'center'}}>
### Restrict to your domains
Set the environment variable `SSO_RESTRICTED_DOMAIN` to ensure that ToolJet verifies the domain of the user who signs in via SSO, on the server side.
If you're setting this environment variable, please make sure that the value does not contain any protocols, subdomains or slashes. It should
simply be `yourdomain.com`. Add multiple domians separated by coma example : `yourdomain.com,yourotherdomain.com`
:::
![ToolJet - authorized redirect urls](/img/sso/google/authorized-redirect-urls.png)
:::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.
:::
</div>
<br />
The Google sign-in button will now be available in your ToolJet login screen.
Lastly, set the `client id` in google manage SSO page. This value will be available from your [Google cloud console credentials page](https://console.cloud.google.com/apis/credentials)
The Google sign-in button will now be available in your ToolJet login screen, if you are not enabled multiple organization.

View file

@ -1,3 +1,5 @@
const isProd = process.env.NODE_ENV === 'production';
/** @type {import('@docusaurus/types').DocusaurusConfig} */
module.exports = {
title: 'ToolJet - Documentation',
@ -33,7 +35,7 @@ module.exports = {
},
navbar: {
logo: {
href: '/docs/intro',
href: '/docs',
alt: 'ToolJet Logo',
src: 'img/logo.svg',
width: 90
@ -112,11 +114,13 @@ module.exports = {
customCss: require.resolve('./src/css/custom.css'),
},
sitemap: {},
gtag: {
trackingID: process.env.GA_MID,
// Optional fields.
anonymizeIP: true, // Should IPs be anonymized?
},
gtag: isProd
? {
trackingID: process.env.GA_MID,
// Optional fields.
anonymizeIP: true, // Should IPs be anonymized?
}
: undefined,
},
],
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

BIN
docs/static/img/sso/git/manage-sso-1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
docs/static/img/sso/git/manage-sso-2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

View file

@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 2H3C2.44772 2 2 2.44772 2 3V3.5C2 4.05228 2.44772 4.5 3 4.5H4C4.55228 4.5 5 4.05228 5 3.5V3C5 2.44772 4.55228 2 4 2Z" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 6.5H3C2.44772 6.5 2 6.94772 2 7.5V9C2 9.55228 2.44772 10 3 10H4C4.55228 10 5 9.55228 5 9V7.5C5 6.94772 4.55228 6.5 4 6.5Z" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 2H8C7.44772 2 7 2.44772 7 3V9C7 9.55229 7.44772 10 8 10H9C9.55228 10 10 9.55229 10 9V3C10 2.44772 9.55228 2 9 2Z" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View file

@ -0,0 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 3.5L5 6L2.5 8.5" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 8.5H9.5" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-pinned-off" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.85" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<line x1="3" y1="3" x2="21" y2="21"></line>
<path d="M15 4.5l-3.249 3.249m-2.57 1.433l-2.181 .818l-1.5 1.5l7 7l1.5 -1.5l.82 -2.186m1.43 -2.563l3.25 -3.251"></path>
<line x1="9" y1="15" x2="4.5" y2="19.5"></line>
<line x1="14.5" y1="4" x2="20" y2="9.5"></line>
</svg>

After

Width:  |  Height:  |  Size: 572 B

View file

@ -0,0 +1,8 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4C3.55228 4 4 3.55228 4 3C4 2.44772 3.55228 2 3 2C2.44772 2 2 2.44772 2 3C2 3.55228 2.44772 4 3 4Z" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 10C9.55228 10 10 9.55228 10 9C10 8.44772 9.55228 8 9 8C8.44772 8 8 8.44772 8 9C8 9.55228 8.44772 10 9 10Z" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 3H8C8.26522 3 8.51957 3.10536 8.70711 3.29289C8.89464 3.48043 9 3.73478 9 4V8" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 4.5L5.5 3L7 1.5" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 9H4C3.73478 9 3.48043 8.89464 3.29289 8.70711C3.10536 8.51957 3 8.26522 3 8V4" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 7.5L6.5 9L5 10.5" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

View file

@ -0,0 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 2C1.25 4.5 1.25 7 2.5 10M9.5 2C10.75 4.5 10.75 7 9.5 10M4.5 4.5H5C5.5 4.5 5.5 5 6.008 6.2635C6.5 7.5 6.5 8 7 8H7.5" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 8C4.75 8 5.5 7 6 6.25C6.5 5.5 7.25 4.5 8 4.5" stroke="#2C3E50" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="17px" height="17px" viewBox="0 0 17 17" version="1.1" >
<g>
</g>
<path d="M2.207 8h3.772v1h-3.772l1.646 1.646-0.707 0.707-2.853-2.853 2.854-2.854 0.707 0.707-1.647 1.647zM13.854 5.646l-0.707 0.707 1.646 1.647h-3.772v1h3.772l-1.646 1.646 0.707 0.707 2.853-2.853-2.853-2.854zM8 17h1v-17h-1v17z" stroke="#61656F" stroke-width="0"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

View file

@ -1 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon" 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 d="M11 17a2.5 2.5 0 0 0 2 0" /><path d="M12 3c-4.664 0 -7.396 2.331 -7.862 5.595a11.816 11.816 0 0 0 2 8.592a10.777 10.777 0 0 0 3.199 3.064c1.666 1 3.664 1 5.33 0a10.777 10.777 0 0 0 3.199 -3.064a11.89 11.89 0 0 0 2 -8.592c-.466 -3.265 -3.198 -5.595 -7.862 -5.595z" /><line x1="8" y1="11" x2="10" y2="13" /><line x1="16" y1="11" x2="14" y2="13" /></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user-exclamation" 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>
<circle cx="9" cy="7" r="4"></circle>
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path>
<line x1="19" y1="7" x2="19" y2="10"></line>
<line x1="19" y1="14" x2="19" y2="14.01"></line>
</svg>

Before

Width:  |  Height:  |  Size: 605 B

After

Width:  |  Height:  |  Size: 507 B

View file

@ -1,11 +1,13 @@
import React from 'react';
import { buildURLWithQuery } from '@/_helpers/utils';
export default function GitSSOLoginButton() {
const clientId = window.public_config.SSO_GIT_OAUTH2_CLIENT_ID;
export default function GitSSOLoginButton({ configs }) {
const gitLogin = (e) => {
e.preventDefault();
window.location.href = buildURLWithQuery('https://github.com/login/oauth/authorize', { client_id: clientId });
window.location.href = buildURLWithQuery('https://github.com/login/oauth/authorize', {
client_id: configs?.client_id,
scope: 'user:email',
});
};
return (
<div>

View file

@ -1,24 +1,15 @@
import React from 'react';
import GoogleLogin from 'react-google-login';
import { authenticationService } from '@/_services';
export default function GoogleSSOLoginButton(props) {
const googleSSOSuccessHandler = (googleUser) => {
const idToken = googleUser.getAuthResponse().id_token;
authenticationService
.signInViaOAuth({ token: idToken, origin: 'google' })
.then(props.authSuccessHandler)
.catch(props.authFailureHandler);
};
return (
<div className="mt-2">
<GoogleLogin
clientId={window.public_config?.SSO_GOOGLE_OAUTH2_CLIENT_ID}
clientId={props.configs?.client_id}
buttonText="Login"
onSuccess={googleSSOSuccessHandler}
onFailure={props.authFailureHandler}
cookiePolicy={'single_host_origin'}
uxMode="redirect"
redirectUri={`${window.location.protocol}//${window.location.host}/sso/google/${props.configId}`}
render={(renderProps) => (
<div>
<button {...renderProps} className="btn border-0 rounded-2">

View file

@ -1,4 +1,5 @@
import React from 'react';
import config from 'config';
import { Router, Route, Redirect } from 'react-router-dom';
import { history } from '@/_helpers';
import { authenticationService, tooljetService } from '@/_services';
@ -6,7 +7,7 @@ import { PrivateRoute } from '@/_components';
import { HomePage } from '@/HomePage';
import { LoginPage } from '@/LoginPage';
import { SignupPage } from '@/SignupPage';
import { ConfirmationPage } from '@/ConfirmationPage';
import { ConfirmationPage, OrganizationInvitationPage } from '@/ConfirmationPage';
import { Authorize } from '@/Oauth2';
import { Authorize as Oauth } from '@/Oauth';
import { Viewer } from '@/Editor';
@ -17,9 +18,11 @@ import { SettingsPage } from '../SettingsPage/SettingsPage';
import { OnboardingModal } from '@/Onboarding/OnboardingModal';
import { ForgotPassword } from '@/ForgotPassword';
import { ResetPassword } from '@/ResetPassword';
import { ManageSSO } from '@/ManageSSO';
import { lt } from 'semver';
import { Toaster } from 'react-hot-toast';
import { RealtimeEditor } from '@/Editor/RealtimeEditor';
import { Editor } from '@/Editor/Editor';
import '@/_styles/theme.scss';
import 'emoji-mart/css/emoji-mart.css';
@ -119,8 +122,9 @@ class App extends React.Component {
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
<Route path="/login" component={LoginPage} />
<Route path="/sso/:origin" component={Oauth} />
<Route path="/login/:organisationId" exact component={LoginPage} />
<Route path="/login" exact component={LoginPage} />
<Route path="/sso/:origin/:configId" component={Oauth} />
<Route path="/signup" component={SignupPage} />
<Route path="/forgot-password" component={ForgotPassword} />
<Route path="/reset-password" component={ResetPassword} />
@ -132,17 +136,30 @@ class App extends React.Component {
pathname: '/confirm',
state: {
token: props.match.params.token,
search: props.location.search,
},
}}
/>
)}
/>
<Route path="/confirm" component={ConfirmationPage} />
<Route
path="/organization-invitations/:token"
render={(props) => (
<Redirect
to={{
pathname: '/confirm-invite',
state: {
token: props.match.params.token,
},
}}
/>
)}
/>
<Route path="/confirm-invite" component={OrganizationInvitationPage} />
<PrivateRoute
exact
path="/apps/:id"
component={RealtimeEditor}
component={config.ENABLE_MULTIPLAYER_EDITING ? RealtimeEditor : Editor}
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
@ -174,6 +191,13 @@ class App extends React.Component {
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
<PrivateRoute
exact
path="/manage-sso"
component={ManageSSO}
switchDarkMode={this.switchDarkMode}
darkMode={darkMode}
/>
<PrivateRoute
exact
path="/groups"

View file

@ -1,7 +1,6 @@
import React from 'react';
import { userService } from '@/_services';
import { toast } from 'react-hot-toast';
import queryString from 'query-string';
class ConfirmationPage extends React.Component {
constructor(props) {
@ -9,7 +8,6 @@ class ConfirmationPage extends React.Component {
this.state = {
isLoading: false,
newSignup: !!queryString.parse(props.location.state.search).signup,
};
this.formRef = React.createRef(null);
}
@ -26,7 +24,7 @@ class ConfirmationPage extends React.Component {
setPassword = (e) => {
e.preventDefault();
const token = this.props.location.state.token;
const { password, organization, role, newSignup, firstName, lastName, password_confirmation } = this.state;
const { password, organization, role, firstName, lastName, password_confirmation } = this.state;
this.setState({ isLoading: true });
if (!password || !password_confirmation || !password.trim() || !password_confirmation.trim()) {
@ -51,13 +49,12 @@ class ConfirmationPage extends React.Component {
password,
organization,
role,
newSignup,
firstName,
lastName,
})
.then(() => {
this.setState({ isLoading: false });
toast.success('Password has been set successfully.', {
toast.success('Account has been setup successfully.', {
position: 'top-center',
});
this.props.history.push('/login');
@ -69,7 +66,7 @@ class ConfirmationPage extends React.Component {
};
render() {
const { isLoading, newSignup } = this.state;
const { isLoading } = this.state;
const roles = [
'CTO/CIO',
'Founder/CEO',
@ -102,59 +99,54 @@ class ConfirmationPage extends React.Component {
<form className="card card-md" action="." method="get" autoComplete="off">
<div className="card-body">
<h2 className="card-title text-center mb-4">Set up your account</h2>
{newSignup && (
<>
<div className="mb-3">
<label className="form-label">First name</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="firstName"
type="text"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<label className="form-label">Last name</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="lastName"
type="text"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<label className="form-label">Organization</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="organization"
type="text"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<div className="form-label">Role</div>
<select className="form-select" name="role" defaultValue="" onChange={this.handleChange}>
<option value="" disabled>
Please select
</option>
{roleOptions}
</select>
</div>
</>
)}
<div className="mb-3">
<label className="form-label">First name</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="firstName"
type="text"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<label className="form-label">Last name</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="lastName"
type="text"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<label className="form-label">Organization</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="organization"
type="text"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<div className="form-label">Role</div>
<select className="form-select" name="role" defaultValue="" onChange={this.handleChange}>
<option value="" disabled>
Please select
</option>
{roleOptions}
</select>
</div>
<div className="mb-3">
<label className="form-label">Password</label>
<div className="input-group input-group-flat">

View file

@ -0,0 +1,143 @@
import React from 'react';
import { userService } from '@/_services';
import { toast } from 'react-hot-toast';
class OrganizationInvitationPage extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoading: false,
};
this.formRef = React.createRef(null);
this.single_organization = window.public_config?.MULTI_ORGANIZATION !== 'true';
}
handleChange = (event) => {
this.setState({ [event.target.name]: event.target.value });
};
acceptInvite = (e, isSetPassword) => {
e.preventDefault();
const token = this.props.location.state.token;
const { password, password_confirmation } = this.state;
this.setState({ isLoading: true });
if (isSetPassword) {
if (!password || !password_confirmation || !password.trim() || !password_confirmation.trim()) {
this.setState({ isLoading: false });
toast.error("Password shouldn't be empty or contain white space(s)", {
position: 'top-center',
});
return;
}
if (password !== password_confirmation) {
this.setState({ isLoading: false });
toast.error("Passwords don't match", {
position: 'top-center',
});
return;
}
}
userService
.acceptInvite({
token,
password,
})
.then(() => {
this.setState({ isLoading: false });
toast.success(`Added to the organization${isSetPassword ? ' and password has been set ' : ' '}successfully.`, {
position: 'top-center',
});
this.props.history.push('/login');
})
.catch(({ error }) => {
this.setState({ isLoading: false });
toast.error(error, { position: 'top-center' });
});
};
render() {
const { isLoading } = this.state;
return (
<div className="page page-center" ref={this.formRef}>
<div className="container-tight py-2 invitation-page">
<div className="text-center mb-4">
<a href=".">
<img src="/assets/images/logo-color.svg" height="30" alt="" />
</a>
</div>
<form className="card card-md" action="." method="get" autoComplete="off">
<div className="card-body">
{!this.single_organization && (
<>
<h2 className="card-title text-center mb-2">Already have an account?</h2>
<div className="mb-3">
<button
className={`btn mt-2 btn-primary w-100 ${isLoading ? ' btn-loading' : ''}`}
onClick={(e) => this.acceptInvite(e)}
disabled={isLoading}
>
Accept invite
</button>
</div>
<div className="org-invite-or">
<h2>
<span>OR</span>
</h2>
</div>
</>
)}
<h2 className="card-title text-center mb-4">Set up your account</h2>
<div className="mb-3">
<label className="form-label">Password</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="password"
type="password"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="mb-3">
<label className="form-label">Confirm Password</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="password_confirmation"
type="password"
className="form-control"
autoComplete="off"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="form-footer">
<p>
By clicking the button below, you agree to our{' '}
<a href="https://tooljet.io/terms">Terms and Conditions</a>.
</p>
<button
className={`btn mt-2 btn-primary w-100 ${isLoading ? ' btn-loading' : ''}`}
onClick={(e) => this.acceptInvite(e, true)}
disabled={isLoading}
>
Finish account setup and accept invite
</button>
</div>
</div>
</form>
</div>
</div>
);
}
}
export { OrganizationInvitationPage };

View file

@ -1 +1,2 @@
export * from './ConfirmationPage';
export * from './OrganizationInvitationPage';

View file

@ -38,6 +38,7 @@ import { renderTooltip } from '@/_helpers/appUtils';
import { RangeSlider } from './Components/RangeSlider';
import { Timeline } from './Components/Timeline';
import { SvgImage } from './Components/SvgImage';
import { VerticalDivider } from './Components/verticalDivider';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import '@/_styles/custom.scss';
import { resolveProperties, resolveStyles } from './component-properties-resolution';
@ -82,6 +83,7 @@ const AllComponents = {
RangeSlider,
Timeline,
SvgImage,
VerticalDivider,
};
export const Box = function Box({

View file

@ -12,10 +12,12 @@ export const DropDown = function DropDown({
onComponentClick,
id,
component,
exposedVariables,
}) {
let { label, value, display_values, values } = properties;
const { selectedTextColor, borderRadius, visibility, disabledState, justifyContent } = styles;
const [currentValue, setCurrentValue] = useState(() => value);
const { value: exposedValue } = exposedVariables;
if (!_.isArray(values)) {
values = [];
@ -46,12 +48,13 @@ export const DropDown = function DropDown({
if (values?.includes(value)) {
newValue = value;
}
setExposedVariable('value', value);
setCurrentValue(newValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {
setExposedVariable('value', currentValue);
if (exposedValue !== currentValue) setExposedVariable('value', currentValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValue]);

View file

@ -916,7 +916,7 @@ export const componentTypes = [
justifyContent: { type: 'alignButtons', displayName: 'Align Text' },
},
exposedVariables: {
value: null,
value: 2,
searchText: '',
},
definition: {
@ -2023,4 +2023,39 @@ export const componentTypes = [
},
},
},
{
name: 'VerticalDivider',
displayName: 'Vertical Divider',
description: 'Vertical Separator between components',
component: 'VerticalDivider',
defaultSize: {
width: 2,
height: 100,
},
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: 'toggle', displayName: 'Visibility' },
},
exposedVariables: {
value: {},
},
definition: {
others: {
showOnDesktop: { value: '{{true}}' },
showOnMobile: { value: '{{false}}' },
},
properties: {},
events: [],
styles: {
visibility: { value: '{{true}}' },
dividerColor: { value: '#E7E8EA' },
},
},
},
];

View file

@ -0,0 +1,16 @@
import React from 'react';
export const VerticalDivider = function Divider({ styles, height, width }) {
const { visibility, dividerColor } = styles;
const color = dividerColor ?? '#E7E8EA';
return (
<div className="row" style={{ display: visibility ? 'flex' : 'none', padding: '0 8px', width, height }}>
<div className="col-6"></div>
<div
className="col-6 border-right"
style={{ height, width: '1px', backgroundColor: color, padding: '0rem', marginLeft: '0.5rem' }}
></div>
</div>
);
};

View file

@ -1,5 +1,6 @@
/* eslint-disable import/no-named-as-default */
import React, { createRef } from 'react';
import cx from 'classnames';
import { datasourceService, dataqueryService, appService, authenticationService, appVersionService } from '@/_services';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
@ -154,11 +155,13 @@ class Editor extends React.Component {
* current appDef is equal to the newAppDef then we do not trigger a realtimeSave
*/
initRealtimeSave = () => {
this.props.ymap.observe(() => {
if (!isEqual(this.state.editingVersion?.id, this.props.ymap.get('appDef').editingVersionId)) return;
if (isEqual(this.state.appDefinition, this.props.ymap.get('appDef').newDefinition)) return;
if (!config.ENABLE_MULTIPLAYER_EDITING) return null;
this.realtimeSave(this.props.ymap.get('appDef').newDefinition, { skipAutoSave: true, skipYmapUpdate: true });
this.props.ymap?.observe(() => {
if (!isEqual(this.state.editingVersion?.id, this.props.ymap?.get('appDef').editingVersionId)) return;
if (isEqual(this.state.appDefinition, this.props.ymap?.get('appDef').newDefinition)) return;
this.realtimeSave(this.props.ymap?.get('appDef').newDefinition, { skipAutoSave: true, skipYmapUpdate: true });
});
};
@ -283,6 +286,7 @@ class Editor extends React.Component {
data.data_queries.forEach((query) => {
queryState[query.name] = {
...DataSourceTypes.find((source) => source.kind === query.kind).exposedVariables,
kind: DataSourceTypes.find((source) => source.kind === query.kind).kind,
...this.state.currentState.queries[query.name],
};
});
@ -469,9 +473,17 @@ class Editor extends React.Component {
this.canRedo = true;
if (!appDefinition) return;
this.setState({
appDefinition,
});
this.setState(
{
appDefinition,
},
() => {
this.props.ymap?.set('appDef', {
newDefinition: appDefinition,
editingVersionId: this.state.editingVersion?.id,
});
}
);
}
};
@ -486,16 +498,24 @@ class Editor extends React.Component {
this.canRedo = this.currentVersionChanges.hasOwnProperty(this.currentVersion + 1);
if (!appDefinition) return;
this.setState({
appDefinition,
});
this.setState(
{
appDefinition,
},
() => {
this.props.ymap?.set('appDef', {
newDefinition: appDefinition,
editingVersionId: this.state.editingVersion?.id,
});
}
);
}
};
appDefinitionChanged = (newDefinition, opts = {}) => {
if (isEqual(this.state.appDefinition, newDefinition)) return;
if (!opts.skipYmapUpdate) {
this.props.ymap.set('appDef', { newDefinition, editingVersionId: this.state.editingVersion?.id });
if (config.ENABLE_MULTIPLAYER_EDITING && !opts.skipYmapUpdate) {
this.props.ymap?.set('appDef', { newDefinition, editingVersionId: this.state.editingVersion?.id });
}
produce(
@ -576,7 +596,7 @@ class Editor extends React.Component {
setStateAsync(_self, newDefinition).then(() => {
computeComponentState(_self, _self.state.appDefinition.components);
this.autoSave();
this.props.ymap.set('appDef', {
this.props.ymap?.set('appDef', {
newDefinition: newDefinition.appDefinition,
editingVersionId: this.state.editingVersion?.id,
});
@ -600,7 +620,7 @@ class Editor extends React.Component {
appDefinition,
},
() => {
this.props.ymap.set('appDef', {
this.props.ymap?.set('appDef', {
newDefinition: appDefinition,
editingVersionId: this.state.editingVersion?.id,
});
@ -1014,11 +1034,13 @@ class Editor extends React.Component {
</span>
</div>
)}
<RealtimeAvatars
updatePresence={this.props.updatePresence}
editingVersionId={this.state?.editingVersion?.id}
self={this.props.self}
/>
{config.ENABLE_MULTIPLAYER_EDITING && (
<RealtimeAvatars
updatePresence={this.props.updatePresence}
editingVersionId={this.state?.editingVersion?.id}
self={this.props.self}
/>
)}
{editingVersion && (
<AppVersionsManager
appId={appId}
@ -1029,8 +1051,6 @@ class Editor extends React.Component {
closeCreateVersionModalPrompt={this.closeCreateVersionModalPrompt}
/>
)}
<div className="layout-buttons cursor-pointer">{this.renderLayoutIcon(currentLayout === 'desktop')}</div>
<div className="navbar-nav flex-row order-md-last release-buttons">
<div className="nav-item dropdown d-none d-md-flex me-2">
<a
@ -1086,6 +1106,14 @@ class Editor extends React.Component {
globalSettingsChanged={this.globalSettingsChanged}
globalSettings={appDefinition.globalSettings}
currentState={currentState}
appDefinition={{
components: appDefinition.components,
queries: dataQueries,
selectedComponent: this.state?.selectedComponent,
}}
setSelectedComponent={this.setSelectedComponent}
removeComponent={this.removeComponent}
runQuery={(queryId, queryName) => runQuery(this, queryId, queryName)}
toggleAppMaintenance={this.toggleAppMaintenance}
is_maintenance_on={this.state.app.is_maintenance_on}
/>
@ -1107,7 +1135,7 @@ class Editor extends React.Component {
backgroundColor: this.state.appDefinition.globalSettings.canvasBackgroundColor,
}}
>
{this.props.othersOnSameVersion.map(({ id, presence }) => {
{this.props?.othersOnSameVersion?.map(({ id, presence }) => {
if (!presence) return null;
return (
<Cursor key={id} name={presence.firstName} color={presence.color} x={presence.x} y={presence.y} />
@ -1317,10 +1345,56 @@ class Editor extends React.Component {
</div>
</div>
<div className="editor-sidebar">
<div className="col-md-12">
<div></div>
<div className="editor-actions col-md-12">
<div className="m-auto undo-redo-buttons">
<svg
onClick={this.handleUndo}
xmlns="http://www.w3.org/2000/svg"
className={cx('cursor-pointer icon icon-tabler icon-tabler-arrow-back-up', {
disabled: !this.canUndo,
})}
width="44"
height="44"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke={this.props.darkMode ? '#fff' : '#2c3e50'}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none">
<title>undo</title>
</path>
<path d="M9 13l-4 -4l4 -4m-4 4h11a4 4 0 0 1 0 8h-1" fill="none">
<title>undo</title>
</path>
</svg>
<svg
title="redo"
onClick={this.handleRedo}
xmlns="http://www.w3.org/2000/svg"
className={cx('cursor-pointer icon icon-tabler icon-tabler-arrow-forward-up', {
disabled: !this.canRedo,
})}
width="44"
height="44"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke={this.props.darkMode ? '#fff' : '#2c3e50'}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none">
<title>redo</title>
</path>
<path d="M15 13l4 -4l-4 -4m4 4h-11a4 4 0 0 0 0 8h1" />
</svg>
</div>
<div className="layout-buttons cursor-pointer">
{this.renderLayoutIcon(currentLayout === 'desktop')}
</div>
</div>
{currentSidebarTab === 1 && (
<div className="pages-container">
{selectedComponent &&

View file

@ -5,15 +5,12 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="1" y="1" width="36" height="26" rx="1" fill='#EEF3F9' />
<rect x="37" y="1" width="36" height="26" rx="1" fill='#FFFFFC' />
<g clipPath="url(#clip0_392_5486)">
<path
d="M58.375 8H53.125C52.5039 8 52 8.50391 52 9.125V18.875C52 19.4961 52.5039 20 53.125 20H58.375C58.9961 20 59.5 19.4961 59.5 18.875V9.125C59.5 8.50391 58.9961 8 58.375 8ZM55.75 19.25C55.3352 19.25 55 18.9148 55 18.5C55 18.0852 55.3352 17.75 55.75 17.75C56.1648 17.75 56.5 18.0852 56.5 18.5C56.5 18.9148 56.1648 19.25 55.75 19.25ZM58.375 16.7188C58.375 16.8734 58.2484 17 58.0938 17H53.4062C53.2516 17 53.125 16.8734 53.125 16.7188V9.40625C53.125 9.25156 53.2516 9.125 53.4062 9.125H58.0938C58.2484 9.125 58.375 9.25156 58.375 9.40625V16.7188Z"
fill='#8092AC'
/>
</g>
<rect x="0.5" y="0.5" width="73" height="27" rx="3.5" stroke="#D2DDEC" />
<path d="M37 1L37 27" stroke="#D2DDEC" strokeLinecap="round" />
<path
d="M17.2 18.2H14.2C13.8817 18.2 13.5765 18.0736 13.3515 17.8485C13.1264 17.6235 13 17.3183 13 17V9.2C13 8.54 13.54 8 14.2 8H23.8C24.1183 8 24.4235 8.12643 24.6485 8.35147C24.8736 8.57652 25 8.88174 25 9.2V17C25 17.3183 24.8736 17.6235 24.6485 17.8485C24.4235 18.0736 24.1183 18.2 23.8 18.2H20.8L23.2 19.4V20H14.8V19.4L17.2 18.2ZM14.2 9.2V15.8H23.8V9.2H14.2Z"

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -5,15 +5,12 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="1" y="1" width="36" height="26" rx="1" fill='#FFFFFC'/>
<rect x="37" y="1" width="36" height="26" rx="1" fill='#EEF3F9' />
<g clipPath="url(#clip0_392_5486)">
<path
d="M58.375 8H53.125C52.5039 8 52 8.50391 52 9.125V18.875C52 19.4961 52.5039 20 53.125 20H58.375C58.9961 20 59.5 19.4961 59.5 18.875V9.125C59.5 8.50391 58.9961 8 58.375 8ZM55.75 19.25C55.3352 19.25 55 18.9148 55 18.5C55 18.0852 55.3352 17.75 55.75 17.75C56.1648 17.75 56.5 18.0852 56.5 18.5C56.5 18.9148 56.1648 19.25 55.75 19.25ZM58.375 16.7188C58.375 16.8734 58.2484 17 58.0938 17H53.4062C53.2516 17 53.125 16.8734 53.125 16.7188V9.40625C53.125 9.25156 53.2516 9.125 53.4062 9.125H58.0938C58.2484 9.125 58.375 9.25156 58.375 9.40625V16.7188Z"
fill='#4D72FA'
/>
</g>
<rect x="0.5" y="0.5" width="73" height="27" rx="3.5" stroke="#D2DDEC" />
<path d="M37 1L37 27" stroke="#D2DDEC" strokeLinecap="round" />
<path
d="M17.2 18.2H14.2C13.8817 18.2 13.5765 18.0736 13.3515 17.8485C13.1264 17.6235 13 17.3183 13 17V9.2C13 8.54 13.54 8 14.2 8H23.8C24.1183 8 24.4235 8.12643 24.6485 8.35147C24.8736 8.57652 25 8.88174 25 9.2V17C25 17.3183 24.8736 17.6235 24.6485 17.8485C24.4235 18.0736 24.1183 18.2 23.8 18.2H20.8L23.2 19.4V20H14.8V19.4L17.2 18.2ZM14.2 9.2V15.8H23.8V9.2H14.2Z"

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,12 +1,128 @@
import React from 'react';
import React, { useMemo } from 'react';
import usePinnedPopover from '@/_hooks/usePinnedPopover';
import { LeftSidebarItem } from './SidebarItem';
import { SidebarPinnedButton } from './SidebarPinnedButton';
import ReactJson from 'react-json-view';
import JSONTreeViewer from '@/_ui/JSONTreeViewer';
import _ from 'lodash';
import { allSvgs } from '@tooljet/plugins/client';
import RunjsIcon from '../Icons/runjs.svg';
import { toast } from 'react-hot-toast';
export const LeftSidebarInspector = ({ darkMode, currentState }) => {
export const LeftSidebarInspector = ({
darkMode,
currentState,
appDefinition,
setSelectedComponent,
removeComponent,
runQuery,
}) => {
const [open, trigger, content, popoverPinned, updatePopoverPinnedState] = usePinnedPopover(false);
const jsonData = Object.entries(currentState).filter(([key]) => key !== 'errors');
const componentDefinitions = JSON.parse(JSON.stringify(appDefinition))['components'];
const queryDefinitions = appDefinition['queries'];
const selectedComponent = React.useMemo(() => {
return {
id: appDefinition['selectedComponent']?.id,
component: appDefinition['selectedComponent']?.component?.name,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appDefinition['selectedComponent']]);
const queries = {};
if (!_.isEmpty(queryDefinitions)) {
queryDefinitions.forEach((query) => {
queries[query.name] = { id: query.id };
});
}
const memoizedJSONData = React.useMemo(() => {
const data = _.merge(currentState, { queries });
const jsontreeData = { ...data };
delete jsontreeData.errors;
return jsontreeData;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentState]);
const queryIcons = Object.entries(currentState['queries']).map(([key, value]) => {
if (value.kind === 'runjs') {
return { iconName: key, jsx: () => <RunjsIcon style={{ height: 16, width: 16, marginRight: 12 }} /> };
}
const Icon = allSvgs[value.kind];
return { iconName: key, jsx: () => <Icon style={{ height: 16, width: 16, marginRight: 12 }} /> };
});
const componentIcons = Object.entries(currentState['components']).map(([key, value]) => {
const component = componentDefinitions[value.id]?.component ?? {};
if (!_.isEmpty(component) && component.name === key) {
return {
iconName: key,
iconPath: `/assets/images/icons/widgets/${
component.component.toLowerCase() === 'radiobutton' ? 'radio-button' : component.component.toLowerCase()
}.svg`,
className: 'component-icon',
};
}
});
const iconsList = useMemo(() => [...queryIcons, ...componentIcons], [queryIcons, componentIcons]);
const handleRemoveComponent = (component) => {
removeComponent(component);
};
const handleSelectComponentOnEditor = (component) => {
setSelectedComponent(component.id, component);
};
const handleRunQuery = (query, currentNode) => {
runQuery(query.id, currentNode);
};
const copyToClipboard = (data) => {
const stringified = JSON.stringify(data, null, 2);
navigator.clipboard.writeText(stringified);
return toast.success('Copied to the clipboard', { position: 'top-center' });
};
const updatePinnedParentState = () => {
if (!popoverPinned) {
updatePopoverPinnedState();
}
};
const callbackActions = [
{
for: 'queries',
actions: [
{
name: 'Run Query',
dispatchAction: handleRunQuery,
icon: true,
src: '/assets/images/icons/editor/play.svg',
width: 8,
height: 8,
},
],
enableForAllChildren: false,
enableFor1stLevelChildren: true,
},
{
for: 'components',
actions: [
{ name: 'Select Widget', dispatchAction: handleSelectComponentOnEditor, icon: false, onSelect: true },
{ name: 'Delete Widget', dispatchAction: handleRemoveComponent, icon: true, iconName: 'trash' },
],
enableForAllChildren: false,
enableFor1stLevelChildren: true,
},
{
for: 'all',
actions: [{ name: 'Copy value', dispatchAction: copyToClipboard, icon: false }],
},
];
return (
<>
<LeftSidebarItem
@ -19,7 +135,7 @@ export const LeftSidebarInspector = ({ darkMode, currentState }) => {
<div
{...content}
className={`card popover ${open || popoverPinned ? 'show' : 'hide'}`}
style={{ resize: 'horizontal', maxWidth: '50%' }}
style={{ resize: 'horizontal', maxWidth: '60%', minWidth: '312px' }}
>
<SidebarPinnedButton
darkMode={darkMode}
@ -28,22 +144,22 @@ export const LeftSidebarInspector = ({ darkMode, currentState }) => {
updateState={updatePopoverPinnedState}
/>
<div style={{ marginTop: '1rem' }} className="card-body">
{jsonData.map((data) => (
<ReactJson
key={data[0]}
src={data[1]}
theme={darkMode ? 'shapeshifter' : 'rjv-default'}
name={data[0]}
style={{ fontSize: '0.7rem' }}
enableClipboard={false}
displayDataTypes={false}
collapsed={true}
displayObjectSize={false}
quotesOnKeys={false}
sortKeys={true}
collapseStringsAfterLength={1000}
/>
))}
<JSONTreeViewer
data={memoizedJSONData}
useIcons={true}
iconsList={iconsList}
useIndentedBlock={true}
enableCopyToClipboard={true}
useActions={true}
actionsList={callbackActions}
currentState={appDefinition}
actionIdentifier="id"
expandWithLabels={false}
selectedComponent={selectedComponent}
treeType="inspector"
parentPopoverState={popoverPinned}
updateParentState={updatePinnedParentState}
/>
</div>
</div>
</>

View file

@ -4,6 +4,7 @@ import Tooltip from 'react-bootstrap/Tooltip';
export const SidebarPinnedButton = ({ state, component, updateState, darkMode }) => {
const tooltipMsg = state ? `Unpin ${component}` : `Pin ${component}`;
const pinnedIcon = !state ? 'pinned' : 'pinnedoff';
return (
<SidebarPinnedButton.OverlayContainer tip={tooltipMsg}>
@ -13,7 +14,12 @@ export const SidebarPinnedButton = ({ state, component, updateState, darkMode })
}`}
onClick={updateState}
>
<img className="svg-icon" src={`/assets/images/icons/editor/left-sidebar/pinned.svg`} width="16" height="16" />
<img
className="svg-icon"
src={`/assets/images/icons/editor/left-sidebar/${pinnedIcon}.svg`}
width="16"
height="16"
/>
</div>
</SidebarPinnedButton.OverlayContainer>
);

View file

@ -26,6 +26,10 @@ export const LeftSidebar = ({
globalSettingsChanged,
globalSettings,
currentState,
appDefinition,
setSelectedComponent,
removeComponent,
runQuery,
toggleAppMaintenance,
is_maintenance_on,
}) => {
@ -33,7 +37,14 @@ export const LeftSidebar = ({
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
return (
<div className="left-sidebar">
<LeftSidebarInspector darkMode={darkMode} currentState={currentState} />
<LeftSidebarInspector
darkMode={darkMode}
currentState={currentState}
appDefinition={appDefinition}
setSelectedComponent={setSelectedComponent}
removeComponent={removeComponent}
runQuery={runQuery}
/>
<LeftSidebarDataSources
darkMode={darkMode}
appId={appId}

View file

@ -1,5 +1,6 @@
/* eslint-disable import/no-unresolved */
import React from 'react';
import config from 'config';
import Avatar from '@/_ui/Avatar';
import { useOthers } from 'y-presence';

View file

@ -121,11 +121,11 @@ export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel,
}
return (
<div className="components-container mx-3 mt-3">
<div className="components-container mx-3">
<div className="input-icon">
<input
type="text"
className="form-control mb-2"
className="form-control mt-3 mb-2"
placeholder="Search…"
value={searchQuery}
onChange={(e) => handleSearchQueryChange(e)}

View file

@ -10,19 +10,45 @@ import { validateEmail } from '../_helpers/utils';
class LoginPage extends React.Component {
constructor(props) {
super(props);
// redirect to home if already logged in
if (authenticationService.currentUserValue) {
this.props.history.push('/');
}
this.state = {
isLoading: false,
showPassword: false,
isGettingConfigs: true,
configs: undefined,
};
this.single_organization = window.public_config?.MULTI_ORGANIZATION !== 'true';
}
componentDidMount() {
const organizationId = this.props.match.params.organisationId;
if (
(!organizationId && authenticationService.currentUserValue) ||
(organizationId && authenticationService?.currentUserValue?.organization_id === organizationId)
) {
// redirect to home if already logged in
return this.props.history.push('/');
}
if (organizationId || this.single_organization) {
authenticationService.getOrganizationConfigs(organizationId).then(
(configs) => {
this.setState({ isGettingConfigs: false, configs });
},
() => this.props.history.push({ pathname: '/', state: { errorMessage: 'Error while login, please try again' } })
);
} else {
// Not single organization login page and not an organization login page => Multi organization common login page
// Only form login is allowed
this.setState({
isGettingConfigs: false,
configs: {
form: {
enable_sign_up: window.public_config?.DISABLE_SIGNUPS !== 'true',
enabled: true,
},
},
});
}
this.props.location?.state?.errorMessage &&
toast.error(this.props.location.state.errorMessage, {
id: 'toast-login-auth-error',
@ -54,7 +80,9 @@ class LoginPage extends React.Component {
return;
}
authenticationService.login(email, password).then(this.authSuccessHandler, this.authFailureHandler);
authenticationService
.login(email, password, this.props.match.params.organisationId)
.then(this.authSuccessHandler, this.authFailureHandler);
};
authSuccessHandler = () => {
@ -65,11 +93,7 @@ class LoginPage extends React.Component {
this.setState({ isLoading: false });
};
authFailureHandler = (error) => {
if (error?.error === 'idpiframe_initialization_failed') {
//Error thrown by google on load
return this.setState({ isLoading: false });
}
authFailureHandler = () => {
toast.error('Invalid email or password', {
id: 'toast-login-auth-error',
position: 'top-center',
@ -77,9 +101,24 @@ class LoginPage extends React.Component {
this.setState({ isLoading: false });
};
showLoading = () => {
return (
<div className="card-body">
<div className="skeleton-heading"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
<div className="mb-2"></div>
<div className="skeleton-heading"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
</div>
);
};
render() {
const { isLoading } = this.state;
const passwordLoginDisabled = window.public_config?.DISABLE_PASSWORD_LOGIN === 'true';
const { isLoading, configs, isGettingConfigs } = this.state;
return (
<div className="page page-center">
<div className="container-tight py-2">
@ -89,93 +128,108 @@ class LoginPage extends React.Component {
</a>
</div>
<form className="card card-md" action="." method="get" autoComplete="off">
<div className="card-body">
{!passwordLoginDisabled && (
<div>
<h2 className="card-title text-center mb-4" data-cy="login-page-header">
Login to your account
</h2>
<div className="mb-3">
<label className="form-label" data-cy="email-label">
Email address
</label>
<input
onChange={this.handleChange}
name="email"
type="email"
className="form-control"
placeholder="Email"
data-testid="emailField"
data-cy="email-text-field"
/>
</div>
<div className="mb-2">
<label className="form-label" data-cy="password-label">
Password
<span className="form-label-description">
<Link to={'/forgot-password'} tabIndex="-1" data-cy="forgot-password-link">
Forgot password
</Link>
</span>
</label>
<div className="input-group input-group-flat">
{isGettingConfigs ? (
this.showLoading()
) : (
<div className="card-body">
{!configs && <div className="text-center">No login methods enabled for this organization</div>}
{configs?.form?.enabled && (
<div>
<h2 className="card-title text-center mb-4" data-cy="login-page-header">
Login to {this.single_organization ? 'your account' : configs?.name || 'your account'}
</h2>
<div className="mb-3">
<label className="form-label" data-cy="email-label">
Email address
</label>
<input
onChange={this.handleChange}
name="password"
type={this.state.showPassword ? 'text' : 'password'}
name="email"
type="email"
className="form-control"
placeholder="Password"
autoComplete="off"
data-testid="passwordField"
data-cy="password-text-field"
placeholder="Email"
data-testid="emailField"
data-cy="email-text-field"
/>
<span className="input-group-text"></span>
</div>
<div className="mb-2">
<label className="form-label" data-cy="password-label">
Password
<span className="form-label-description">
<Link to={'/forgot-password'} tabIndex="-1" data-cy="forgot-password-link">
Forgot password
</Link>
</span>
</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}
name="password"
type={this.state.showPassword ? 'text' : 'password'}
className="form-control"
placeholder="Password"
autoComplete="off"
data-testid="passwordField"
data-cy="password-text-field"
/>
<span className="input-group-text"></span>
</div>
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="check-input"
name="check-input"
onChange={this.handleOnCheck}
data-cy="checkbox-input"
/>
<label className="form-check-label" htmlFor="check-input" data-cy="show-password-label">
show password
</label>
</div>
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="check-input"
name="check-input"
onChange={this.handleOnCheck}
data-cy="checkbox-input"
)}
<div
className={`form-footer d-flex flex-column align-items-center ${
!configs?.form?.enabled ? 'mt-0' : ''
}`}
>
{configs?.form?.enabled && (
<button
data-testid="loginButton"
className={`btn btn-primary w-100 ${isLoading ? 'btn-loading' : ''}`}
onClick={this.authUser}
data-cy="login-button"
>
Sign in
</button>
)}
{this.state.configs?.google?.enabled && (
<GoogleSSOLoginButton
configs={this.state.configs?.google?.configs}
configId={this.state.configs?.google?.config_id}
/>
<label className="form-check-label" htmlFor="check-input" data-cy="show-password-label">
show password
</label>
</div>
)}
{this.state.configs?.git?.enabled && <GitSSOLoginButton configs={this.state.configs?.git?.configs} />}
</div>
)}
<div
className={`form-footer d-flex flex-column align-items-center ${passwordLoginDisabled ? 'mt-0' : ''}`}
>
{!passwordLoginDisabled && (
<button
data-testid="loginButton"
className={`btn btn-primary w-100 ${isLoading ? 'btn-loading' : ''}`}
onClick={this.authUser}
data-cy="login-button"
>
Sign in
</button>
)}
{window.public_config?.SSO_GOOGLE_OAUTH2_CLIENT_ID && (
<GoogleSSOLoginButton
authSuccessHandler={this.authSuccessHandler}
authFailureHandler={this.authFailureHandler}
/>
)}
{window.public_config?.SSO_GIT_OAUTH2_CLIENT_ID && <GitSSOLoginButton />}
</div>
</div>
)}
</form>
{!passwordLoginDisabled && (
<div className="text-center text-secondary mt-3" data-cy="sign-up-message">
Don&apos;t have account yet? &nbsp;
<Link to={'/signup'} tabIndex="-1" data-cy="sign-up-link">
Sign up
</Link>
{!this.props.match.params.organisationId &&
!this.single_organization &&
configs?.form?.enabled &&
configs?.form?.enable_sign_up && (
<div className="text-center text-secondary mt-3" data-cy="sign-up-message">
Don&apos;t have account yet? &nbsp;
<Link to={'/signup'} tabIndex="-1" data-cy="sign-up-link">
Sign up
</Link>
</div>
)}
{authenticationService?.currentUserValue?.organization && (
<div className="text-center mt-3">
back to <a href="/">{authenticationService?.currentUserValue?.organization}</a>
</div>
)}
</div>

View file

@ -173,7 +173,7 @@ class ManageOrgUsers extends React.Component {
history.push('/login');
};
generateInvitationURL = (user) => window.location.origin + '/invitations/' + user.invitation_token;
generateInvitationURL = (user) => window.location.origin + '/organization-invitations/' + user.invitation_token;
invitationLinkCopyHandler = () => {
toast.success('Invitation URL copied', {

View file

@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { organizationService } from '@/_services';
import { toast } from 'react-hot-toast';
export function Form({ settings, updateData }) {
const [enabled, setEnabled] = useState(settings?.enabled || false);
const changeStatus = () => {
organizationService.editOrganizationConfigs({ type: 'form', enabled: !enabled }).then(
(data) => {
const enabled_tmp = !enabled;
setEnabled(enabled_tmp);
updateData('form', { id: data.id, enabled: enabled_tmp });
toast.success(`${enabled_tmp ? 'Enabled' : 'Disabled'} Form login`, {
position: 'top-center',
});
},
() => {
toast.error('Error saving sso configurations', {
position: 'top-center',
});
}
);
};
return (
<div className="card">
<div className="card-header">
<div className="d-flex justify-content-between title-with-toggle">
<div className="card-title">
Password Login
<span className={`badge bg-${enabled ? 'green' : 'grey'} ms-1`}>{enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div>
<label className="form-check form-switch">
<input className="form-check-input" type="checkbox" checked={enabled} onChange={changeStatus} />
</label>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { organizationService } from '@/_services';
import { toast } from 'react-hot-toast';
export function GeneralSettings({ settings, updateData }) {
const [enableSignUp, setEnableSignUp] = useState(settings?.enable_sign_up || false);
const [domain, setDomain] = useState(settings?.domain || '');
const [isSaving, setSaving] = useState(false);
const reset = () => {
setEnableSignUp(settings?.enable_sign_up || false);
setDomain(settings?.domain || '');
};
const saveSettings = () => {
setSaving(true);
organizationService.editOrganization({ enableSignUp, domain }).then(
() => {
setSaving(false);
updateData('general', { enable_sign_up: enableSignUp, domain });
toast.success('updated sso configurations', {
position: 'top-center',
});
},
() => {
setSaving(false);
toast.error('Error saving sso configurations', {
position: 'top-center',
});
}
);
};
return (
<div className="card">
<div className="card-header">
<div className="card-title">General Settings</div>
</div>
<div className="card-body">
<form noValidate>
<div className="form-group mb-3">
<label className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
onChange={() => setEnableSignUp((enableSignUp) => !enableSignUp)}
checked={enableSignUp}
/>
<span className="form-check-label">Enable signup</span>
</label>
<div className="help-text">
<div>New account will be created for user&apos;s first time sso sign in</div>
</div>
</div>
<div className="form-group mb-3">
<label className="form-label">Allowed domain</label>
<div>
<input
type="text"
className="form-control"
placeholder="Enter Domains"
name="domain"
value={domain}
onChange={(e) => setDomain(e.target.value)}
/>
</div>
</div>
<div className="form-footer">
<button type="button" className="btn btn-light mr-2" onClick={reset}>
Cancel
</button>
<button
type="button"
className={`btn mx-2 btn-primary ${isSaving ? 'btn-loading' : ''}`}
disabled={isSaving}
onClick={saveSettings}
>
Save
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { organizationService } from '@/_services';
import { toast } from 'react-hot-toast';
export function Git({ settings, updateData }) {
const [enabled, setEnabled] = useState(settings?.enabled || false);
const [clientId, setClientId] = useState(settings?.configs?.client_id || '');
const [clientSecret, setClientSecret] = useState(settings?.configs?.client_secret || '');
const [isSaving, setSaving] = useState(false);
const [configId, setConfigId] = useState(settings?.id);
const reset = () => {
setClientId(settings?.configs?.client_id || '');
setClientSecret(settings?.configs?.client_secret || '');
};
const saveSettings = () => {
setSaving(true);
organizationService.editOrganizationConfigs({ type: 'git', configs: { clientId, clientSecret } }).then(
(data) => {
setSaving(false);
data.id && setConfigId(data.id);
updateData('git', { id: data.id, configs: { client_id: clientId, client_secret: clientSecret } });
toast.success('updated SSO configurations', {
position: 'top-center',
});
},
() => {
setSaving(false);
toast.error('Error saving sso configurations', {
position: 'top-center',
});
}
);
};
const changeStatus = () => {
setSaving(true);
organizationService.editOrganizationConfigs({ type: 'git', enabled: !enabled }).then(
(data) => {
setSaving(false);
const enabled_tmp = !enabled;
setEnabled(enabled_tmp);
data.id && setConfigId(data.id);
updateData('git', { id: data.id, enabled: enabled_tmp });
toast.success(`${enabled_tmp ? 'Enabled' : 'Disabled'} Git SSO`, {
position: 'top-center',
});
},
() => {
setSaving(false);
toast.error('Error saving sso configurations', {
position: 'top-center',
});
}
);
};
return (
<div className="card">
<div className="card-header">
<div className="d-flex justify-content-between title-with-toggle">
<div className="card-title">
Git
<span className={`badge bg-${enabled ? 'green' : 'grey'} ms-1`}>{enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div>
<label className="form-check form-switch">
<input className="form-check-input" type="checkbox" checked={enabled} onChange={changeStatus} />
</label>
</div>
</div>
</div>
<div className="card-body">
<form noValidate>
<div className="form-group mb-3">
<label className="form-label">Client Id</label>
<div>
<input
type="text"
className="form-control"
placeholder="Enter Client Id"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
</div>
</div>
<div className="form-group mb-3">
<label className="form-label">
Client Secret
<small className="text-green mx-2">
<img className="mx-2 encrypted-icon" src="/assets/images/icons/padlock.svg" width="12" height="12" />
Encrypted
</small>
</label>
<div>
<input
type="text"
className="form-control"
placeholder="Enter Client Secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
</div>
</div>
{configId && (
<div className="form-group mb-3">
<label className="form-label">Redirect URL</label>
<div>{`${window.location.protocol}//${window.location.host}/sso/git/${configId}`}</div>
</div>
)}
<div className="form-footer">
<button type="button" className="btn btn-light mr-2" onClick={reset}>
Cancel
</button>
<button
type="button"
className={`btn mx-2 btn-primary ${isSaving ? 'btn-loading' : ''}`}
disabled={isSaving}
onClick={saveSettings}
>
Save
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { organizationService } from '@/_services';
import { toast } from 'react-hot-toast';
export function Google({ settings, updateData }) {
const [enabled, setEnabled] = useState(settings?.enabled || false);
const [clientId, setClientId] = useState(settings?.configs?.client_id || '');
const [isSaving, setSaving] = useState(false);
const [configId, setConfigId] = useState(settings?.id);
const reset = () => {
setClientId(settings?.configs?.client_id || '');
};
const saveSettings = () => {
setSaving(true);
organizationService.editOrganizationConfigs({ type: 'google', configs: { clientId } }).then(
(data) => {
setSaving(false);
data.id && setConfigId(data.id);
updateData('google', { id: data.id, configs: { client_id: clientId } });
toast.success('updated SSO configurations', {
position: 'top-center',
});
},
() => {
setSaving(false);
toast.error('Error saving sso configurations', {
position: 'top-center',
});
}
);
};
const changeStatus = () => {
setSaving(true);
organizationService.editOrganizationConfigs({ type: 'google', enabled: !enabled }).then(
(data) => {
setSaving(false);
const enabled_tmp = !enabled;
setEnabled(enabled_tmp);
data.id && setConfigId(data.id);
updateData('google', { id: data.id, enabled: enabled_tmp });
toast.success(`${enabled_tmp ? 'Enabled' : 'Disabled'} Google SSO`, {
position: 'top-center',
});
},
() => {
setSaving(false);
toast.error('Error saving sso configurations', {
position: 'top-center',
});
}
);
};
return (
<div className="card">
<div className="card-header">
<div className="d-flex justify-content-between title-with-toggle">
<div className="card-title">
Google
<span className={`badge bg-${enabled ? 'green' : 'grey'} ms-1`}>{enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div>
<label className="form-check form-switch">
<input className="form-check-input" type="checkbox" checked={enabled} onChange={changeStatus} />
</label>
</div>
</div>
</div>
<div className="card-body">
<form noValidate>
<div className="form-group mb-3">
<label className="form-label">Client Id</label>
<div>
<input
type="text"
className="form-control"
placeholder="Enter Client Secret"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
</div>
</div>
{configId && (
<div className="form-group mb-3">
<label className="form-label">Redirect URL</label>
<div>{`${window.location.protocol}//${window.location.host}/sso/google/${configId}`}</div>
</div>
)}
<div className="form-footer">
<button type="button" className="btn btn-light mr-2" onClick={reset}>
Cancel
</button>
<button
type="button"
className={`btn mx-2 btn-primary ${isSaving ? 'btn-loading' : ''}`}
disabled={isSaving}
onClick={saveSettings}
>
Save
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
export function Loader() {
return (
<div className="card">
<div className="card-header">
<div className="card-title">
<div className="skeleton-avatar"></div>
</div>
</div>
<div className="card-body">
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
import React, { useState, useCallback, useEffect } from 'react';
import { organizationService } from '@/_services';
import { Header, Menu } from '@/_components';
import ReactTooltip from 'react-tooltip';
import { GeneralSettings } from './GeneralSettings';
import { Google } from './Google';
import { Loader } from './Loader';
import { Git } from './Git';
import { Form } from './Form';
export function ManageSSO({ switchDarkMode, darkMode }) {
const menuItems = [
{ id: 'general-settings', label: 'General Settings' },
{ id: 'google', label: 'Google' },
{ id: 'git', label: 'Git' },
{ id: 'form', label: 'Password Login' },
];
const changePage = useCallback(
(page) => {
setCurrentPage(page);
},
[setCurrentPage]
);
const [currentPage, setCurrentPage] = useState('');
const [isLoading, setIsloading] = useState(true);
const [ssoData, setSsoData] = useState({});
const showPage = () => {
switch (currentPage) {
case 'general-settings':
return <GeneralSettings updateData={updateData} settings={ssoData} />;
case 'google':
return <Google updateData={updateData} settings={ssoData?.sso_configs?.find((obj) => obj.sso === 'google')} />;
case 'git':
return <Git updateData={updateData} settings={ssoData?.sso_configs?.find((obj) => obj.sso === 'git')} />;
case 'form':
return <Form updateData={updateData} settings={ssoData?.sso_configs?.find((obj) => obj.sso === 'form')} />;
default:
return <Loader />;
}
};
useEffect(() => {
organizationService.getSSODetails().then((data) => {
setSsoData(data.organization_details);
setIsloading(false);
setCurrentPage('general-settings');
});
}, []);
const updateData = useCallback(
(type, data) => {
const ssoData_tmp = ssoData;
let configs = ssoData_tmp.sso_configs.find((obj) => obj.sso === type);
switch (type) {
case 'general':
return setSsoData({ ...ssoData, ...data });
default:
if (!configs) {
// Enable/Disable
ssoData_tmp.sso_configs.push({ ...data, sso: type });
} else {
// Change configs
if (data.id !== undefined) {
configs.id = data.id;
}
if (data.enabled !== undefined) {
configs.enabled = data.enabled;
}
if (data.configs !== undefined) {
configs.configs = data.configs;
}
}
return setSsoData(ssoData_tmp);
}
},
[ssoData]
);
return (
<div className="wrapper manage-sso">
<Header switchDarkMode={switchDarkMode} darkMode={darkMode} />
<ReactTooltip type="dark" effect="solid" delayShow={250} />
<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">Manage SSO</h2>
</div>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">
<div className="row">
<div className="col-3">
<div>
{isLoading ? (
<div className="row">
<div className="row">
<div className="skeleton-line"></div>
</div>
<div className="row">
<div className="skeleton-line"></div>
</div>
<div className="row">
<div className="skeleton-line"></div>
</div>
</div>
) : (
<Menu items={menuItems} onChange={changePage} selected={currentPage} />
)}
</div>
</div>
<div className="col-9">{showPage()}</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

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

View file

@ -10,6 +10,7 @@ export function Authorize() {
const router = useRouter();
useEffect(() => {
authenticationService.clearUser();
const errorMessage = router.query.error_description || router.query.error;
if (errorMessage) {
@ -21,15 +22,26 @@ export function Authorize() {
}
const configs = Configs[router.query.origin];
let authParams = {};
if (configs.responseType === 'hash') {
if (!window.location.hash) {
return setError('Login failed');
}
const params = new Proxy(new URLSearchParams(window.location.hash.substr(1)), {
get: (searchParams, prop) => searchParams.get(prop),
});
authParams.token = params[configs.params.token];
authParams.state = params[configs.params.state];
} else {
authParams.token = router.query[configs.params.token];
authParams.state = router.query[configs.params.state];
}
authenticationService
.signInViaOAuth({
token: router.query[configs.params.token],
origin: router.query.origin,
state: router.query[configs.params.state],
})
.signInViaOAuth(router.query.configId, authParams)
.then(() => setSuccess(true))
.catch(() => setError(`${configs.name} login failed`));
.catch((err) => setError(`${configs.name} login failed - ${err?.error ? err?.error : ''}`));
// Disabled for useEffect not being called for updation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -77,7 +89,7 @@ export function Authorize() {
<Redirect
to={{
pathname: '/login',
state: { errorMessage: success ? '' : error },
state: { errorMessage: error && error },
}}
/>
)}

View file

@ -1,8 +1,16 @@
{
"git": {
"name": "Github",
"responseType": "query",
"params": {
"token": "code"
}
},
"google": {
"name": "Google",
"responseType": "hash",
"params": {
"token": "id_token"
}
}
}

View file

@ -0,0 +1,39 @@
import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { toast } from 'react-hot-toast';
import { ToolTip } from '@/_components/ToolTip';
export const CopyToClipboardComponent = ({ data, callback }) => {
const [copied, setCopied] = React.useState(false);
const dataToCopy = callback(data);
const message = 'Path copied to clipboard';
const tip = 'Copy path to clipboard';
//clears the clipboard after 2 seconds
React.useEffect(() => {
const timer = setTimeout(() => {
setCopied(false);
}, 2000);
return () => clearTimeout(timer);
}, [copied]);
if (copied) {
return <center>Copied</center>;
}
return (
<ToolTip message={tip}>
<CopyToClipboard
text={dataToCopy}
onCopy={() => {
setCopied(true);
toast.success(message, { position: 'top-center' });
}}
>
<span style={{ height: '13px', width: '13px', marginBottom: '2px' }} className="mx-1 copy-to-clipboard">
<img src={`/assets/images/icons/copy.svg`} width="12" height="12" />
</span>
</CopyToClipboard>
</ToolTip>
);
};

View file

@ -0,0 +1,3 @@
import { CopyToClipboardComponent } from './CopyToClipboard';
export default CopyToClipboardComponent;

View file

@ -5,6 +5,7 @@ import { history } from '@/_helpers';
import { DarkModeToggle } from './DarkModeToggle';
import LogoIcon from '../Editor/Icons/logo.svg';
import { Organization } from './Organization';
export const Header = function Header({ switchDarkMode, darkMode }) {
// eslint-disable-next-line no-unused-vars
@ -42,6 +43,9 @@ export const Header = function Header({ switchDarkMode, darkMode }) {
<div className="p-1 m-1 d-flex align-items-center">
<DarkModeToggle switchDarkMode={switchDarkMode} darkMode={darkMode} />
</div>
<div>
<Organization admin={admin} />
</div>
<div className="nav-item dropdown ms-2 user-avatar-nav-item">
<a
href="#"
@ -58,16 +62,6 @@ export const Header = function Header({ switchDarkMode, darkMode }) {
</div>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow end-0">
{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>

View file

@ -0,0 +1,16 @@
import React from 'react';
export function Menu({ onChange, items, selected }) {
return (
<div className="left-menu">
<ul>
{items &&
items.map((item) => (
<li key={item.id} onClick={() => onChange(item.id)} className={selected === item.id ? 'active' : ''}>
{item.label}
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,349 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { authenticationService, organizationService } from '@/_services';
import Modal from '../HomePage/Modal';
import { toast } from 'react-hot-toast';
import { SearchBox } from './SearchBox';
export const Organization = function Organization() {
const isSingleOrganization = window.public_config?.MULTI_ORGANIZATION !== 'true';
const { admin, organization_id } = authenticationService.currentUserValue;
const [organization, setOrganization] = useState(authenticationService.currentUserValue?.organization);
const [showCreateOrg, setShowCreateOrg] = useState(false);
const [showEditOrg, setShowEditOrg] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [searchText, setSearchText] = useState('');
const [organizationList, setOrganizationList] = useState([]);
const [getOrgStatus, setGetOrgStatus] = useState('loading');
const [isListOrganizations, setIsListOrganizations] = useState(false);
const [newOrgName, setNewOrgName] = useState('');
const getAvatar = (organization) => {
if (!organization) return;
const orgName = organization.split(' ');
if (orgName.length > 1) {
return `${orgName[0]?.[0]}${orgName[1]?.[0]}`;
} else {
return `${organization[0]}${organization[1]}`;
}
};
useEffect(() => {
!isSingleOrganization && getOrganizations();
}, [isSingleOrganization]);
const getOrganizations = () => {
setGetOrgStatus('loading');
organizationService.getOrganizations().then(
(data) => {
setOrganizationList(data.organizations);
setGetOrgStatus('success');
},
() => {
setGetOrgStatus('failure');
}
);
};
const showEditModal = () => {
setNewOrgName(organization);
setShowEditOrg(true);
};
const showCreateModal = () => {
setNewOrgName('');
setShowCreateOrg(true);
};
const createOrganization = () => {
if (!(newOrgName && newOrgName.trim())) {
toast.error("organization name can't be empty.", {
position: 'top-center',
});
return;
}
setIsCreating(true);
organizationService.createOrganization(newOrgName).then(
(data) => {
authenticationService.updateCurrentUserDetails(data);
window.location.href = '/';
},
() => {
toast.error('Error while creating organization', {
position: 'top-center',
});
}
);
setIsCreating(false);
};
const editOrganization = () => {
if (!(newOrgName && newOrgName.trim())) {
toast.error("organization name can't be empty.", {
position: 'top-center',
});
return;
}
setIsCreating(true);
organizationService.editOrganization({ name: newOrgName }).then(
() => {
authenticationService.updateCurrentUserDetails({ organization: newOrgName });
toast.success('Organization updated', {
position: 'top-center',
});
setOrganization(newOrgName);
},
() => {
toast.error('Error while editing organization', {
position: 'top-center',
});
}
);
setIsCreating(false);
setShowEditOrg(false);
};
const switchOrganization = (orgId) => {
organizationService.switchOrganization(orgId).then((response) => {
response.text().then((text) => {
if (!response.ok) {
return (window.location.href = `/login/${orgId}`);
}
const data = text && JSON.parse(text);
authenticationService.updateCurrentUserDetails(data);
window.location.href = '/';
});
});
};
const listOrganization = () => {
return (
organizationList &&
organizationList
.filter((org) => org.name.toLowerCase().includes(searchText ? searchText.toLowerCase() : ''))
.map((org) => {
return (
<div
key={org.id}
onClick={organization_id === org.id ? undefined : () => switchOrganization(org.id)}
className="dropdown-item org-list-item"
>
<div className="col-3">
<span className="avatar bg-secondary-lt">{getAvatar(org.name)}</span>
</div>
<div className="col-8">
<div className="org-name">{org.name}</div>
</div>
<div className="col-1">
{organization_id === org.id && (
<div className="tick-ico">
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-check"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path>
</svg>
</div>
)}
</div>
</div>
);
})
);
};
const searchOrganizations = (text) => {
setSearchText(text);
};
const getListOrganizations = () => {
return (
<div className="organization-switchlist">
<div className="dd-item-padding">
<div className="d-flex">
<div className="back-ico" onClick={() => setIsListOrganizations(false)}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-chevron-left"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<polyline points="15 6 9 12 15 18"></polyline>
</svg>
</div>
<div className="back-btn" onClick={() => setIsListOrganizations(false)}>
Back
</div>
</div>
<div className="search-box">
<SearchBox onSubmit={searchOrganizations} debounceDelay={100} width="14rem" />
</div>
</div>
<div className="org-list">
{getOrgStatus === 'success' ? (
listOrganization()
) : (
<div className="text-center">
<a
onClick={getOrganizations}
href="#"
className={`btn btn-primary mb-2 ${getOrgStatus === 'loading' ? 'btn-loading' : ''}`}
>
Load Organizations
</a>
</div>
)}
</div>
</div>
);
};
const getOrganizationMenu = () => {
return (
<div>
<div className="dropdown-item org-avatar">
<div className="row">
<div className="col-3">
<span className="avatar bg-secondary-lt">{getAvatar(organization)}</span>
</div>
<div className={`col-${isSingleOrganization ? '9' : '7'}`}>
<div className="org-name" style={{ padding: `${admin ? '0px' : '0.6rem'} 0px` }}>
{organization}
</div>
{admin && (
<div className="org-edit">
<span onClick={showEditModal}>Edit</span>
</div>
)}
</div>
{!isSingleOrganization && (
<div className="col-2">
<div className="arrow-container" onClick={() => setIsListOrganizations(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-chevron-right"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<polyline points="9 6 15 12 9 18"></polyline>
</svg>
</div>
</div>
)}
</div>
</div>
{!isSingleOrganization && (
<div className="dropdown-item org-actions">
<div onClick={showCreateModal}>Add Organizations</div>
</div>
)}
{admin && (
<>
<div className="dropdown-divider"></div>
<Link data-testid="settingsBtn" to="/users" className="dropdown-item">
Manage Users
</Link>
<Link data-tesid="settingsBtn" to="/groups" className="dropdown-item">
Manage Groups
</Link>
<Link data-tesid="settingsBtn" to="/manage-sso" className="dropdown-item">
Manage SSO
</Link>
</>
)}
</div>
);
};
return (
<div>
<div className="dropdown organization-list" onMouseEnter={() => setIsListOrganizations(false)}>
<a href="#" className={`btn ${!isSingleOrganization || admin ? 'dropdown-toggle' : ''}`}>
<div>{organization}</div>
</a>
{(!isSingleOrganization || admin) && (
<div className="dropdown-menu end-0">
{isListOrganizations ? getListOrganizations() : getOrganizationMenu()}
</div>
)}
</div>
<Modal show={showCreateOrg} closeModal={() => setShowCreateOrg(false)} title="Create organization">
<div className="row">
<div className="col modal-main">
<input
type="text"
onChange={(e) => setNewOrgName(e.target.value)}
className="form-control"
placeholder="organization name"
disabled={isCreating}
maxLength={25}
/>
</div>
</div>
<div className="row">
<div className="col d-flex modal-footer-btn">
<button className="btn btn-light" onClick={() => setShowCreateOrg(false)}>
Cancel
</button>
<button
disabled={isCreating}
className={`btn btn-primary ${isCreating ? 'btn-loading' : ''}`}
onClick={createOrganization}
>
Create organization
</button>
</div>
</div>
</Modal>
<Modal show={showEditOrg} closeModal={() => setShowEditOrg(false)} title="Edit organization">
<div className="row">
<div className="col modal-main">
<input
type="text"
onChange={(e) => setNewOrgName(e.target.value)}
className="form-control"
placeholder="organization name"
disabled={isCreating}
value={newOrgName}
maxLength={25}
/>
</div>
</div>
<div className="row">
<div className="col d-flex modal-footer-btn">
<button className="btn btn-light" onClick={() => setShowEditOrg(false)}>
Cancel
</button>
<button className={`btn btn-primary ${isCreating ? 'btn-loading' : ''}`} onClick={editOrganization}>
Save
</button>
</div>
</div>
</Modal>
</div>
);
};

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import useDebounce from '@/_hooks/useDebounce';
export function SearchBox({ onSubmit, debounceDelay = 300 }) {
export function SearchBox({ width = '200px', onSubmit, debounceDelay = 300 }) {
const [searchText, setSearchText] = useState('');
const debouncedSearchTerm = useDebounce(searchText, debounceDelay);
const [isFocused, setFocussed] = useState(false);
@ -16,7 +16,6 @@ export function SearchBox({ onSubmit, debounceDelay = 300 }) {
};
useEffect(() => {
console.log(debouncedSearchTerm);
onSubmit(debouncedSearchTerm);
}, [debouncedSearchTerm, onSubmit]);
@ -44,6 +43,7 @@ export function SearchBox({ onSubmit, debounceDelay = 300 }) {
</span>
)}
<input
style={{ width }}
type="text"
value={searchText}
onChange={handleChange}
@ -81,4 +81,5 @@ export function SearchBox({ onSubmit, debounceDelay = 300 }) {
SearchBox.propTypes = {
onSubmit: PropTypes.func.isRequired,
debounceDelay: PropTypes.number,
width: PropTypes.string,
};

View file

@ -6,3 +6,4 @@ export * from './DarkModeToggle';
export * from './SearchBox';
export * from './ToolTip';
export * from './ImageWithSpinner';
export * from './Menu';

View file

@ -7,6 +7,7 @@ jest.mock(
apiUrl: `http://localhost:3000/api`,
SERVER_IP: process.env.SERVER_IP,
COMMENT_FEATURE_ENABLE: true,
ENABLE_MULTIPLAYER_EDITING: true,
};
},
{ virtual: true }

View file

@ -6,7 +6,9 @@ const currentUserSubject = new BehaviorSubject(JSON.parse(localStorage.getItem('
export const authenticationService = {
login,
getOrganizationConfigs,
logout,
clearUser,
signup,
updateCurrentUserDetails,
currentUser: currentUserSubject.asObservable(),
@ -17,29 +19,40 @@ export const authenticationService = {
resetPassword,
};
function login(email, password) {
function login(email, password, organizationId) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
};
return fetch(`${config.apiUrl}/authenticate`, requestOptions)
return fetch(`${config.apiUrl}/authenticate${organizationId ? `/${organizationId}` : ''}`, requestOptions)
.then(handleResponse)
.then((user) => {
// store user details and jwt token in local storage to keep user logged in between page refreshes
localStorage.setItem('currentUser', JSON.stringify(user));
currentUserSubject.next(user);
updateUser(user);
return user;
});
}
function getOrganizationConfigs(organizationId) {
const requestOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
};
return fetch(
`${config.apiUrl}/organizations/${organizationId ? `${organizationId}/` : ''}public-configs`,
requestOptions
)
.then(handleResponse)
.then((configs) => configs?.sso_configs);
}
function updateCurrentUserDetails(details) {
const currentUserDetails = JSON.parse(localStorage.getItem('currentUser'));
const updatedUserDetails = Object.assign({}, currentUserDetails, details);
localStorage.setItem('currentUser', JSON.stringify(updatedUserDetails));
currentUserSubject.next(updatedUserDetails);
updateUser(updatedUserDetails);
}
function signup(email) {
@ -70,25 +83,40 @@ function resetPassword(params) {
}
function logout() {
clearUser();
history.push(`/login?redirectTo=${window.location.pathname?.startsWith('/sso/') ? '/' : window.location.pathname}`);
}
function clearUser() {
// remove user from local storage to log user out
localStorage.removeItem('currentUser');
currentUserSubject.next(null);
history.push(`/login?redirectTo=${window.location.pathname}`);
}
function signInViaOAuth(ssoResponse) {
function signInViaOAuth(configId, ssoResponse) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ssoResponse),
};
return fetch(`${config.apiUrl}/oauth/sign-in`, requestOptions)
.then(handleResponse)
return fetch(`${config.apiUrl}/oauth/sign-in/${configId}`, requestOptions)
.then((response) => {
return response.text().then((text) => {
const data = text && JSON.parse(text);
if (!response.ok) {
const error = (data && data.message) || response.statusText;
return Promise.reject({ error, data });
}
return data;
});
})
.then((user) => {
localStorage.setItem('currentUser', JSON.stringify(user));
currentUserSubject.next(user);
updateUser(user);
return user;
});
}
function updateUser(user) {
localStorage.setItem('currentUser', JSON.stringify(user));
currentUserSubject.next(user);
}

View file

@ -9,15 +9,15 @@ function getThreads(appId, appVersionsId) {
}
function createThread(data) {
return adapter.post(`/threads/create`, data);
return adapter.post(`/threads`, data);
}
function updateThread(threadId, data) {
return adapter.patch(`/threads/edit/${threadId}`, data);
return adapter.patch(`/threads/${threadId}`, data);
}
function deleteThread(threadId) {
return adapter.delete(`/threads/delete/${threadId}`);
return adapter.delete(`/threads/${threadId}`);
}
function getComments(threadId, appVersionsId) {
@ -25,15 +25,15 @@ function getComments(threadId, appVersionsId) {
}
function createComment(data) {
return adapter.post(`/comments/create`, data);
return adapter.post(`/comments`, data);
}
function updateComment(commentId, data) {
return adapter.patch(`/comments/edit/${commentId}`, data);
return adapter.patch(`/comments/${commentId}`, data);
}
function deleteComment(commentId) {
return adapter.delete(`/comments/delete/${commentId}`);
return adapter.delete(`/comments/${commentId}`);
}
function getNotifications(appId, isResolved, appVersionsId) {

View file

@ -3,9 +3,45 @@ import { authHeader, handleResponse } from '@/_helpers';
export const organizationService = {
getUsers,
createOrganization,
editOrganization,
getOrganizations,
switchOrganization,
getSSODetails,
editOrganizationConfigs,
};
function getUsers() {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/organizations/users`, requestOptions).then(handleResponse);
}
function createOrganization(name) {
const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify({ name }) };
return fetch(`${config.apiUrl}/organizations`, requestOptions).then(handleResponse);
}
function editOrganization(params) {
const requestOptions = { method: 'PATCH', headers: authHeader(), body: JSON.stringify(params) };
return fetch(`${config.apiUrl}/organizations/`, requestOptions).then(handleResponse);
}
function getOrganizations() {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/organizations`, requestOptions).then(handleResponse);
}
function switchOrganization(organizationId) {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/switch/${organizationId}`, requestOptions);
}
function getSSODetails() {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/organizations/configs`, requestOptions).then(handleResponse);
}
function editOrganizationConfigs(params) {
const requestOptions = { method: 'PATCH', headers: authHeader(), body: JSON.stringify(params) };
return fetch(`${config.apiUrl}/organizations/configs`, requestOptions).then(handleResponse);
}

View file

@ -8,6 +8,7 @@ export const userService = {
setPasswordFromToken,
updateCurrentUser,
changePassword,
acceptInvite,
};
function getAll() {
@ -32,13 +33,12 @@ function deleteUser(id) {
return fetch(`${config.apiUrl}/users/${id}`, requestOptions).then(handleResponse);
}
function setPasswordFromToken({ token, password, organization, role, newSignup, firstName, lastName }) {
function setPasswordFromToken({ token, password, organization, role, firstName, lastName }) {
const body = {
token,
password,
organization,
role,
new_signup: newSignup,
first_name: firstName,
last_name: lastName,
};
@ -47,6 +47,16 @@ function setPasswordFromToken({ token, password, organization, role, newSignup,
return fetch(`${config.apiUrl}/users/set_password_from_token`, requestOptions).then(handleResponse);
}
function acceptInvite({ token, password }) {
const body = {
token,
password,
};
const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body) };
return fetch(`${config.apiUrl}/users/accept-invite`, requestOptions).then(handleResponse);
}
function updateCurrentUser(firstName, lastName) {
const body = { first_name: firstName, last_name: lastName };
const requestOptions = { method: 'PATCH', headers: authHeader(), body: JSON.stringify(body) };

View file

@ -12,6 +12,7 @@ $dark-background: #1f2936;
$bg-light: #EEF3F9;
$bg-dark: #22272E;
$bg-dark-light: #232e3c;
$primary-light: #7A95FB;
.color-primary {
color: $primary !important;

View file

@ -731,7 +731,7 @@ button {
.page-body,
.homepage-body {
height: 100vh;
height: 90.6vh;
.list-group.list-group-transparent.dark .all-apps-link,
.list-group-item-action.dark.active {
@ -1219,6 +1219,17 @@ button {
filter: invert(89%) sepia(2%) saturate(127%) hue-rotate(175deg) brightness(99%) contrast(96%);
}
}
.organization-list {
margin-top: 5px;
.btn {
border: 0px;
}
.dropdown-toggle div {
max-width: 200px;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
.pagination {
@ -3227,6 +3238,12 @@ input:focus-visible {
.app-version-name.form-select {
border-color: $border-grey-dark;
}
.organization-list {
.btn {
background-color: #273342;
color: #656d77;
}
}
}
.main-wrapper {
@ -3914,7 +3931,7 @@ input[type="text"] {
}
.close-icon {
position: fixed;
top: 45px;
top: 84px;
right: 0;
width: 60px;
height: 22;
@ -3973,9 +3990,9 @@ input[type="text"] {
color: #36af8b;
}
.layout-buttons {
position: absolute;
left: 50%;
.undo-redo-buttons {
flex: 1;
padding-left: .5rem;
}
.app-version-menu {
@ -4716,11 +4733,351 @@ div#driver-page-overlay {
#transformation-popover-container {
margin-left: 80px !important;
margin-bottom: -2px !important;
// top: -10px !important;
// left: 100px !important;
// background-color: #0565ff;
}
.organization-list {
margin-top: 5px;
.btn {
border: 0px;
}
.dropdown-toggle div {
max-width: 200px;
text-overflow: ellipsis;
overflow: hidden;
}
.org-name {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
font-weight: bold;
}
.org-actions div {
color: #0565ff;
cursor: pointer;
font-size: 12px;
}
.dropdown-menu {
min-width: 14rem;
}
.org-avatar {
display: block;
}
.org-avatar:hover {
.avatar {
background: #fcfcfc no-repeat center/cover;
}
.arrow-container {
svg {
filter: invert(48%) sepia(6%) saturate(6%) hue-rotate(315deg) brightness(103%) contrast(96%);
}
}
}
.arrow-container {
padding: 5px 0px;
}
.arrow-container {
svg {
cursor: pointer;
height: 30px;
width: 30px;
padding: 0px 0px;
filter: invert(84%) sepia(13%) saturate(11%) hue-rotate(352deg) brightness(90%) contrast(91%);
}
}
.org-edit {
span {
color: #0565ff;
cursor: pointer;
font-size: 10px;
}
}
.organization-switchlist {
.back-btn {
font-size: 12px;
padding: 2px 0px;
cursor: pointer;
}
.back-ico {
cursor: pointer;
svg {
height: 20px;
width: 20px;
filter: invert(84%) sepia(13%) saturate(11%) hue-rotate(352deg) brightness(90%) contrast(91%);
}
}
.dd-item-padding {
padding: 0.5rem 0.75rem 0rem 0.75rem;
}
.search-box {
margin-top: 10px;
}
.org-list {
max-height: 60vh;
overflow: auto;
}
.tick-ico {
filter: invert(50%) sepia(13%) saturate(208%) hue-rotate(153deg) brightness(99%) contrast(86%);
}
.org-list-item {
cursor: pointer;
}
.org-list-item:hover {
.avatar {
background: #fcfcfc no-repeat center/cover;
}
.tick-ico {
filter: invert(35%) sepia(17%) saturate(238%) hue-rotate(153deg) brightness(94%) contrast(89%);
}
}
}
}
// Left Menu
.left-menu {
background-color: #e8ebf4;
padding: 1rem 0.5rem;
border-radius: 5px;
ul {
overflow: auto;
margin: 0px;
padding: 0px;
li {
float: left;
list-style: none;
width: 100%;
padding: 5px 10px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
margin: 3px 0px;
}
li.active {
background-color: $primary;
color: $white;
}
li:not(.active):hover {
background-color: #d2daf0;
}
}
}
.manage-sso {
.title-with-toggle {
width: 100%;
input[type=checkbox] {
/* Double-sized Checkboxes */
-ms-transform: scale(1.5); /* IE */
-moz-transform: scale(1.5); /* FF */
-webkit-transform: scale(1.5); /* Safari and Chrome */
-o-transform: scale(1.5); /* Opera */
transform: scale(1.5);
margin-top: 5px;
}
}
}
.help-text {
overflow: auto;
div {
margin: 0px 0px 5px 0px;
background-color: #eaeaea;
float: left;
padding: 2px 10px;
font-size: 11px;
border-radius: 5px;
border: 1px solid #e1e1e1;
}
}
.org-invite-or {
padding: 1rem 0rem;
h2 {
width: 100%;
text-align: center;
border-bottom: 1px solid #000;
line-height: 0.1em;
margin: 10px 0 20px;
}
h2 span {
background:#fff;
padding:0 10px;
}
}
.theme-dark .json-tree-container {
.json-tree-node-icon {
svg {
filter: invert(89%) sepia(2%) saturate(127%) hue-rotate(175deg) brightness(99%) contrast(96%);
}
}
.json-tree-svg-icon.component-icon {
filter: brightness(0) invert(1);
}
.node-key-outline {
height: 1rem!important;
border: 1px solid transparent!important;
color: #ccd4df;
}
.selected-node {
border-color: $primary-light !important;
}
.json-tree-icon-container .selected-node > svg:first-child {
filter: invert(65%) sepia(62%) saturate(4331%) hue-rotate(204deg) brightness(106%) contrast(97%);
}
.node-length-color {
color: #B8C7FD;
}
.node-type {
color: #8a96a6;
}
.group-border {
border-color: rgb(97, 101, 111);
}
.action-icons-group {
img, svg {
filter: invert(89%) sepia(2%) saturate(127%) hue-rotate(175deg) brightness(99%) contrast(96%);
}
}
.hovered-node.node-key.badge {
color: #8092AB !important;
border-color: #8092AB !important;
}
}
.json-tree-container {
.json-tree-svg-icon.component-icon {
height: 16px;
width: 16px;
}
.json-tree-icon-container {
max-width: 20px;
margin-right: 6px;
}
.node-type {
color: #A6B6CC;
padding-top: 2px;
}
.json-tree-valuetype {
font-size: 10px;
padding-top: 2px;
}
.node-length-color {
color: #3650AF;
padding-top: 3px;
}
.json-tree-node-value {
font-size: 11px;
}
.json-tree-node-string {
color: #F6820C;
}
.json-tree-node-boolean {
color: #3EB25F;
}
.json-tree-node-number {
color: #F4B2B0;
}
.json-tree-node-null {
color: red;
}
.group-border {
border-left: 0.5px solid #dadcde;
margin-top: 16px;
margin-left: -12px;
}
.selected-node {
border-color: #4D72FA !important;
}
.selected-node .group-object-container .badge {
font-weight: 400 !important;
height: 1rem !important;
}
.group-object-container {
margin-left: 0.72rem;
margin-top: -16px;
}
.json-node-element {
cursor: pointer;
}
.hide-show-icon {
cursor: pointer;
margin-left: 1rem;
&:hover {
color: $primary;
}
}
.action-icons-group {
margin-right: 4rem !important;
margin-left: 2rem !important;
}
.action-icons-group {
cursor: pointer;
}
.hovered-node {
font-weight: 400 !important;
height: 1rem !important;
color:#8092AB;
}
.node-key {
font-weight: 400!important;
margin-left: -0.25rem!important;
}
.node-key-outline {
height: 1rem!important;
border: 1px solid transparent!important;
color: #3e525b;
}
}
.popover-more-actions {
font-weight: 400!important;
&:hover {
background: #d2ddec !important;
}
}
.popover-dark-themed .popover-more-actions {
color: #ccd4df;
&:hover {
background-color: #324156 !important;
}
}
#json-tree-popover {
padding: 0.25rem !important;
}
// Font sizes
.fs-9 {
font-size: 9px !important;
}
.fs-10 {
font-size: 10px !important;
}
.fs-12 {
font-size: 12px !important;
}
.realtime-avatars {
position: absolute;
left: 35%;
@ -4752,3 +5109,9 @@ div#driver-page-overlay {
.list-timeline:not(.list-timeline-simple) .list-timeline-time {
top: auto;
}
.editor-actions {
border-bottom: 1px solid #eee;
padding: 5px;
display: flex;
justify-content: end;
}

View file

@ -0,0 +1,362 @@
import React from 'react';
import _ from 'lodash';
import cx from 'classnames';
import { ToolTip } from '@/_components/ToolTip';
import CopyToClipboardComponent from '@/_components/CopyToClipboard';
import { Popover } from 'react-bootstrap';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import JSONNodeObject from './JSONNodeObject';
import JSONNodeArray from './JSONNodeArray';
import JSONNodeValue from './JSONNodeValue';
import JSONNodeIndicator from './JSONNodeIndicator';
export const JSONNode = ({ data, ...restProps }) => {
const {
path,
shouldExpandNode,
currentNode,
selectedNode,
hoveredNode,
getCurrentPath,
getCurrentNodeType,
getLength,
toUseNodeIcons,
renderNodeIcons,
useIndentedBlock,
updateSelectedNode,
updateHoveredNode,
useActions,
enableCopyToClipboard,
getNodeShowHideComponents,
getOnSelectLabelDispatchActions,
expandWithLabels,
getAbsoluteNodePath,
actionsList,
updateParentState = () => null,
} = restProps;
const [expandable, set] = React.useState(() =>
typeof shouldExpandNode === 'function' ? shouldExpandNode(path, data) : shouldExpandNode
);
const [showHiddenOptionsForNode, setShowHiddenOptionsForNode] = React.useState(false);
const [showHiddenOptionButtons, setShowHiddenOptionButtons] = React.useState([]);
const [onSelectDispatchActions, setOnSelectDispatchActions] = React.useState([]);
React.useEffect(() => {
if (showHiddenOptionButtons) {
setShowHiddenOptionButtons(() => getNodeShowHideComponents(currentNode, path));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (useActions && currentNode) {
const onSelectDispatchActions = getOnSelectLabelDispatchActions(currentNode, path).filter(
(action) => action.onSelect
);
if (onSelectDispatchActions.length > 0) {
setOnSelectDispatchActions(onSelectDispatchActions);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNode]);
const toggleExpandNode = (node) => {
if (expandable) {
updateSelectedNode(null);
} else {
updateSelectedNode(node, path);
}
set((prev) => !prev);
};
const onSelect = (data, currentNode, path) => {
const actions = onSelectDispatchActions;
actions.forEach((action) => action.dispatchAction(data, currentNode));
if (!expandWithLabels) {
updateSelectedNode(currentNode, path);
set(true);
}
};
const handleOnClickLabels = (data, currentNode, path) => {
if (expandWithLabels) {
toggleExpandNode(currentNode);
}
if (useActions) {
onSelect(data, currentNode, path);
}
};
const typeofCurrentNode = getCurrentNodeType(data);
const currentNodePath = getCurrentPath(path, currentNode);
const toExpandNode = (data instanceof Array || data instanceof Object) && !_.isEmpty(data);
const toShowNodeIndicator = (data instanceof Array || data instanceof Object) && typeofCurrentNode !== 'Function';
const numberOfEntries = getLength(typeofCurrentNode, data);
const toRenderSelector = (typeofCurrentNode === 'Object' || typeofCurrentNode === 'Array') && numberOfEntries > 0;
let $VALUE = null;
let $NODEType = null;
let $NODEIcon = null;
const checkSelectedNode = (_selectedNode, _currentNode, parent, toExpand) => {
if (selectedNode?.parent && parent) {
return _selectedNode.parent === parent && _selectedNode?.node === _currentNode && toExpand;
}
return toExpand && _selectedNode?.node === _currentNode;
};
const parent = path && typeof path?.length === 'number' ? path[path.length - 2] : null;
const applySelectedNodeStyles = toExpandNode
? checkSelectedNode(selectedNode, currentNode, parent, expandable)
: false;
React.useEffect(() => {
if (!expandable) {
updateSelectedNode(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandable]);
React.useEffect(() => {
if (selectedNode?.node === currentNode) {
set(true);
}
}, [selectedNode, currentNode]);
React.useEffect(() => {
if (hoveredNode?.node === currentNode && hoveredNode?.parent === parent) {
setShowHiddenOptionsForNode(true);
}
return () => {
setShowHiddenOptionsForNode(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredNode]);
if (toUseNodeIcons && currentNode) {
$NODEIcon = renderNodeIcons(currentNode);
}
switch (typeofCurrentNode) {
case 'String':
case 'Boolean':
case 'Number':
case 'Null':
case 'Undefined':
case 'Function':
$VALUE = <JSONNodeValue data={data} type={typeofCurrentNode} />;
$NODEType = <JSONNode.DisplayNodeLabel type={typeofCurrentNode} />;
break;
case 'Object':
$VALUE = <JSONNodeObject data={data} path={currentNodePath} {...restProps} />;
$NODEType = (
<JSONNode.DisplayNodeLabel type={'Object'}>
<span className="mx-1 fs-9 node-length-color">
{`${numberOfEntries} ${numberOfEntries > 1 ? 'entries' : 'entry'}`}{' '}
</span>
</JSONNode.DisplayNodeLabel>
);
break;
case 'Array':
$VALUE = <JSONNodeArray data={data} path={currentNodePath} {...restProps} />;
$NODEType = (
<JSONNode.DisplayNodeLabel type={'Array'}>
<span className="mx-1 fs-9 node-length-color">
{`${numberOfEntries} ${numberOfEntries > 1 ? 'items' : 'item'}`}{' '}
</span>
</JSONNode.DisplayNodeLabel>
);
break;
default:
$VALUE = <span>{String(data)}</span>;
$NODEType = typeofCurrentNode;
}
let $key = (
<span
onClick={() => toExpandNode && handleOnClickLabels(data, currentNode, path)}
style={{ marginTop: '1px', cursor: 'pointer', textTransform: 'none' }}
className={cx('node-key fs-12 mx-0 badge badge-outline', {
'color-primary': applySelectedNodeStyles && !showHiddenOptionsForNode,
'hovered-node': showHiddenOptionsForNode,
'node-key-outline': !applySelectedNodeStyles && !showHiddenOptionsForNode,
})}
>
{String(currentNode)}
</span>
);
if (!currentNode) {
return $VALUE;
}
const shouldDisplayIntendedBlock =
useIndentedBlock && expandable && (typeofCurrentNode === 'Object' || typeofCurrentNode === 'Array');
function moreActionsPopover(actions) {
//Todo: For adding more actions to the menu popover!
const darkMode = localStorage.getItem('darkMode') === 'true';
return (
<Popover
id="popover-basic popover-positioned-right json-tree-popover"
style={{ maxWidth: '350px', padding: '0px' }}
className={`shadow ${darkMode && 'popover-dark-themed theme-dark'}`}
>
<div className="list-group">
{actions?.map((action, index) => (
<span
key={index}
type="button"
className="list-group-item list-group-item-action popover-more-actions"
aria-current="true"
onClick={() => {
action.dispatchAction(data, currentNode);
updateParentState();
}}
>
{action.name}
</span>
))}
</div>
</Popover>
);
}
const renderHiddenOptionsForNode = () => {
const moreActions = actionsList.filter((action) => action.for === 'all')[0];
const renderOptions = () => {
if (!useActions || showHiddenOptionButtons?.length === 0) return null;
return showHiddenOptionButtons?.map((actionOption, index) => {
const { name, icon, src, iconName, dispatchAction, width = 12, height = 12 } = actionOption;
if (icon) {
return (
<ToolTip key={`${name}-${index}`} message={`${name} ${currentNode}`}>
<span
style={{ height: '13px', width: '13px' }}
className="mx-1"
onClick={() => dispatchAction(data, currentNode)}
>
<img src={src ?? `/assets/images/icons/${iconName}.svg`} width={width} height={height} />
</span>
</ToolTip>
);
}
});
};
return (
<div style={{ fontSize: '9px', marginTop: '0px' }} className="d-flex end-0 position-absolute">
{enableCopyToClipboard && (
<CopyToClipboardComponent data={currentNodePath} path={true} callback={getAbsoluteNodePath} />
)}
{renderOptions()}
{moreActions.actions?.length > 0 && (
<OverlayTrigger
rootClose={true}
rootCloseEvent="mousedown"
trigger="click"
placement={'right'}
overlay={moreActionsPopover(moreActions?.actions)}
>
<span>
<ToolTip message={'More actions'}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon-tabler icon-tabler-dots-vertical"
width="13"
height="13"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="#2c3e50"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="12" cy="5" r="1" />
</svg>
</ToolTip>
</span>
</OverlayTrigger>
)}
</div>
);
};
return (
<div
className={cx('d-flex row-flex mt-1 font-monospace container-fluid px-1', {
'json-node-element': !expandable,
})}
>
<div className={`json-tree-icon-container mx-2 ${applySelectedNodeStyles && 'selected-node'}`}>
<JSONNodeIndicator
toExpand={expandable}
toShowNodeIndicator={toShowNodeIndicator}
handleToggle={toggleExpandNode}
typeofCurrentNode={typeofCurrentNode}
currentNode={currentNode}
isSelected={selectedNode?.node === currentNode}
toExpandNode={toExpandNode}
data={data}
path={currentNodePath}
toExpandWithLabels={expandWithLabels}
toggleWithLabels={handleOnClickLabels}
/>
</div>
<div
style={{ width: 'inherit' }}
className={`${shouldDisplayIntendedBlock && 'group-border'} ${applySelectedNodeStyles && 'selected-node'}`}
>
<div
onMouseEnter={() => updateHoveredNode(currentNode, currentNodePath)}
onMouseLeave={() => updateHoveredNode(null)}
className={cx('d-flex', {
'group-object-container': shouldDisplayIntendedBlock,
'mx-2': typeofCurrentNode !== 'Object' && typeofCurrentNode !== 'Array',
})}
>
{$NODEIcon && <div className="json-tree-icon-container">{$NODEIcon}</div>}
{$key} {$NODEType}
{!toExpandNode && !expandable && !toRenderSelector ? $VALUE : null}
<div className="action-icons-group">{showHiddenOptionsForNode && renderHiddenOptionsForNode()}</div>
</div>
{toRenderSelector && (toExpandNode && !expandable ? null : $VALUE)}
</div>
</div>
);
};
const DisplayNodeLabel = ({ type = '', children }) => {
if (type === 'Null' || type === 'Undefined') {
return null;
}
return (
<>
<span className="mx-1 fs-10 node-type">{type}</span>
{children}
</>
);
};
JSONNode.DisplayNodeLabel = DisplayNodeLabel;

View file

@ -0,0 +1,21 @@
import React from 'react';
import { JSONNode } from './JSONNode';
const JSONTreeArrayNode = ({ data, path, ...restProps }) => {
const keys = [];
for (let i = 0; i < data.length; i++) {
keys.push(String(i));
}
return keys.map((key, index) => {
const currentPath = [...path, key];
const _currentNode = key;
const props = { ...restProps };
props.currentNode = _currentNode;
return <JSONNode key={`arr-${key}/${index}`} data={data[Number(key)]} path={currentPath} {...props} />;
});
};
export default JSONTreeArrayNode;

View file

@ -0,0 +1,53 @@
import React from 'react';
const JSONTreeNodeIndicator = ({ toExpand, toShowNodeIndicator, handleToggle, ...restProps }) => {
const {
renderCustomIndicator,
typeofCurrentNode,
currentNode,
isSelected,
toExpandNode,
data,
path,
toExpandWithLabels,
toggleWithLabels,
} = restProps;
const defaultStyles = {
transform: toExpandNode && toExpand ? 'rotate(90deg)' : 'rotate(0deg)',
transition: '0.2s all',
display: 'inline-block',
cursor: 'pointer',
};
const handleToggleForNode = () => {
if (toExpandWithLabels) {
return toggleWithLabels(data, currentNode, path);
}
return handleToggle(currentNode);
};
const renderDefaultIndicator = () => (
<svg width="6" height="10" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.02063 1L5.01032 5.01028L1.00003 8.99997"
stroke={`${toExpand && isSelected ? '#4D72FA' : '#61656F'}`}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
if (!toShowNodeIndicator && (typeofCurrentNode !== 'Object' || typeofCurrentNode !== 'Array')) return null;
return (
<React.Fragment>
<span className="json-tree-node-icon" onClick={handleToggleForNode} style={defaultStyles}>
{renderCustomIndicator ? renderCustomIndicator() : renderDefaultIndicator()}
</span>
</React.Fragment>
);
};
export default JSONTreeNodeIndicator;

View file

@ -0,0 +1,17 @@
import React from 'react';
import { JSONNode } from './JSONNode';
const JSONTreeObjectNode = ({ data, path, ...restProps }) => {
const nodeKeys = Object.keys(data);
return nodeKeys.map((key, index) => {
const currentPath = [...path, key];
const _currentNode = key;
const props = { ...restProps };
props.currentNode = _currentNode;
return <JSONNode key={`obj-${key}/${index}`} data={data[key]} path={currentPath} {...props} />;
});
};
export default JSONTreeObjectNode;

View file

@ -0,0 +1,31 @@
import React from 'react';
const JSONTreeValueNode = ({ data, type }) => {
if (type === 'Function') {
const functionString = `${data.toString().split('{')[0].trim()}{...}`;
return (
<React.Fragment>
<span
className={`text-secondary node-value-${type}`}
style={{ fontSize: '12px', fontFamily: 'monospace', textTransform: 'none' }}
>
{functionString}
</span>
</React.Fragment>
);
}
const value = type === 'String' ? `"${data}"` : String(data);
const clsForUndefinedOrNull = (type === 'Undefined' || type === 'Null') && 'badge badge-secondary';
return (
<span
className={`mx-2 json-tree-valuetype json-tree-node-${String(
type
).toLowerCase()} text-break ${clsForUndefinedOrNull}`}
>
{value}
</span>
);
};
export default JSONTreeValueNode;

View file

@ -0,0 +1,229 @@
import _ from 'lodash';
import React from 'react';
import { JSONNode } from './JSONNode';
import ErrorBoundary from '@/Editor/ErrorBoundary';
export class JSONTreeViewer extends React.Component {
constructor(props) {
super(props);
this.state = {
data: this.props.data,
shouldExpandNode: false,
currentNode: 'Root',
selectedNode: null,
hoveredNode: null,
darkTheme: false,
showHideActions: false,
enableCopyToClipboard: false,
actionsList: [],
};
}
componentDidUpdate(prevProps, prevState) {
if (!_.isEqual(prevProps, this.props)) {
this.setState({
data: this.props.data,
shouldExpandNode: this.props.shouldExpandNode,
...this.props,
});
}
if (prevState.selectedComponent !== this.state.selectedComponent && this.props.treeType === 'inspector') {
if (this.getCurrentNodeType(this.state.data) === 'Object') {
const matchedWidget = Object.keys(this.state.data.components).filter(
(component) => this.state.data.components[component].id === this.state.selectedComponent.id
)[0];
if (matchedWidget) {
this.setState(
{
selectedWidget: matchedWidget,
},
() => {
this.updateSelectedNode(matchedWidget);
}
);
}
}
}
}
getCurrentNodePath(path, node) {
let currentPath = path ?? [];
if (node) {
if (!currentPath[currentPath.length - 1] === node) {
currentPath = [...currentPath, node];
}
}
return currentPath;
}
getCurrentNodeType(node) {
const typeofCurrentNode = Object.prototype.toString.call(node).slice(8, -1);
//Todo: Handle more types (Custom type or Iterable type)
return typeofCurrentNode;
}
getLength(type, collection) {
if (!collection) return 0;
if (type === 'Object') {
return Object.keys(collection).length;
} else if (type === 'Array') {
return collection.length;
}
return 0;
}
renderNodeIcons = (node) => {
const icon = this.props.iconsList.filter((icon) => icon?.iconName === node)[0];
if (icon && icon.iconPath) {
return (
<img
style={{ maxWidth: 'none', padding: '2px' }}
className={`json-tree-svg-icon ${icon.className}`}
src={icon.iconPath}
/>
);
}
if (icon && icon.jsx) {
return icon.jsx();
}
};
updateSelectedNode = (node, path) => {
if (node) {
this.setState({
selectedNode: { node: node, parent: path?.length ? path[path.length - 2] : null },
});
}
};
updateHoveredNode = (node, path) => {
this.setState({
hoveredNode: { node: node, parent: path?.length ? path[path.length - 2] : null },
});
};
getDispatchActionsForNode = (node) => {
if (!node) return null;
return this.state.actionsList.filter((action) => action.for === node)[0];
};
getNodeShowHideComponents = (currentNode, path) => {
const showHideComponents = [];
const parent = path ? path[path.length - 2] : 'root';
const dispatchActionForCurrentNode = this.getDispatchActionsForNode(parent);
if (currentNode === parent) return;
if (dispatchActionForCurrentNode && dispatchActionForCurrentNode['enableFor1stLevelChildren']) {
dispatchActionForCurrentNode['actions'].map((action) => showHideComponents.push(action));
}
return showHideComponents;
//Todo: if actions should be available for all children
};
getOnSelectLabelDispatchActions = (currentNode, path) => {
const actions = [];
const parent = path ? path[path.length - 2] : 'root';
const dispatchActionForCurrentNode = this.getDispatchActionsForNode(parent);
if (currentNode === parent) return;
if (dispatchActionForCurrentNode && dispatchActionForCurrentNode['enableFor1stLevelChildren']) {
dispatchActionForCurrentNode['actions'].map((action) => actions.push(action));
}
//Todo: if actions should be available for all children
return actions;
};
getAbsoluteNodePath = (path) => {
const data = this.state.data;
if (!data || _.isEmpty(data)) return null;
const map = new Map();
// loop through the data and build the map
const buildMap = (data, path = '') => {
const keys = Object.keys(data);
keys.forEach((key) => {
const value = data[key];
const _type = Object.prototype.toString.call(value).slice(8, -1);
let newPath = '';
if (path === '') {
newPath = key;
} else {
newPath = `${path}.${key}`;
}
if (_.isObject(value) || _.isArray(value)) {
buildMap(value, newPath);
} else if (_.isFunction(value)) {
map.set(newPath, { type: _type });
} else {
map.set(newPath, { type: _type });
}
});
};
const computeAbsolutePath = (path) => {
let prevPath, prevType, prevRelPath, currentPath, abs;
for (let i = 0; i < path.length; i++) {
prevType = map.get(prevRelPath)?.type;
const node = path[i];
currentPath = prevRelPath ? `${prevRelPath}.${node}` : node;
if (prevType === 'Object') {
abs = `${prevPath}.${node}`;
} else if (prevType === 'Array') {
abs = `${prevPath}[${node}]`;
} else {
abs = currentPath;
}
prevPath = abs;
prevRelPath = currentPath;
}
return abs;
};
buildMap(data);
return computeAbsolutePath(path);
};
render() {
return (
<div className="json-tree-container row-flex container-fluid p-0">
<ErrorBoundary showFallback={true}>
<JSONNode
data={this.state.data}
shouldExpandNode={false}
getCurrentPath={this.getCurrentNodePath}
getCurrentNodeType={this.getCurrentNodeType}
toUseNodeIcons={this.props.useIcons ?? false}
getLength={this.getLength}
renderNodeIcons={this.renderNodeIcons}
useIndentedBlock={this.props.useIndentedBlock ?? false}
selectedNode={this.state.selectedNode}
hoveredNode={this.state.hoveredNode}
selectedWidget={this.state.selectedWidget ?? null}
updateSelectedNode={this.updateSelectedNode}
updateHoveredNode={this.updateHoveredNode}
useActions={this.state.useActions}
actionsList={this.state.actionsList}
enableCopyToClipboard={this.state.enableCopyToClipboard}
getNodeShowHideComponents={this.getNodeShowHideComponents}
getOnSelectLabelDispatchActions={this.getOnSelectLabelDispatchActions}
expandWithLabels={this.props.expandWithLabels ?? false} //expand and collapse: onclick of label
getAbsoluteNodePath={this.getAbsoluteNodePath}
updateParentState={this.state.updateParentState}
/>
</ErrorBoundary>
</div>
);
}
}

View file

@ -0,0 +1,3 @@
import { JSONTreeViewer } from './JSONTreeViewer';
export default JSONTreeViewer;

View file

@ -110,6 +110,7 @@ module.exports = {
apiUrl: `${API_URL[environment] || ''}/api`,
SERVER_IP: process.env.SERVER_IP,
COMMENT_FEATURE_ENABLE: true,
ENABLE_MULTIPLAYER_EDITING: true,
}),
},
};

View file

@ -56,4 +56,4 @@
"cy:open": "cypress open --env db.name=$TEST_PG_DB,db.user=$TEST_PG_USERNAME,db.password=$TEST_PG_PASSWORD",
"prepare": "husky install"
}
}
}

View file

@ -27,6 +27,7 @@
"@tooljet-plugins/mssql": "file:packages/mssql",
"@tooljet-plugins/mysql": "file:packages/mysql",
"@tooljet-plugins/n8n": "file:packages/n8n",
"@tooljet-plugins/notion": "file:packages/notion",
"@tooljet-plugins/openapi": "file:packages/openapi",
"@tooljet-plugins/oracledb": "file:packages/oracledb",
"@tooljet-plugins/postgresql": "file:packages/postgresql",
@ -4068,6 +4069,18 @@
"node": ">= 8"
}
},
"node_modules/@notionhq/client": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@notionhq/client/-/client-1.0.4.tgz",
"integrity": "sha512-m7zZ5l3RUktayf1lRBV1XMb8HSKsmWTv/LZPqP7UGC1NMzOlc+bbTOPNQ4CP/c1P4cP61VWLb/zBq7a3c0nMaw==",
"dependencies": {
"@types/node-fetch": "^2.5.10",
"node-fetch": "^2.6.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@npmcli/ci-detect": {
"version": "1.4.0",
"dev": true,
@ -4459,8 +4472,9 @@
}
},
"node_modules/@sindresorhus/is": {
"version": "4.2.0",
"license": "MIT",
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"engines": {
"node": ">=10"
},
@ -4570,6 +4584,10 @@
"resolved": "packages/n8n",
"link": true
},
"node_modules/@tooljet-plugins/notion": {
"resolved": "packages/notion",
"link": true
},
"node_modules/@tooljet-plugins/openapi": {
"resolved": "packages/openapi",
"link": true
@ -8416,6 +8434,11 @@
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz",
"integrity": "sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg=="
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -10696,11 +10719,9 @@
"license": "ISC"
},
"node_modules/json5": {
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5"
},
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"bin": {
"json5": "lib/cli.js"
},
@ -16529,9 +16550,98 @@
"version": "1.0.0",
"dependencies": {
"@tooljet-plugins/common": "file:../common",
"got": "^12.0.3",
"json5": "^2.2.1",
"react": "^17.0.2"
}
},
"packages/baserow/node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"packages/baserow/node_modules/cacheable-lookup": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
"integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==",
"engines": {
"node": ">=10.6.0"
}
},
"packages/baserow/node_modules/got": {
"version": "12.0.4",
"resolved": "https://registry.npmjs.org/got/-/got-12.0.4.tgz",
"integrity": "sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==",
"dependencies": {
"@sindresorhus/is": "^4.6.0",
"@szmarczak/http-timer": "^5.0.1",
"@types/cacheable-request": "^6.0.2",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^6.0.4",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"form-data-encoder": "1.7.1",
"get-stream": "^6.0.1",
"http2-wrapper": "^2.1.10",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^3.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"packages/baserow/node_modules/http2-wrapper": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
"integrity": "sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"packages/baserow/node_modules/lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/baserow/node_modules/p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
"integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
"engines": {
"node": ">=12.20"
}
},
"packages/baserow/node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bigquery": {
"name": "@tooljet-plugins/bigquery",
"version": "1.0.0",
@ -16707,11 +16817,109 @@
"react": "^17.0.2"
}
},
"packages/notion": {
"version": "1.0.0",
"dependencies": {
"@notionhq/client": "^1.0.4",
"@tooljet-plugins/common": "file:../common",
"react": "^17.0.2"
}
},
"packages/openapi": {
"name": "@tooljet-plugins/openapi",
"version": "1.0.0",
"dependencies": {
"@tooljet-plugins/common": "file:../common",
"react": "^17.0.2"
"got": "^12.0.3",
"react": "^17.0.2",
"tough-cookie": "^4.0.0"
}
},
"packages/openapi/node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"packages/openapi/node_modules/cacheable-lookup": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
"integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==",
"engines": {
"node": ">=10.6.0"
}
},
"packages/openapi/node_modules/got": {
"version": "12.0.4",
"resolved": "https://registry.npmjs.org/got/-/got-12.0.4.tgz",
"integrity": "sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==",
"dependencies": {
"@sindresorhus/is": "^4.6.0",
"@szmarczak/http-timer": "^5.0.1",
"@types/cacheable-request": "^6.0.2",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^6.0.4",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"form-data-encoder": "1.7.1",
"get-stream": "^6.0.1",
"http2-wrapper": "^2.1.10",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^3.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"packages/openapi/node_modules/http2-wrapper": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
"integrity": "sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"packages/openapi/node_modules/lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/openapi/node_modules/p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
"integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
"engines": {
"node": ">=12.20"
}
},
"packages/openapi/node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/oracledb": {
@ -19982,6 +20190,15 @@
"fastq": "^1.6.0"
}
},
"@notionhq/client": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@notionhq/client/-/client-1.0.4.tgz",
"integrity": "sha512-m7zZ5l3RUktayf1lRBV1XMb8HSKsmWTv/LZPqP7UGC1NMzOlc+bbTOPNQ4CP/c1P4cP61VWLb/zBq7a3c0nMaw==",
"requires": {
"@types/node-fetch": "^2.5.10",
"node-fetch": "^2.6.1"
}
},
"@npmcli/ci-detect": {
"version": "1.4.0",
"dev": true
@ -20277,7 +20494,9 @@
}
},
"@sindresorhus/is": {
"version": "4.2.0"
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
},
"@sinonjs/commons": {
"version": "1.8.3",
@ -20321,7 +20540,68 @@
"version": "file:packages/baserow",
"requires": {
"@tooljet-plugins/common": "file:../common",
"got": "^12.0.3",
"json5": "^2.2.1",
"react": "^17.0.2"
},
"dependencies": {
"@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"requires": {
"defer-to-connect": "^2.0.1"
}
},
"cacheable-lookup": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
"integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A=="
},
"got": {
"version": "12.0.4",
"resolved": "https://registry.npmjs.org/got/-/got-12.0.4.tgz",
"integrity": "sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==",
"requires": {
"@sindresorhus/is": "^4.6.0",
"@szmarczak/http-timer": "^5.0.1",
"@types/cacheable-request": "^6.0.2",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^6.0.4",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"form-data-encoder": "1.7.1",
"get-stream": "^6.0.1",
"http2-wrapper": "^2.1.10",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^3.0.0",
"responselike": "^2.0.0"
}
},
"http2-wrapper": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
"integrity": "sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==",
"requires": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
}
},
"lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="
},
"p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
"integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="
},
"quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="
}
}
},
"@tooljet-plugins/bigquery": {
@ -20461,11 +20741,80 @@
"react": "^17.0.2"
}
},
"@tooljet-plugins/notion": {
"version": "file:packages/notion",
"requires": {
"@notionhq/client": "^1.0.4",
"@tooljet-plugins/common": "file:../common",
"react": "^17.0.2"
}
},
"@tooljet-plugins/openapi": {
"version": "file:packages/openapi",
"requires": {
"@tooljet-plugins/common": "file:../common",
"react": "^17.0.2"
"got": "^12.0.3",
"react": "^17.0.2",
"tough-cookie": "^4.0.0"
},
"dependencies": {
"@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"requires": {
"defer-to-connect": "^2.0.1"
}
},
"cacheable-lookup": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
"integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A=="
},
"got": {
"version": "12.0.4",
"resolved": "https://registry.npmjs.org/got/-/got-12.0.4.tgz",
"integrity": "sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==",
"requires": {
"@sindresorhus/is": "^4.6.0",
"@szmarczak/http-timer": "^5.0.1",
"@types/cacheable-request": "^6.0.2",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^6.0.4",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"form-data-encoder": "1.7.1",
"get-stream": "^6.0.1",
"http2-wrapper": "^2.1.10",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^3.0.0",
"responselike": "^2.0.0"
}
},
"http2-wrapper": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.1.11.tgz",
"integrity": "sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==",
"requires": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
}
},
"lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="
},
"p-cancelable": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
"integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="
},
"quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="
}
}
},
"@tooljet-plugins/oracledb": {
@ -23219,6 +23568,11 @@
"mime-types": "^2.1.12"
}
},
"form-data-encoder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz",
"integrity": "sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg=="
},
"fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -24718,10 +25072,9 @@
"version": "5.0.1"
},
"json5": {
"version": "2.2.0",
"requires": {
"minimist": "^1.2.5"
}
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA=="
},
"jsonfile": {
"version": "6.1.0",

View file

@ -1,4 +1,4 @@
import { ConnectionTestResult, QueryService, QueryResult } from '@tooljet-plugins/common';
import { ConnectionTestResult, QueryService, QueryResult, QueryError } from '@tooljet-plugins/common';
import { getDocument, updateDocument } from './operations';
import { indexDocument, search } from './operations';
import { Client } from '@opensearch-project/opensearch';
@ -27,6 +27,7 @@ export default class ElasticsearchService implements QueryService {
}
} catch (err) {
console.log(err);
throw new QueryError('Query could not be completed', err.message, {});
}
return {

View file

@ -1,13 +1,13 @@
import { Body, Controller, Post, Request } from '@nestjs/common';
import { Body, Controller, Param, Post } from '@nestjs/common';
import { OauthService } from '../services/oauth/oauth.service';
@Controller('oauth')
export class OauthController {
constructor(private oauthService: OauthService) {}
@Post('sign-in')
async create(@Request() req, @Body() body) {
const result = await this.oauthService.signIn(body);
@Post('sign-in/:configId')
async create(@Param('configId') configId, @Body() body) {
const result = await this.oauthService.signIn(body, configId);
return result;
}
}

View file

@ -5,35 +5,47 @@ import UserResponse from './models/user_response';
@Injectable()
export class GitOAuthService {
constructor(private readonly configService: ConfigService) {
this.clientId = this.configService.get<string>('SSO_GIT_OAUTH2_CLIENT_ID');
this.clientSecret = this.configService.get<string>('SSO_GIT_OAUTH2_CLIENT_SECRET');
}
private readonly clientId: string;
private readonly clientSecret: string;
constructor(private readonly configService: ConfigService) {}
private readonly authUrl = 'https://github.com/login/oauth/access_token';
private readonly getUserUrl = 'https://api.github.com/user';
private readonly getUserEmailUrl = 'https://api.github.com/user/emails';
async #getUserDetails({ access_token }: AuthResponse): Promise<UserResponse> {
const response: any = await got(this.getUserUrl, {
method: 'get',
headers: { Accept: 'application/json', Authorization: `token ${access_token}` },
}).json();
const { name, email } = response;
const { name } = response;
let { email } = response;
const words = name?.split(' ');
const firstName = words?.[0] || '';
const lastName = words?.length > 1 ? words[words.length - 1] : '';
if (!email) {
// email visibility not set to public
email = await this.#getEmailId(access_token);
}
return { userSSOId: access_token, firstName, lastName, email, sso: 'git' };
}
async signIn(code: string): Promise<any> {
async #getEmailId(access_token: string) {
const response: any = await got(this.getUserEmailUrl, {
method: 'get',
headers: { Accept: 'application/json', Authorization: `token ${access_token}` },
}).json();
return response?.find((emails) => emails.primary)?.email;
}
async signIn(code: string, configs: any): Promise<any> {
const response: any = await got(this.authUrl, {
method: 'post',
headers: { Accept: 'application/json' },
json: { client_id: this.clientId, client_secret: this.clientSecret, code },
json: { client_id: configs.clientId, client_secret: configs.clientSecret, code },
}).json();
return await this.#getUserDetails(response);
}
}

View file

@ -1,32 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OAuth2Client, TokenPayload } from 'google-auth-library';
import UserResponse from './models/user_response';
@Injectable()
export class GoogleOAuthService {
constructor(private readonly configService: ConfigService) {
this.clientId = this.configService.get<string>('SSO_GOOGLE_OAUTH2_CLIENT_ID');
this.client = new OAuth2Client(this.clientId);
}
private readonly client: OAuth2Client;
private readonly clientId: string;
constructor() {}
#extractDetailsFromPayload(payload: TokenPayload): UserResponse {
const email = payload.email;
const userSSOId = payload.sub;
const domain = payload.hd;
const words = payload.name?.split(' ');
const firstName = words?.[0] || '';
const lastName = words?.length > 1 ? words[words.length - 1] : '';
return { userSSOId, firstName, lastName, email, domain, sso: 'google' };
return { userSSOId, firstName, lastName, email, sso: 'google' };
}
async signIn(token: string): Promise<UserResponse> {
const ticket = await this.client.verifyIdToken({
async signIn(token: string, configs: any): Promise<UserResponse> {
const client: OAuth2Client = new OAuth2Client(configs.clientId);
const ticket = await client.verifyIdToken({
idToken: token,
audience: this.clientId,
audience: configs.clientId,
});
const payload = ticket.getPayload();
return this.#extractDetailsFromPayload(payload);

View file

@ -3,6 +3,5 @@ export default interface UserResponse {
firstName?: string;
lastName?: string;
email: string;
domain?: string;
sso: string;
}

View file

@ -1,6 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { User } from 'src/entities/user.entity';
import { OrganizationsService } from '@services/organizations.service';
import { OrganizationUsersService } from '@services/organization_users.service';
@ -9,6 +8,9 @@ import { GoogleOAuthService } from './google_oauth.service';
import { decamelizeKeys } from 'humps';
import { GitOAuthService } from './git_oauth.service';
import UserResponse from './models/user_response';
import { OrganizationUser } from 'src/entities/organization_user.entity';
import { Organization } from 'src/entities/organization.entity';
import { SSOConfigs } from 'src/entities/sso_config.entity';
@Injectable()
export class OauthService {
@ -18,12 +20,14 @@ export class OauthService {
private readonly jwtService: JwtService,
private readonly organizationUsersService: OrganizationUsersService,
private readonly googleOAuthService: GoogleOAuthService,
private readonly gitOAuthService: GitOAuthService,
private readonly configService: ConfigService
private readonly gitOAuthService: GitOAuthService
) {}
#isValidDomain(domain: string): boolean {
const restrictedDomain = this.configService.get<string>('SSO_RESTRICTED_DOMAIN');
#isValidDomain(email: string, restrictedDomain: string): boolean {
if (!email) {
return false;
}
const domain = email.substring(email.lastIndexOf('@') + 1);
if (!restrictedDomain) {
return true;
@ -34,6 +38,7 @@ export class OauthService {
if (
!restrictedDomain
.split(',')
.map((e) => e && e.trim())
.filter((e) => !!e)
.includes(domain)
) {
@ -42,63 +47,71 @@ export class OauthService {
return true;
}
async #findOrCreateUser({ userSSOId, firstName, lastName, email, sso }: UserResponse): Promise<User> {
const organization = await this.organizationService.findFirst();
async #findOrCreateUser({ firstName, lastName, email }: UserResponse, organization: Organization): Promise<User> {
const { user, newUserCreated } = await this.usersService.findOrCreateByEmail(
{ firstName, lastName, email, ssoId: userSSOId, sso },
organization
{ firstName, lastName, email },
organization.id
);
if (newUserCreated) {
const organizationUser = await this.organizationUsersService.create(user, organization);
await this.organizationUsersService.activate(organizationUser);
} else if (userSSOId) {
await this.usersService.updateSSODetails(user, { userSSOId, sso });
}
return user;
}
async #findAndActivateUser(email: string): Promise<User> {
const user = await this.usersService.findByEmail(email);
async #findAndActivateUser(email: string, organizationId: string): Promise<User> {
const user = await this.usersService.findByEmail(email, organizationId);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException('User not exist in the organization');
}
const organizationUser: OrganizationUser = user.organizationUsers?.[0];
if (!organizationUser) {
throw new UnauthorizedException('User not exist in the organization');
}
const organizationUser = user.organizationUsers[0];
if (organizationUser.status != 'active') await this.organizationUsersService.activate(organizationUser);
return user;
}
async #generateLoginResultPayload(user: User): Promise<any> {
const JWTPayload: JWTPayload = { username: user.id, sub: user.email, ssoId: user.ssoId, sso: user.sso };
async #generateLoginResultPayload(user: User, organization: Organization): Promise<any> {
const JWTPayload: JWTPayload = { username: user.id, sub: user.email, organizationId: organization.id };
user.organizationId = organization.id;
return decamelizeKeys({
id: user.id,
auth_token: this.jwtService.sign(JWTPayload),
email: user.email,
first_name: user.firstName,
last_name: user.lastName,
organizationId: organization.id,
organization: organization.name,
admin: await this.usersService.hasGroup(user, 'admin'),
group_permissions: await this.usersService.groupPermissions(user),
app_group_permissions: await this.usersService.appGroupPermissions(user),
});
}
async signIn(ssoResponse: SSOResponse): Promise<any> {
const ssoSignUpDisabled =
this.configService.get<string>('SSO_DISABLE_SIGNUP') &&
this.configService.get<string>('SSO_DISABLE_SIGNUP') === 'true';
async signIn(ssoResponse: SSOResponse, configId: string): Promise<any> {
const ssoConfigs: SSOConfigs = await this.organizationService.getConfigs(configId);
const { token, origin } = ssoResponse;
if (!(ssoConfigs && ssoConfigs?.organization)) {
throw new UnauthorizedException();
}
const organization = ssoConfigs.organization;
const { enableSignUp, domain } = ssoConfigs.organization;
const { sso, configs } = ssoConfigs;
const { token } = ssoResponse;
let userResponse: UserResponse;
switch (origin) {
switch (sso) {
case 'google':
userResponse = await this.googleOAuthService.signIn(token);
if (!this.#isValidDomain(userResponse.domain))
throw new UnauthorizedException(`You cannot sign in using a ${userResponse.domain} id`);
userResponse = await this.googleOAuthService.signIn(token, configs);
break;
case 'git':
userResponse = await this.gitOAuthService.signIn(token);
userResponse = await this.gitOAuthService.signIn(token, configs);
break;
default:
@ -108,28 +121,33 @@ export class OauthService {
if (!(userResponse.userSSOId && userResponse.email)) {
throw new UnauthorizedException('Invalid credentials');
}
const user: User = await (ssoSignUpDisabled
? this.#findAndActivateUser(userResponse.email)
: this.#findOrCreateUser(userResponse));
if (!this.#isValidDomain(userResponse.email, domain)) {
throw new UnauthorizedException(`You cannot sign in using the mail id - Domain verification failed`);
}
// If name not found
if (!(userResponse.firstName && userResponse.lastName)) {
userResponse.firstName = userResponse.email?.split('@')?.[0];
}
const user: User = await (!enableSignUp
? this.#findAndActivateUser(userResponse.email, organization.id)
: this.#findOrCreateUser(userResponse, organization));
if (!user) {
throw new UnauthorizedException(`Email id ${userResponse.email} is not registered`);
}
return await this.#generateLoginResultPayload(user);
return await this.#generateLoginResultPayload(user, organization);
}
}
interface SSOResponse {
token: string;
origin: 'google' | 'git';
state?: string;
redirectUri?: string;
}
interface JWTPayload {
username: string;
sub: string;
ssoId: string;
sso: string;
organizationId: string;
}

View file

@ -11,7 +11,7 @@ export class PopulateUserGroupsFromOrganizationRoles1632468258787 implements Mig
const entityManager = queryRunner.manager;
const OrganizationRepository = entityManager.getRepository(Organization);
const organizations = await OrganizationRepository.find();
const organizations = await OrganizationRepository.find({ select: ['id'] });
for (const organization of organizations) {
const groupPermissions = await setupInitialGroupPermissions(entityManager, organization);

View file

@ -13,7 +13,9 @@ import { cloneDeep } from 'lodash';
export class BackfillDataSourcesAndQueriesForAppVersions1639734070615 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const organizations = await entityManager.find(Organization);
const organizations = await entityManager.find(Organization, {
select: ['id', 'name'],
});
const nestApp = await NestFactory.createApplicationContext(AppModule);
const dataSourcesService = nestApp.get(DataSourcesService);

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class MultiOrganization1645864719155 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'organization_users',
new TableColumn({
name: 'invitation_token',
type: 'varchar',
isNullable: true,
})
);
await queryRunner.dropColumn('users', 'sso');
await queryRunner.dropColumn('users', 'sso_id');
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class OrganizationConfigs1646823984673 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'sso_configs',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'organization_id',
type: 'uuid',
isNullable: false,
},
{
name: 'sso',
type: 'varchar',
isNullable: false,
},
{
name: 'configs',
type: 'json',
isNullable: true,
},
{
name: 'enabled',
type: 'boolean',
default: true,
},
{
name: 'created_at',
type: 'timestamp',
isNullable: true,
default: 'now()',
},
{
name: 'updated_at',
type: 'timestamp',
isNullable: true,
default: 'now()',
},
],
}),
true
);
await queryRunner.createForeignKey(
'sso_configs',
new TableForeignKey({
columnNames: ['organization_id'],
referencedColumnNames: ['id'],
referencedTableName: 'organizations',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class OrganizationEnableSignup1650455299630 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns('organizations', [
new TableColumn({
name: 'enable_sign_up',
type: 'boolean',
default: false,
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -0,0 +1,105 @@
import { Organization } from 'src/entities/organization.entity';
import { SSOConfigs } from 'src/entities/sso_config.entity';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { EncryptionService } from 'src/services/encryption.service';
export class PopulateSSOConfigs1650485473528 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const encryptionService = new EncryptionService();
const OrganizationRepository = entityManager.getRepository(Organization);
const isSingleOrganization = process.env.MULTI_ORGANIZATION !== 'true';
const enableSignUp = process.env.SSO_DISABLE_SIGNUP !== 'true';
const domain = process.env.SSO_RESTRICTED_DOMAIN;
const googleEnabled = !!process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID;
const googleConfigs = {
clientId: process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID,
};
const gitEnabled = !!process.env.SSO_GIT_OAUTH2_CLIENT_ID;
const gitConfigs = {
clientId: process.env.SSO_GIT_OAUTH2_CLIENT_ID,
clientSecret:
process.env.SSO_GIT_OAUTH2_CLIENT_SECRET &&
(await encryptionService.encryptColumnValue(
'ssoConfigs',
'clientSecret',
process.env.SSO_GIT_OAUTH2_CLIENT_SECRET
)),
};
const passwordEnabled = process.env.DISABLE_PASSWORD_LOGIN !== 'true';
const organizations: Organization[] = await OrganizationRepository.find({
relations: ['ssoConfigs'],
select: ['ssoConfigs', 'id'],
});
if (organizations && organizations.length > 0) {
for (const organization of organizations) {
await OrganizationRepository.update({ id: organization.id }, { enableSignUp, ...(domain ? { domain } : {}) });
// adding form configs for organizations which does not have any
if (
!organization.ssoConfigs?.some((og) => {
og?.sso === 'form';
})
) {
await entityManager
.createQueryBuilder()
.insert()
.into(SSOConfigs, ['organizationId', 'sso', 'enabled'])
.values({
organizationId: organization.id,
sso: 'form',
enabled: !isSingleOrganization ? true : passwordEnabled,
})
.execute();
}
if (
isSingleOrganization &&
googleEnabled &&
!organization.ssoConfigs?.some((og) => {
og?.sso === 'google';
})
) {
await entityManager
.createQueryBuilder()
.insert()
.into(SSOConfigs, ['organizationId', 'sso', 'enabled', 'configs'])
.values({
organizationId: organization.id,
sso: 'google',
enabled: googleEnabled,
configs: googleConfigs,
})
.execute();
}
if (
isSingleOrganization &&
gitEnabled &&
!organization.ssoConfigs?.some((og) => {
og?.sso === 'git';
})
) {
await entityManager
.createQueryBuilder()
.insert()
.into(SSOConfigs, ['organizationId', 'sso', 'enabled', 'configs'])
.values({
organizationId: organization.id,
sso: 'git',
enabled: gitEnabled,
configs: gitConfigs,
})
.execute();
}
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -77,6 +77,7 @@ const imports = [
MetaModule,
LibraryAppModule,
GroupPermissionsModule,
EventsModule,
];
if (process.env.SERVE_CLIENT !== 'false') {
@ -98,7 +99,7 @@ if (process.env.APM_VENDOR == 'sentry') {
}
if (process.env.COMMENT_FEATURE_ENABLE !== 'false') {
imports.unshift(CommentModule, ThreadModule, EventsModule);
imports.unshift(CommentModule, ThreadModule);
}
@Module({

View file

@ -1,22 +1,30 @@
import { Controller, Get, Request, Post, UseGuards, Body, Param, BadRequestException } from '@nestjs/common';
import { User } from 'src/decorators/user.decorator';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { AppAuthenticationDto, AppForgotPasswordDto, AppPasswordResetDto } from '@dto/app-authentication.dto';
import { Controller, Get, Request, Post, UseGuards, Body } from '@nestjs/common';
import { PasswordLoginDisabledGuard } from 'src/modules/auth/password-login-disabled.guard';
import { AuthService } from '../services/auth.service';
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(PasswordLoginDisabledGuard)
@Post('authenticate')
async login(@Body() appAuthDto: AppAuthenticationDto) {
return this.authService.login(appAuthDto);
@Post(['authenticate', 'authenticate/:organizationId'])
async login(@Body() appAuthDto: AppAuthenticationDto, @Param('organizationId') organizationId) {
return this.authService.login(appAuthDto.email, appAuthDto.password, organizationId);
}
@UseGuards(JwtAuthGuard)
@Get('switch/:organizationId')
async switch(@Param('organizationId') organizationId, @User() user) {
if (!organizationId) {
throw new BadRequestException();
}
return await this.authService.switchOrganization(organizationId, user);
}
@UseGuards(PasswordLoginDisabledGuard)
@Post('signup')
async signup(@Body() appAuthDto: AppAuthenticationDto) {
return this.authService.signup(appAuthDto);
return this.authService.signup(appAuthDto.email);
}
@Post('/forgot_password')

View file

@ -23,7 +23,7 @@ export class AppUsersController {
const { role } = params;
const app = await this.appsService.find(appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, { id: appId });
const ability = await this.appsAbilityFactory.appsActions(req.user, appId);
if (!ability.can('createUsers', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');

View file

@ -1,16 +1,4 @@
import {
Controller,
ForbiddenException,
Get,
Param,
Post,
Put,
Delete,
Query,
Request,
UseGuards,
Body,
} from '@nestjs/common';
import { Controller, ForbiddenException, Get, Param, Post, Put, Delete, Query, UseGuards, Body } from '@nestjs/common';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { AppsService } from '../services/apps.service';
import { camelizeKeys, decamelizeKeys } from 'humps';
@ -19,6 +7,7 @@ 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';
import { User } from 'src/decorators/user.decorator';
import { AppUpdateDto } from '@dto/app-update.dto';
import { VersionCreateDto } from '@dto/version-create.dto';
@ -33,26 +22,26 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@Post()
async create(@Request() req) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
async create(@User() user) {
const ability = await this.appsAbilityFactory.appsActions(user);
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);
const app = await this.appsService.create(user);
const appUpdateDto = new AppUpdateDto();
appUpdateDto.slug = app.id;
await this.appsService.update(req.user, app.id, appUpdateDto);
await this.appsService.update(user, app.id, appUpdateDto);
return decamelizeKeys(app);
}
@UseGuards(JwtAuthGuard)
@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);
async show(@User() user, @Param('id') id) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('viewApp', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
@ -86,19 +75,17 @@ export class AppsController {
@UseGuards(AppAuthGuard) // This guard will allow access for unauthenticated user if the app is public
@Get('slugs/:slug')
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, {
id: app.id,
});
async appFromSlug(@User() user, @Param('slug') slug) {
if (user) {
const app = await this.appsService.findBySlug(slug);
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('viewApp', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
}
const app = await this.appsService.findBySlug(params.slug);
const app = await this.appsService.findBySlug(slug);
const versionToLoad = app.currentVersionId
? await this.appsService.findVersion(app.currentVersionId)
: await this.appsService.findVersion(app.editingVersion?.id);
@ -117,15 +104,15 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@Put(':id')
async update(@Request() req, @Param() params, @Body('app') appUpdateDto: AppUpdateDto) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
async update(@User() user, @Param('id') id, @Body('app') appUpdateDto: AppUpdateDto) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('updateParams', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.update(req.user, params.id, appUpdateDto);
const result = await this.appsService.update(user, id, appUpdateDto);
const response = decamelizeKeys(result);
return response;
@ -133,15 +120,15 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@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, params);
async clone(@User() user, @Param('id') id) {
const existingApp = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('cloneApp', existingApp)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.clone(existingApp, req.user);
const result = await this.appsService.clone(existingApp, user);
const response = decamelizeKeys(result);
return response;
@ -149,15 +136,15 @@ export class AppsController {
@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);
async export(@User() user, @Param('id') id) {
const appToExport = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
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);
const app = await this.appImportExportService.export(user, id);
return {
...app,
tooljetVersion: globalThis.TOOLJET_VERSION,
@ -166,28 +153,28 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@Post('/import')
async import(@Request() req, @Body() body) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
async import(@User() user, @Body() body) {
const ability = await this.appsAbilityFactory.appsActions(user);
if (!ability.can('createApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
await this.appImportExportService.import(req.user, body);
await this.appImportExportService.import(user, 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, params);
async delete(@User() user, @Param('id') id) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('deleteApp', app)) {
throw new ForbiddenException('Only administrators are allowed to delete apps.');
}
const result = await this.appsService.delete(params.id);
const result = await this.appsService.delete(id);
const response = decamelizeKeys(result);
return response;
@ -195,7 +182,7 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@Get()
async index(@Request() req, @Query() query) {
async index(@User() user, @Query() query) {
const page = query.page;
const folderId = query.folder;
const searchKey = query.searchKey || '';
@ -205,15 +192,15 @@ export class AppsController {
if (folderId) {
const folder = await this.foldersService.findOne(folderId);
apps = await this.foldersService.getAppsFor(req.user, folder, page, searchKey);
totalFolderCount = await this.foldersService.userAppCount(req.user, folder, searchKey);
apps = await this.foldersService.getAppsFor(user, folder, page, searchKey);
totalFolderCount = await this.foldersService.userAppCount(user, folder, searchKey);
} else {
apps = await this.appsService.all(req.user, page, searchKey);
apps = await this.appsService.all(user, page, searchKey);
}
//remove password from user info
apps.forEach((app) => (app.user.password = undefined));
const totalCount = await this.appsService.count(req.user, searchKey);
const totalCount = await this.appsService.count(user, searchKey);
const totalPageCount = folderId ? totalFolderCount : totalCount;
@ -235,44 +222,44 @@ export class AppsController {
// 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, params);
async fetchUsers(@User() user, @Param('id') id) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('fetchUsers', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.fetchUsers(req.user, params.id);
const result = await this.appsService.fetchUsers(user, id);
return decamelizeKeys({ users: result });
}
@UseGuards(JwtAuthGuard)
@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, params);
async fetchVersions(@User() user, @Param('id') id) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('fetchVersions', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.fetchVersions(req.user, params.id);
const result = await this.appsService.fetchVersions(user, id);
return { versions: result };
}
@UseGuards(JwtAuthGuard)
@Post(':id/versions')
async createVersion(@Request() req, @Param() params, @Body() versionCreateDto: VersionCreateDto) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
async createVersion(@User() user, @Param('id') id, @Body() versionCreateDto: VersionCreateDto) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('createVersions', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const appUser = await this.appsService.createVersion(
req.user,
user,
app,
versionCreateDto.versionName,
versionCreateDto.versionFromId
@ -282,38 +269,38 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@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, params);
async version(@User() user, @Param('id') id, @Param('versionId') versionId) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('fetchVersions', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const appVersion = await this.appsService.findVersion(params.versionId);
const appVersion = await this.appsService.findVersion(versionId);
return { ...appVersion, data_queries: appVersion.dataQueries };
}
@UseGuards(JwtAuthGuard)
@Put(':id/versions/:versionId')
async updateVersion(@Request() req, @Param() params, @Body('definition') definition) {
const version = await this.appsService.findVersion(params.versionId);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
async updateVersion(@User() user, @Param('id') id, @Param('versionId') versionId, @Body('definition') definition) {
const version = await this.appsService.findVersion(versionId);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('updateVersions', version.app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const appUser = await this.appsService.updateVersion(req.user, version, definition);
const appUser = await this.appsService.updateVersion(user, version, definition);
return decamelizeKeys(appUser);
}
@UseGuards(JwtAuthGuard)
@Delete(':id/versions/:versionId')
async deleteVersion(@Request() req, @Param() params) {
const version = await this.appsService.findVersion(params.versionId);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
async deleteVersion(@User() user, @Param('id') id, @Param('versionId') versionId) {
const version = await this.appsService.findVersion(versionId);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!version || !ability.can('deleteVersions', version.app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
@ -324,9 +311,9 @@ export class AppsController {
@UseGuards(JwtAuthGuard)
@Put(':id/icons')
async updateIcon(@Request() req, @Param() params, @Body('icon') icon) {
const app = await this.appsService.find(params.id);
const ability = await this.appsAbilityFactory.appsActions(req.user, params);
async updateIcon(@User() user, @Param('id') id, @Body('icon') icon) {
const app = await this.appsService.find(id);
const ability = await this.appsAbilityFactory.appsActions(user, id);
if (!ability.can('updateIcon', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
@ -334,7 +321,7 @@ export class AppsController {
const appUpdateDto = new AppUpdateDto();
appUpdateDto.icon = icon;
const appUser = await this.appsService.update(req.user, params.id, appUpdateDto);
const appUser = await this.appsService.update(user, id, appUpdateDto);
return decamelizeKeys(appUser);
}
}

View file

@ -1,6 +1,5 @@
import {
Controller,
Request,
Get,
Post,
Body,
@ -17,34 +16,35 @@ import { Comment } from '../entities/comment.entity';
import { Thread } from '../entities/thread.entity';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { CommentsAbilityFactory } from 'src/modules/casl/abilities/comments-ability.factory';
import { User } from 'src/decorators/user.decorator';
@Controller('comments')
export class CommentController {
constructor(private commentService: CommentService, private commentsAbilityFactory: CommentsAbilityFactory) {}
@UseGuards(JwtAuthGuard)
@Post('create')
public async createComment(@Request() req, @Body() createCommentDto: CreateCommentDto): Promise<Comment> {
@Post()
public async createComment(@User() user, @Body() createCommentDto: CreateCommentDto): Promise<Comment> {
const _response = await Thread.findOne({
where: { id: createCommentDto.threadId },
});
const ability = await this.commentsAbilityFactory.appsActions(req.user, { id: _response.appId });
const ability = await this.commentsAbilityFactory.appsActions(user, { id: _response.appId });
if (!ability.can('createComment', Comment)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const comment = await this.commentService.createComment(createCommentDto, req.user.id, req.user.organization.id);
const comment = await this.commentService.createComment(createCommentDto, user.id, user.organizationId);
return comment;
}
@UseGuards(JwtAuthGuard)
@Get('/:threadId/all')
public async getComments(@Request() req, @Param('threadId') threadId: string, @Query() query): Promise<Comment[]> {
public async getComments(@User() user, @Param('threadId') threadId: string, @Query() query): Promise<Comment[]> {
const _response = await Thread.findOne({
where: { id: threadId },
});
const ability = await this.commentsAbilityFactory.appsActions(req.user, { id: _response.appId });
const ability = await this.commentsAbilityFactory.appsActions(user, { id: _response.appId });
if (!ability.can('fetchComments', Comment)) {
throw new ForbiddenException('You do not have permissions to perform this action');
@ -56,18 +56,13 @@ export class CommentController {
@UseGuards(JwtAuthGuard)
@Get('/:appId/notifications')
public async getNotifications(@Request() req, @Param('appId') appId: string, @Query() query): Promise<Comment[]> {
const ability = await this.commentsAbilityFactory.appsActions(req.user, { id: appId });
public async getNotifications(@User() user, @Param('appId') appId: string, @Query() query): Promise<Comment[]> {
const ability = await this.commentsAbilityFactory.appsActions(user, { id: appId });
if (!ability.can('fetchComments', Comment)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const comments = await this.commentService.getNotifications(
appId,
req.user.id,
query.isResolved,
query.appVersionsId
);
const comments = await this.commentService.getNotifications(appId, user.id, query.isResolved, query.appVersionsId);
return comments;
}
@ -79,9 +74,9 @@ export class CommentController {
}
@UseGuards(JwtAuthGuard)
@Patch('/edit/:commentId')
@Patch('/:commentId')
public async editComment(
@Request() req,
@User() user,
@Body() updateCommentDto: UpdateCommentDto,
@Param('commentId') commentId: string
): Promise<Comment> {
@ -89,7 +84,7 @@ export class CommentController {
where: { id: commentId },
relations: ['thread'],
});
const ability = await this.commentsAbilityFactory.appsActions(req.user, { id: _response.thread.appId });
const ability = await this.commentsAbilityFactory.appsActions(user, { id: _response.thread.appId });
if (!ability.can('updateComment', Comment)) {
throw new ForbiddenException('You do not have permissions to perform this action');
@ -99,13 +94,13 @@ export class CommentController {
}
@UseGuards(JwtAuthGuard)
@Delete('/delete/:commentId')
public async deleteComment(@Request() req, @Param('commentId') commentId: string) {
@Delete('/:commentId')
public async deleteComment(@User() user, @Param('commentId') commentId: string) {
const _response = await Comment.findOne({
where: { id: commentId },
relations: ['thread'],
});
const ability = await this.commentsAbilityFactory.appsActions(req.user, { id: _response.thread.appId });
const ability = await this.commentsAbilityFactory.appsActions(user, { id: _response.thread.appId });
if (!ability.can('deleteComment', Comment)) {
throw new ForbiddenException('You do not have permissions to perform this action');

View file

@ -7,7 +7,6 @@ import {
Patch,
Delete,
Query,
Request,
UseGuards,
ForbiddenException,
} from '@nestjs/common';
@ -19,6 +18,7 @@ import { QueryAuthGuard } from 'src/modules/auth/query-auth.guard';
import { AppsAbilityFactory } from 'src/modules/casl/abilities/apps-ability.factory';
import { AppsService } from '@services/apps.service';
import { CreateDataQueryDto, UpdateDataQueryDto } from '@dto/data-query.dto';
import { User } from 'src/decorators/user.decorator';
@Controller('data_queries')
export class DataQueriesController {
@ -31,17 +31,15 @@ export class DataQueriesController {
@UseGuards(JwtAuthGuard)
@Get()
async index(@Request() req, @Query() query) {
async index(@User() user, @Query() query) {
const app = await this.appsService.find(query.app_id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: query.app_id,
});
const ability = await this.appsAbilityFactory.appsActions(user, query.app_id);
if (!ability.can('getQueries', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
}
const queries = await this.dataQueriesService.all(req.user, query);
const queries = await this.dataQueriesService.all(user, query);
const seralizedQueries = [];
// serialize
@ -59,16 +57,14 @@ export class DataQueriesController {
@UseGuards(JwtAuthGuard)
@Post()
async create(@Request() req, @Body() dataQueryDto: CreateDataQueryDto): Promise<object> {
async create(@User() user, @Body() dataQueryDto: CreateDataQueryDto): Promise<object> {
const { kind, name, options, app_id, app_version_id, data_source_id } = dataQueryDto;
const appId = app_id;
const appVersionId = app_version_id;
const dataSourceId = data_source_id;
const app = await this.appsService.find(appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: appId,
});
const ability = await this.appsAbilityFactory.appsActions(user, appId);
if (!ability.can('createQuery', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -83,7 +79,7 @@ export class DataQueriesController {
}
const dataQuery = await this.dataQueriesService.create(
req.user,
user,
name,
kind,
options,
@ -96,32 +92,28 @@ export class DataQueriesController {
@UseGuards(JwtAuthGuard)
@Patch(':id')
async update(@Request() req, @Param() params, @Body() updateDataQueryDto: UpdateDataQueryDto) {
async update(@User() user, @Param() params, @Body() updateDataQueryDto: UpdateDataQueryDto) {
const { name, options } = updateDataQueryDto;
const dataQueryId = params.id;
const dataQuery = await this.dataQueriesService.findOne(dataQueryId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQuery.appId,
});
const ability = await this.appsAbilityFactory.appsActions(user, dataQuery.appId);
if (!ability.can('updateQuery', dataQuery.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
}
const result = await this.dataQueriesService.update(req.user, dataQueryId, name, options);
const result = await this.dataQueriesService.update(user, dataQueryId, name, options);
return decamelizeKeys(result);
}
@UseGuards(JwtAuthGuard)
@Delete(':id')
async delete(@Request() req, @Param() params) {
async delete(@User() user, @Param() params) {
const dataQueryId = params.id;
const dataQuery = await this.dataQueriesService.findOne(dataQueryId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQuery.appId,
});
const ability = await this.appsAbilityFactory.appsActions(user, dataQuery.appId);
if (!ability.can('deleteQuery', dataQuery.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -133,16 +125,13 @@ export class DataQueriesController {
@UseGuards(QueryAuthGuard)
@Post(':id/run')
async runQuery(@Request() req, @Param() params, @Body() updateDataQueryDto: UpdateDataQueryDto) {
const dataQueryId = params.id;
async runQuery(@User() user, @Param('id') dataQueryId, @Body() updateDataQueryDto: UpdateDataQueryDto) {
const { options } = updateDataQueryDto;
const dataQuery = await this.dataQueriesService.findOne(dataQueryId);
if (req.user) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQuery.appId,
});
if (user) {
const ability = await this.appsAbilityFactory.appsActions(user, dataQuery.appId);
if (!ability.can('runQuery', dataQuery.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -152,7 +141,7 @@ export class DataQueriesController {
let result = {};
try {
result = await this.dataQueriesService.runQuery(req.user, dataQuery, options);
result = await this.dataQueriesService.runQuery(user, dataQuery, options);
} catch (error) {
if (error.constructor.name === 'QueryError') {
result = {
@ -177,7 +166,7 @@ export class DataQueriesController {
@UseGuards(JwtAuthGuard)
@Post('/preview')
async previewQuery(@Request() req, @Body() updateDataQueryDto: UpdateDataQueryDto) {
async previewQuery(@User() user, @Body() updateDataQueryDto: UpdateDataQueryDto) {
const { options, query } = updateDataQueryDto;
const dataQueryEntity = {
...query,
@ -185,9 +174,7 @@ export class DataQueriesController {
};
if (dataQueryEntity.dataSource) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: dataQueryEntity.dataSource.appId,
});
const ability = await this.appsAbilityFactory.appsActions(user, dataQueryEntity.dataSource.appId);
if (!ability.can('previewQuery', dataQueryEntity.dataSource.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -197,7 +184,7 @@ export class DataQueriesController {
let result = {};
try {
result = await this.dataQueriesService.runQuery(req.user, dataQueryEntity, options);
result = await this.dataQueriesService.runQuery(user, dataQueryEntity, options);
} catch (error) {
if (error.constructor.name === 'QueryError') {
result = {

View file

@ -8,7 +8,6 @@ import {
Delete,
Put,
Query,
Request,
UseGuards,
BadRequestException,
} from '@nestjs/common';
@ -25,6 +24,7 @@ import {
TestDataSourceDto,
UpdateDataSourceDto,
} from '@dto/data-source.dto';
import { User } from 'src/decorators/user.decorator';
@Controller('data_sources')
export class DataSourcesController {
@ -37,17 +37,15 @@ export class DataSourcesController {
@UseGuards(JwtAuthGuard)
@Get()
async index(@Request() req, @Query() query) {
async index(@User() user, @Query() query) {
const app = await this.appsService.find(query.app_id);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('getDataSources', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
}
const dataSources = await this.dataSourcesService.all(req.user, query);
const dataSources = await this.dataSourcesService.all(user, query);
const response = decamelizeKeys({ data_sources: dataSources });
return response;
@ -55,15 +53,13 @@ export class DataSourcesController {
@UseGuards(JwtAuthGuard)
@Post()
async create(@Request() req, @Body() createDataSourceDto: CreateDataSourceDto) {
async create(@User() user, @Body() createDataSourceDto: CreateDataSourceDto) {
const { kind, name, options, app_id, app_version_id } = createDataSourceDto;
const appId = app_id;
const appVersionId = app_version_id;
const app = await this.appsService.find(appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: appId,
});
const ability = await this.appsAbilityFactory.appsActions(user, appId);
if (!ability.can('createDataSource', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -75,16 +71,13 @@ export class DataSourcesController {
@UseGuards(JwtAuthGuard)
@Put(':id')
async update(@Request() req, @Param() params, @Body() updateDataSourceDto: UpdateDataSourceDto) {
const dataSourceId = params.id;
async update(@User() user, @Param('id') dataSourceId, @Body() updateDataSourceDto: UpdateDataSourceDto) {
const { name, options } = updateDataSourceDto;
const dataSource = await this.dataSourcesService.findOne(dataSourceId);
const app = await this.appsService.find(dataSource.appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('updateDataSource', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
@ -96,21 +89,17 @@ export class DataSourcesController {
@UseGuards(JwtAuthGuard)
@Delete(':id')
async delete(@Request() req, @Param() params) {
const dataSourceId = params.id;
async delete(@User() user, @Param('id') dataSourceId) {
const dataSource = await this.dataSourcesService.findOne(dataSourceId);
const app = await this.appsService.find(dataSource.appId);
const ability = await this.appsAbilityFactory.appsActions(req.user, {
id: app.id,
});
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('deleteDataSource', dataSource.app)) {
throw new ForbiddenException('you do not have permissions to perform this action');
}
const result = await this.dataSourcesService.delete(params.id);
const result = await this.dataSourcesService.delete(dataSourceId);
if (result.affected == 1) {
return;
} else {
@ -120,14 +109,14 @@ export class DataSourcesController {
@UseGuards(JwtAuthGuard)
@Post('test_connection')
async testConnection(@Request() req, @Body() testDataSourceDto: TestDataSourceDto) {
const { kind, options } = req.body;
async testConnection(@User() user, @Body() testDataSourceDto: TestDataSourceDto) {
const { kind, options } = testDataSourceDto;
return await this.dataSourcesService.testConnection(kind, options);
}
@UseGuards(JwtAuthGuard)
@Post('fetch_oauth2_base_url')
async getAuthUrl(@Request() req, @Body() getDataSourceOauthUrlDto: GetDataSourceOauthUrlDto) {
async getAuthUrl(@User() user, @Body() getDataSourceOauthUrlDto: GetDataSourceOauthUrlDto) {
const { provider } = getDataSourceOauthUrlDto;
return await this.dataSourcesService.getAuthUrl(provider);
}
@ -135,7 +124,7 @@ export class DataSourcesController {
@UseGuards(JwtAuthGuard)
@Post(':id/authorize_oauth2')
async authorizeOauth2(
@Request() req,
@User() user,
@Param() params,
@Body() authorizeDataSourceOauthDto: AuthorizeDataSourceOauthDto
) {
@ -145,9 +134,7 @@ 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, {
id: app.id,
});
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('authorizeOauthForSource', app)) {
throw new ForbiddenException('you do not have permissions to perform this action');

View file

@ -1,11 +1,12 @@
import { Controller, Body, Post, Get, Put, Delete, Request, UseGuards, Param } from '@nestjs/common';
import { Controller, Body, Post, Get, Put, Delete, 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';
import { User } from 'src/decorators/user.decorator';
import { User as UserEntity } from 'src/entities/user.entity';
import { CreateGroupPermissionDto, UpdateGroupPermissionDto } from '@dto/group-permission.dto';
@Controller('group_permissions')
@ -13,35 +14,35 @@ export class GroupPermissionsController {
constructor(private groupPermissionsService: GroupPermissionsService) {}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Post()
async create(@Request() req, @Body() createGroupPermissionDto: CreateGroupPermissionDto) {
const groupPermission = await this.groupPermissionsService.create(req.user, createGroupPermissionDto.group);
async create(@User() user, @Body() createGroupPermissionDto: CreateGroupPermissionDto) {
const groupPermission = await this.groupPermissionsService.create(user, createGroupPermissionDto.group);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Get(':id')
async show(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.findOne(req.user, params.id);
async show(@User() user, @Param('id') id: string) {
const groupPermission = await this.groupPermissionsService.findOne(user, id);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Put(':id/app_group_permissions/:appGroupPermissionId')
async updateAppGroupPermission(
@Request() req,
@Param() params,
@Body() updateGroupPermissionDto: UpdateGroupPermissionDto
@Body() updateGroupPermissionDto: UpdateGroupPermissionDto,
@User() user,
@Param('id') id: string,
@Param('appGroupPermissionId') appGroupPermissionId: string
) {
const groupPermission = await this.groupPermissionsService.updateAppGroupPermission(
req.user,
params.id,
params.appGroupPermissionId,
user,
id,
appGroupPermissionId,
updateGroupPermissionDto.actions
);
@ -49,64 +50,64 @@ export class GroupPermissionsController {
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Put(':id')
async update(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.update(req.user, params.id, req.body);
async update(@User() user, @Param('id') id, @Body() body) {
const groupPermission = await this.groupPermissionsService.update(user, id, body);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Get()
async index(@Request() req) {
const groupPermissions = await this.groupPermissionsService.findAll(req.user);
async index(@User() user) {
const groupPermissions = await this.groupPermissionsService.findAll(user);
return decamelizeKeys({ groupPermissions });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Delete(':id')
async destroy(@Request() req, @Param() params) {
const groupPermission = await this.groupPermissionsService.destroy(req.user, params.id);
async destroy(@User() user, @Param('id') id) {
const groupPermission = await this.groupPermissionsService.destroy(user, id);
return decamelizeKeys(groupPermission);
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Get(':id/apps')
async apps(@Request() req, @Param() params) {
const apps = await this.groupPermissionsService.findApps(req.user, params.id);
async apps(@User() user, @Param('id') id) {
const apps = await this.groupPermissionsService.findApps(user, id);
return decamelizeKeys({ apps });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Get(':id/addable_apps')
async addableApps(@Request() req, @Param() params) {
const apps = await this.groupPermissionsService.findAddableApps(req.user, params.id);
async addableApps(@User() user, @Param('id') id) {
const apps = await this.groupPermissionsService.findAddableApps(user, id);
return decamelizeKeys({ apps });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Get(':id/users')
async users(@Request() req, @Param() params) {
const users = await this.groupPermissionsService.findUsers(req.user, params.id);
async users(@User() user, @Param('id') id) {
const users = await this.groupPermissionsService.findUsers(user, id);
return decamelizeKeys({ users });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User))
@CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', UserEntity))
@Get(':id/addable_users')
async addableUsers(@Request() req, @Param() params) {
const users = await this.groupPermissionsService.findAddableUsers(req.user, params.id);
async addableUsers(@User() user, @Param('id') id) {
const users = await this.groupPermissionsService.findAddableUsers(user, id);
return decamelizeKeys({ users });
}

View file

@ -1,5 +1,6 @@
import { Controller, Post, Request, Param, UseGuards, Get, ForbiddenException } from '@nestjs/common';
import { Controller, Post, UseGuards, Get, ForbiddenException, Body } from '@nestjs/common';
import { LibraryAppCreationService } from '@services/library_app_creation.service';
import { User } from 'src/decorators/user.decorator';
import { App } from 'src/entities/app.entity';
import { AppsAbilityFactory } from 'src/modules/casl/abilities/apps-ability.factory';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
@ -14,21 +15,20 @@ export class LibraryAppsController {
@Post()
@UseGuards(JwtAuthGuard)
async create(@Request() req, @Param() _params) {
const ability = await this.appsAbilityFactory.appsActions(req.user, {});
async create(@User() user, @Body('identifier') identifier) {
const ability = await this.appsAbilityFactory.appsActions(user);
if (!ability.can('createApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const { identifier } = req.body;
const newApp = await this.libraryAppCreationService.perform(req.user, identifier);
const newApp = await this.libraryAppCreationService.perform(user, identifier);
return newApp;
}
@Get()
@UseGuards(JwtAuthGuard)
async index(@Request() _req, @Param() _params) {
async index() {
return { template_app_manifests: TemplateAppManifests };
}
}

View file

@ -1,12 +1,13 @@
import { Controller, Param, Post, Request, UseGuards, Body } from '@nestjs/common';
import { Controller, Param, Post, UseGuards, Body } from '@nestjs/common';
import { OrganizationUsersService } from 'src/services/organization_users.service';
import { decamelizeKeys } from 'humps';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { AppAbility } from 'src/modules/casl/casl-ability.factory';
import { PoliciesGuard } from 'src/modules/casl/policies.guard';
import { CheckPolicies } from 'src/modules/casl/check_policies.decorator';
import { User as UserEntity } from 'src/entities/user.entity';
import { User } from 'src/decorators/user.decorator';
import { InviteNewUserDto } from '../dto/invite-new-user.dto';
import { User } from 'src/entities/user.entity';
@Controller('organization_users')
export class OrganizationUsersController {
@ -14,35 +15,35 @@ export class OrganizationUsersController {
// Endpoint for inviting new organization users
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('inviteUser', User))
@CheckPolicies((ability: AppAbility) => ability.can('inviteUser', UserEntity))
@Post()
async create(@Request() req, @Body() inviteNewUserDto: InviteNewUserDto) {
const result = await this.organizationUsersService.inviteNewUser(req.user, inviteNewUserDto);
async create(@User() user, @Body() inviteNewUserDto: InviteNewUserDto) {
const result = await this.organizationUsersService.inviteNewUser(user, inviteNewUserDto);
return decamelizeKeys({ users: result });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('archiveUser', User))
@CheckPolicies((ability: AppAbility) => ability.can('archiveUser', UserEntity))
@Post(':id/archive')
async archive(@Request() req, @Param() params) {
const result = await this.organizationUsersService.archive(params.id);
async archive(@Param('id') id: string) {
const result = await this.organizationUsersService.archive(id);
return decamelizeKeys({ result });
}
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('archiveUser', User))
@CheckPolicies((ability: AppAbility) => ability.can('archiveUser', UserEntity))
@Post(':id/unarchive')
async unarchive(@Request() req, @Param() params) {
const result = await this.organizationUsersService.unarchive(req.user, params.id);
async unarchive(@User() user, @Param('id') id: string) {
const result = await this.organizationUsersService.unarchive(user, id);
return decamelizeKeys({ result });
}
// Deprecated
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('changeRole', User))
@CheckPolicies((ability: AppAbility) => ability.can('changeRole', UserEntity))
@Post(':id/change_role')
async changeRole(@Request() req, @Param() params) {
const result = await this.organizationUsersService.changeRole(req.user, params.id, req.body.role);
async changeRole(@Param('id') id, @Body('role') role) {
const result = await this.organizationUsersService.changeRole(id, role);
return decamelizeKeys({ result });
}
}

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